Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36a6101026 | ||
|
|
cfc98c600e | ||
|
|
e206e1fc94 | ||
|
|
9cf1177b73 | ||
|
|
a7048f6d8e | ||
|
|
06feb85aa5 | ||
|
|
aa0677be57 | ||
|
|
974fce5870 | ||
|
|
682b93f2d6 | ||
|
|
18f0929614 | ||
|
|
1709149ebd | ||
|
|
2664074210 | ||
|
|
9868991900 | ||
|
|
0d15ca588a | ||
|
|
1c1416cc1a | ||
|
|
1735098d81 | ||
|
|
b5da6875d7 | ||
|
|
4b6a088e38 | ||
|
|
f8304aed90 | ||
|
|
40f76013dc | ||
|
|
4e2c6d210b | ||
|
|
7d8ddcccef | ||
|
|
f853d14421 | ||
|
|
f2360d570f | ||
|
|
749234b8b0 | ||
|
|
be8e5ee46b | ||
|
|
687c216e65 | ||
|
|
26630e5ffd | ||
|
|
fee690744d | ||
|
|
79f4ec4bde | ||
|
|
61bfdac7cc | ||
|
|
1e648800ad | ||
|
|
8a2cabdba9 | ||
|
|
4d1bd063e8 | ||
|
|
b8397b5ecb |
@@ -74,12 +74,38 @@ jobs:
|
||||
|
||||
- name: Include AIUI if available
|
||||
run: |
|
||||
if [ -d "/opt/archipelago/web-ui/aiui" ] && [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then
|
||||
mkdir -p web/dist/neode-ui/aiui
|
||||
cp -r /opt/archipelago/web-ui/aiui/* web/dist/neode-ui/aiui/
|
||||
echo "AIUI included from /opt/archipelago/web-ui/aiui/"
|
||||
# AIUI (the Claude chat sidebar) lives outside the Vue build
|
||||
# and must be copied into the frontend dist BEFORE packaging,
|
||||
# otherwise OTA-tarball upgrades silently strip it from nodes
|
||||
# in the field. Try in order: cached on runner, then the
|
||||
# newest release tarball in this repo's releases/ dir as a
|
||||
# fallback so a freshly-provisioned runner still gets AIUI.
|
||||
AIUI_SRC=""
|
||||
if [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then
|
||||
AIUI_SRC="/opt/archipelago/web-ui/aiui"
|
||||
elif [ -f "$HOME/archy/web/dist/neode-ui/aiui/index.html" ]; then
|
||||
AIUI_SRC="$HOME/archy/web/dist/neode-ui/aiui"
|
||||
else
|
||||
echo "WARNING: AIUI not found on build server — ISO will not include AIUI"
|
||||
LATEST_FRONTEND=$(ls -t releases/v*/archipelago-frontend-*.tar.gz 2>/dev/null | head -1)
|
||||
if [ -n "$LATEST_FRONTEND" ]; then
|
||||
echo "Extracting AIUI from $LATEST_FRONTEND (runner cache miss)"
|
||||
TMP=$(mktemp -d)
|
||||
tar xzf "$LATEST_FRONTEND" -C "$TMP" ./aiui 2>/dev/null || true
|
||||
if [ -f "$TMP/aiui/index.html" ]; then
|
||||
AIUI_SRC="$TMP/aiui"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ -n "$AIUI_SRC" ]; then
|
||||
mkdir -p web/dist/neode-ui/aiui
|
||||
cp -r "$AIUI_SRC/"* web/dist/neode-ui/aiui/
|
||||
echo "AIUI included from $AIUI_SRC ($(du -sh web/dist/neode-ui/aiui | cut -f1))"
|
||||
else
|
||||
echo "FAIL: AIUI not found anywhere (runner cache + release tarballs)"
|
||||
echo " checked: /opt/archipelago/web-ui/aiui"
|
||||
echo " \$HOME/archy/web/dist/neode-ui/aiui"
|
||||
echo " releases/v*/archipelago-frontend-*.tar.gz"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Configure root podman for insecure registry
|
||||
@@ -93,7 +119,15 @@ jobs:
|
||||
run: |
|
||||
cd image-recipe
|
||||
export ARCHIPELAGO_BIN="$(pwd)/../core/target/release/archipelago"
|
||||
ls -la "$ARCHIPELAGO_BIN" || echo "WARNING: binary not found"
|
||||
if [ ! -x "$ARCHIPELAGO_BIN" ]; then
|
||||
echo "FAIL: backend binary missing or not executable at $ARCHIPELAGO_BIN"
|
||||
exit 1
|
||||
fi
|
||||
BIN_VERSION=$(strings "$ARCHIPELAGO_BIN" | grep -oE 'archipelago [0-9]+\.[0-9]+\.[0-9]+(-[a-z]+)?' | head -1 || true)
|
||||
EXPECTED=$(grep '^version' ../core/archipelago/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
echo "Binary: $ARCHIPELAGO_BIN ($(du -h "$ARCHIPELAGO_BIN" | cut -f1))"
|
||||
echo "Embedded version string: ${BIN_VERSION:-unknown}"
|
||||
echo "Expected version (Cargo.toml): $EXPECTED"
|
||||
sudo -E UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 \
|
||||
ARCHIPELAGO_BIN="$ARCHIPELAGO_BIN" \
|
||||
./build-auto-installer-iso.sh
|
||||
|
||||
@@ -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.4-alpha"
|
||||
version = "1.7.37-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.4-alpha"
|
||||
version = "1.7.37-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -113,6 +113,80 @@ 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`). 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()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Ok(build_response(
|
||||
hyper::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"text/plain",
|
||||
hyper::Body::from(format!("client build failed: {}", e)),
|
||||
));
|
||||
}
|
||||
};
|
||||
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()
|
||||
.status(hyper::StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "public, max-age=3600")
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap_or_else(|_| {
|
||||
Response::new(hyper::Body::from("proxy response build failed"))
|
||||
}));
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
Ok(build_response(
|
||||
hyper::StatusCode::BAD_GATEWAY,
|
||||
"text/plain",
|
||||
hyper::Body::from("all upstream catalog URLs failed"),
|
||||
))
|
||||
}
|
||||
|
||||
/// Build a 401 Unauthorized JSON response.
|
||||
fn unauthorized() -> Response<hyper::Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
@@ -352,6 +426,18 @@ impl ApiHandler {
|
||||
// Electrs status — unauthenticated (read-only sync status)
|
||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||
|
||||
// App-catalog proxy — fetches catalog.json from the configured
|
||||
// upstream URLs server-side so the browser doesn't hit CORS
|
||||
// (git.tx1138.com has no ACAO header) or CSP (IP-port upstream
|
||||
// falls outside `connect-src`). Session-authenticated so only
|
||||
// the logged-in node owner can spin up fetches.
|
||||
(Method::GET, "/api/app-catalog") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
self.handle_app_catalog_proxy().await
|
||||
}
|
||||
|
||||
// LND connect info — nginx validates session cookie (presence check),
|
||||
// backend is bound to 127.0.0.1 so only nginx can reach it.
|
||||
// No backend auth check here because the LND UI iframe fetches this
|
||||
|
||||
@@ -220,6 +220,7 @@ impl RpcHandler {
|
||||
"registry.list" => self.handle_registry_list().await,
|
||||
"registry.add" => self.handle_registry_add(params).await,
|
||||
"registry.remove" => self.handle_registry_remove(params).await,
|
||||
"registry.set-primary" => self.handle_registry_set_primary(params).await,
|
||||
"registry.test" => self.handle_registry_test(params).await,
|
||||
|
||||
// Streaming ecash payments
|
||||
@@ -413,12 +414,41 @@ impl RpcHandler {
|
||||
"fips.apply-update" => self.handle_fips_apply_update().await,
|
||||
"fips.install" => self.handle_fips_install().await,
|
||||
"fips.restart" => self.handle_fips_restart().await,
|
||||
"fips.reconnect" => self.handle_fips_reconnect().await,
|
||||
"fips.list-seed-anchors" => self.handle_fips_list_seed_anchors().await,
|
||||
"fips.add-seed-anchor" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_fips_add_seed_anchor(&p).await
|
||||
}
|
||||
"fips.remove-seed-anchor" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_fips_remove_seed_anchor(&p).await
|
||||
}
|
||||
"fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().await,
|
||||
|
||||
// System updates
|
||||
"update.check" => self.handle_update_check().await,
|
||||
"update.status" => self.handle_update_status().await,
|
||||
"update.dismiss" => self.handle_update_dismiss().await,
|
||||
"update.download" => self.handle_update_download().await,
|
||||
"update.cancel-download" => self.handle_update_cancel_download().await,
|
||||
"update.list-mirrors" => self.handle_update_list_mirrors().await,
|
||||
"update.add-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_add_mirror(&p).await
|
||||
}
|
||||
"update.remove-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_remove_mirror(&p).await
|
||||
}
|
||||
"update.set-primary-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_set_primary_mirror(&p).await
|
||||
}
|
||||
"update.test-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_test_mirror(&p).await
|
||||
}
|
||||
"update.apply" => self.handle_update_apply().await,
|
||||
"update.git-apply" => self.handle_update_git_apply().await,
|
||||
"update.rollback" => self.handle_update_rollback().await,
|
||||
|
||||
@@ -79,6 +79,7 @@ impl RpcHandler {
|
||||
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||
let local_pubkey = data.server_info.pubkey.clone();
|
||||
let local_name = data.server_info.name.clone();
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
@@ -90,6 +91,7 @@ impl RpcHandler {
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
local_fips_npub.as_deref(),
|
||||
local_name.as_deref(),
|
||||
|data| node_identity.sign(data),
|
||||
)
|
||||
.await?;
|
||||
@@ -447,6 +449,38 @@ impl RpcHandler {
|
||||
.get("fips_npub")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
// Optional, unsigned: peer's display name. Display-only — identity
|
||||
// claims are anchored on the signed did/pubkey below.
|
||||
let incoming_name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Reject self-peering. If somehow our own did / onion / pubkey
|
||||
// comes back at us (misconfigured invite, gossip loop), adding
|
||||
// the entry causes sync loops where the node syncs with itself
|
||||
// forever. Drop it quietly — no useful recovery path.
|
||||
let (own_data, _) = self.state_manager.get_snapshot().await;
|
||||
let own_did_result =
|
||||
identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
|
||||
let own_onion_trim = own_data
|
||||
.server_info
|
||||
.tor_address
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.trim_end_matches(".onion")
|
||||
.to_string();
|
||||
let incoming_onion_trim = onion.trim_end_matches(".onion");
|
||||
if own_did_result.as_deref() == Some(did)
|
||||
|| pubkey == own_data.server_info.pubkey
|
||||
|| (!own_onion_trim.is_empty() && own_onion_trim == incoming_onion_trim)
|
||||
{
|
||||
tracing::warn!(
|
||||
peer_did = %did,
|
||||
"Rejected peer-joined: inbound identity matches this node"
|
||||
);
|
||||
anyhow::bail!("Refusing to peer with self");
|
||||
}
|
||||
|
||||
// Verify ed25519 signature to prevent federation spoofing (H2 security fix)
|
||||
let signature = params.get("signature").and_then(|v| v.as_str());
|
||||
@@ -471,11 +505,12 @@ impl RpcHandler {
|
||||
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
if let Some(existing) = nodes.iter().find(|n| n.did == did) {
|
||||
// If already known but missing onion/pubkey/fips_npub, update them
|
||||
// If already known but missing onion/pubkey/fips_npub/name, update them
|
||||
let needs_onion = existing.onion.is_empty();
|
||||
let needs_pubkey = existing.pubkey.is_empty();
|
||||
let needs_fips = existing.fips_npub.is_none() && fips_npub.is_some();
|
||||
if needs_onion || needs_pubkey || needs_fips {
|
||||
let needs_name = existing.name.is_none() && incoming_name.is_some();
|
||||
if needs_onion || needs_pubkey || needs_fips || needs_name {
|
||||
let mut updated = existing.clone();
|
||||
if needs_onion && !onion.is_empty() {
|
||||
updated.onion = onion.to_string();
|
||||
@@ -486,6 +521,9 @@ impl RpcHandler {
|
||||
if needs_fips {
|
||||
updated.fips_npub = fips_npub.clone();
|
||||
}
|
||||
if needs_name {
|
||||
updated.name = incoming_name.clone();
|
||||
}
|
||||
updated.last_seen = Some(chrono::Utc::now().to_rfc3339());
|
||||
federation::update_node(&self.config.data_dir, &updated).await?;
|
||||
info!(peer_did = %did, peer_onion = %onion, "Updated existing peer with fresh identity fields");
|
||||
@@ -497,7 +535,7 @@ impl RpcHandler {
|
||||
did: did.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
onion: onion.to_string(),
|
||||
name: None,
|
||||
name: incoming_name.clone(),
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: chrono::Utc::now().to_rfc3339(),
|
||||
last_seen: None,
|
||||
@@ -512,9 +550,38 @@ impl RpcHandler {
|
||||
|
||||
// Mirror into mesh state so the inbound peer is addressable from
|
||||
// the chat UI without waiting for the next mesh restart.
|
||||
self.register_federation_peer_in_mesh(pubkey, did, None)
|
||||
self.register_federation_peer_in_mesh(pubkey, did, incoming_name.as_deref())
|
||||
.await;
|
||||
|
||||
// Bump the data-model revision so any Federation view with an
|
||||
// open WebSocket reloads its node list without waiting for the
|
||||
// user to click Sync.
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
self.state_manager.update_data(data).await;
|
||||
|
||||
// Transitive discovery: spawn a task that pulls the new peer's
|
||||
// state (its own federated peers end up as Observer entries on
|
||||
// our side) so after a join every existing peer in our list is
|
||||
// aware of the newcomer via the next pair of syncs, without the
|
||||
// user clicking anything. Best-effort; errors are logged only.
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let new_peer_did = did.to_string();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
if let Err(e) = crate::federation::sync_with_peer_by_did(
|
||||
&data_dir,
|
||||
&new_peer_did,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!(
|
||||
peer_did = %new_peer_did,
|
||||
error = %e,
|
||||
"Transitive sync on peer-joined failed (non-fatal)"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "accepted": true }))
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
let status = fips::FipsStatus::query(&identity_dir).await;
|
||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
@@ -36,12 +35,179 @@ impl RpcHandler {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
fips::config::install(&identity_dir).await?;
|
||||
fips::service::activate(fips::SERVICE_UNIT).await?;
|
||||
let status = fips::FipsStatus::query(&identity_dir).await;
|
||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// Restart whichever fips unit is supervising the daemon on this host.
|
||||
/// Nodes installed from the archipelago ISO use `archipelago-fips.service`;
|
||||
/// nodes that had the upstream debian package set up first may only have
|
||||
/// `fips.service`. We resolve the active one via `service::active_unit()`
|
||||
/// so the UI button is never a no-op.
|
||||
pub(super) async fn handle_fips_restart(&self) -> Result<serde_json::Value> {
|
||||
fips::service::restart(fips::SERVICE_UNIT).await?;
|
||||
Ok(serde_json::json!({ "restarted": true }))
|
||||
let unit = fips::service::active_unit().await;
|
||||
fips::service::restart(unit).await?;
|
||||
Ok(serde_json::json!({ "restarted": true, "unit": unit }))
|
||||
}
|
||||
|
||||
/// Full reconnect: stop the daemon, bring it back, wait for the DHT
|
||||
/// bootstrap window, poll the identity-cache + peer list, and
|
||||
/// classify what recovered (or didn't) so the UI can explain it to
|
||||
/// the user instead of showing a generic failure.
|
||||
///
|
||||
/// Runtime: ~20s. Needs an RPC timeout ≥ 45s on the client.
|
||||
pub(super) async fn handle_fips_reconnect(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
let before = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
|
||||
// Heal the pre-fix bech32-text fips_key.pub → 32-raw-bytes
|
||||
// mismatch. The daemon silently authenticates with a garbage
|
||||
// pubkey when the .pub file is 63-char text, which looks like
|
||||
// "anchor unreachable" to the user even though the real fault
|
||||
// was an identity malformed on the node itself. Re-install the
|
||||
// config + keys so /etc/fips gets the healed .pub.
|
||||
let key_src = identity_dir.join("fips_key");
|
||||
let pub_src = identity_dir.join("fips_key.pub");
|
||||
if key_src.exists() {
|
||||
let _ = fips::config::normalize_pub_file(&key_src, &pub_src).await;
|
||||
// Re-install refreshes /etc/fips/fips.pub from the healed
|
||||
// source. No-op if nothing changed.
|
||||
let _ = fips::config::install(&identity_dir).await;
|
||||
}
|
||||
|
||||
// Operate on whichever fips unit is actually up — nodes that
|
||||
// have the upstream `fips.service` rather than the
|
||||
// archipelago-managed `archipelago-fips.service` used to see
|
||||
// Reconnect silently fail because we stopped a unit that
|
||||
// didn't exist. Clean stop+start rather than `restart` so a
|
||||
// daemon that fails to come back up surfaces as
|
||||
// service_active=false instead of quietly sticking with the
|
||||
// old process.
|
||||
let unit = fips::service::active_unit().await;
|
||||
let _ = fips::service::stop(unit).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
|
||||
fips::service::activate(unit).await?;
|
||||
|
||||
// Re-push seed anchors after restart so freshly-bound daemons
|
||||
// don't have to wait 5 min for the periodic apply loop.
|
||||
if let Ok(list) = fips::anchors::load(&self.config.data_dir).await {
|
||||
if !list.is_empty() {
|
||||
let _ = fips::anchors::apply(&list).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Anchor bootstrap window: poll the status every ~3s for up to
|
||||
// 20s. Bail as soon as the anchor is connected.
|
||||
let mut last_status: Option<fips::FipsStatus> = None;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
let s = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
if s.anchor_connected {
|
||||
last_status = Some(s);
|
||||
break;
|
||||
}
|
||||
last_status = Some(s);
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let after = last_status.unwrap_or_else(|| before.clone());
|
||||
|
||||
let recovered = after.anchor_connected && !before.anchor_connected;
|
||||
let likely_cause = if after.anchor_connected {
|
||||
"connected"
|
||||
} else if !after.service_active {
|
||||
"daemon_down"
|
||||
} else if !after.key_present {
|
||||
"no_seed_key"
|
||||
} else if after.authenticated_peer_count == 0 {
|
||||
// Daemon is up with a key but hasn't authenticated any
|
||||
// peers — almost always outbound UDP/8668 dropped by the
|
||||
// local firewall/router, or the anchor itself being down.
|
||||
"no_outbound_udp_or_anchor_down"
|
||||
} else {
|
||||
"peers_but_no_anchor"
|
||||
};
|
||||
let hint = match likely_cause {
|
||||
"connected" => "An anchor is reachable.",
|
||||
"daemon_down" => "The FIPS daemon didn't come back up — check the FIPS service on this host.",
|
||||
"no_seed_key" => "No seed-derived FIPS key on disk. Re-run the onboarding unlock step.",
|
||||
"no_outbound_udp_or_anchor_down" =>
|
||||
"Daemon is running but no peers handshook. Your router / ISP might be blocking outbound UDP 8668, or every configured anchor could be down. Add a reachable peer in Seed Anchors.",
|
||||
"peers_but_no_anchor" =>
|
||||
"Mesh has peers but none of them are anchors we recognise. Add your cluster's anchor in Seed Anchors.",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"recovered": recovered,
|
||||
"likely_cause": likely_cause,
|
||||
"hint": hint,
|
||||
"before": before,
|
||||
"after": after,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List the seed-anchor entries configured on this node.
|
||||
pub(super) async fn handle_fips_list_seed_anchors(&self) -> Result<serde_json::Value> {
|
||||
let list = fips::anchors::load(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "seed_anchors": list }))
|
||||
}
|
||||
|
||||
/// Add (or update) a seed anchor and immediately push it into the
|
||||
/// running daemon. Params: `{ npub, address, transport?, label? }`.
|
||||
pub(super) async fn handle_fips_add_seed_anchor(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let anchor: fips::anchors::SeedAnchor = serde_json::from_value(params.clone())
|
||||
.map_err(|e| anyhow::anyhow!("bad seed anchor payload: {}", e))?;
|
||||
if !anchor.npub.starts_with("npub1") {
|
||||
anyhow::bail!("npub must be bech32 (npub1...)");
|
||||
}
|
||||
if !anchor.address.contains(':') {
|
||||
anyhow::bail!("address must be host:port (e.g. 192.168.1.116:8668)");
|
||||
}
|
||||
let list =
|
||||
fips::anchors::add(&self.config.data_dir, anchor.clone()).await?;
|
||||
// Push just the newly-added anchor into the running daemon so
|
||||
// the user sees effect without waiting for the periodic apply.
|
||||
let results = fips::anchors::apply(&[anchor]).await;
|
||||
Ok(serde_json::json!({
|
||||
"seed_anchors": list,
|
||||
"apply": results.iter().map(|r| {
|
||||
serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message })
|
||||
}).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Remove a seed anchor by npub. Params: `{ npub }`. Does NOT tear
|
||||
/// down an already-authenticated peer connection — it only stops
|
||||
/// us from re-dialing the anchor on the next apply cycle.
|
||||
pub(super) async fn handle_fips_remove_seed_anchor(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let npub = params
|
||||
.get("npub")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing npub"))?;
|
||||
let list = fips::anchors::remove(&self.config.data_dir, npub).await?;
|
||||
Ok(serde_json::json!({ "seed_anchors": list }))
|
||||
}
|
||||
|
||||
/// Re-apply all seed anchors to the running daemon. Useful after a
|
||||
/// FIPS restart or when the user wants to force a reconnection
|
||||
/// attempt without waiting for the periodic apply loop.
|
||||
pub(super) async fn handle_fips_apply_seed_anchors(&self) -> Result<serde_json::Value> {
|
||||
let list = fips::anchors::load(&self.config.data_dir).await?;
|
||||
let results = fips::anchors::apply(&list).await;
|
||||
Ok(serde_json::json!({
|
||||
"applied": results.len(),
|
||||
"results": results.iter().map(|r| {
|
||||
serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message })
|
||||
}).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +282,7 @@ impl RpcHandler {
|
||||
let local_fips_npub = crate::identity::fips_npub(&identity_dir2)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let local_name = data.server_info.name.clone();
|
||||
match crate::federation::accept_invite(
|
||||
&self.config.data_dir,
|
||||
invite_code,
|
||||
@@ -289,6 +290,7 @@ impl RpcHandler {
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
local_fips_npub.as_deref(),
|
||||
local_name.as_deref(),
|
||||
|bytes| node_identity.sign(bytes),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -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,31 +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);
|
||||
|
||||
/// 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", docker_image])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.args(&pull_args)
|
||||
.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);
|
||||
@@ -677,38 +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: {}", docker_image);
|
||||
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 {
|
||||
// Try all configured fallback registries dynamically
|
||||
match crate::container::registry::pull_from_registries(
|
||||
&self.config.data_dir,
|
||||
docker_image,
|
||||
&user_tmp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 dynamic registry fallback", 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()
|
||||
@@ -735,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)
|
||||
@@ -795,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();
|
||||
@@ -805,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\
|
||||
@@ -1278,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![],
|
||||
};
|
||||
|
||||
@@ -1467,6 +1516,36 @@ server {
|
||||
Ok(serde_json::json!({ "registries": config.registries, "removed": url }))
|
||||
}
|
||||
|
||||
/// Promote a registry to primary by resetting priorities — the named
|
||||
/// URL becomes priority 0, every other enabled registry is bumped up
|
||||
/// by 10. Order is stable (ties broken by original priority).
|
||||
pub(in crate::api::rpc) async fn handle_registry_set_primary(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing url"))?;
|
||||
|
||||
let mut config = crate::container::registry::load_registries(&self.config.data_dir).await?;
|
||||
if !config.registries.iter().any(|r| r.url == url) {
|
||||
return Err(anyhow::anyhow!("Registry '{}' not found", url));
|
||||
}
|
||||
|
||||
// Reassign priorities: target = 0, everyone else = 10, 20, 30…
|
||||
// in their existing priority order.
|
||||
let target_url = url.to_string();
|
||||
config.registries.sort_by_key(|r| (r.url != target_url, r.priority));
|
||||
for (i, r) in config.registries.iter_mut().enumerate() {
|
||||
r.priority = if r.url == target_url { 0 } else { (i as u32) * 10 };
|
||||
}
|
||||
|
||||
crate::container::registry::save_registries(&self.config.data_dir, &config).await?;
|
||||
Ok(serde_json::json!({ "registries": config.registries, "primary": url }))
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_registry_test(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -1481,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()
|
||||
@@ -1496,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"])
|
||||
|
||||
@@ -6,12 +6,18 @@ impl RpcHandler {
|
||||
/// Check for available system updates.
|
||||
/// Tries git-based check first (if repo exists), falls back to manifest-based.
|
||||
pub(super) async fn handle_update_check(&self) -> Result<serde_json::Value> {
|
||||
// Try git-based check first (preferred for beta nodes)
|
||||
// Manifest override: when ARCHIPELAGO_UPDATE_URL is explicitly set,
|
||||
// the operator wants OTA via manifest — typically a dev box where
|
||||
// ~/archy/.git exists but isn't the intended update surface.
|
||||
// Without this short-circuit the dev box always advertises "Pull
|
||||
// & Rebuild" and can never exercise the manifest OTA path.
|
||||
let manifest_override = std::env::var("ARCHIPELAGO_UPDATE_URL").is_ok();
|
||||
|
||||
let repo_dir = std::path::PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
||||
)
|
||||
.join("archy");
|
||||
if repo_dir.join(".git").exists() {
|
||||
if !manifest_override && repo_dir.join(".git").exists() {
|
||||
if let Ok(git_status) = self.git_check_update(&repo_dir).await {
|
||||
return Ok(git_status);
|
||||
}
|
||||
@@ -34,6 +40,7 @@ impl RpcHandler {
|
||||
"last_check": state.last_check,
|
||||
"update_available": update_info.is_some(),
|
||||
"update": update_info,
|
||||
"manifest_mirror": state.manifest_mirror,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -151,6 +158,37 @@ impl RpcHandler {
|
||||
/// Get update status without checking remote.
|
||||
pub(super) async fn handle_update_status(&self) -> Result<serde_json::Value> {
|
||||
let state = update::get_status(&self.config.data_dir).await?;
|
||||
// Expose live download progress so the UI can resume the
|
||||
// progress bar after navigation instead of showing the fake
|
||||
// creep again. An RPC poll every ~1s during download drives a
|
||||
// real progress indicator that survives route changes.
|
||||
let downloaded = update::DOWNLOAD_BYTES
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let total = update::DOWNLOAD_TOTAL
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let active = total > 0 && downloaded < total;
|
||||
let completed = total > 0 && downloaded >= total;
|
||||
|
||||
// Stall detection: if the progress-at timestamp hasn't advanced
|
||||
// for 30+ seconds while active, the download is wedged (usually
|
||||
// HTTP stream silently dropped and reqwest is waiting out its
|
||||
// read timeout). The UI uses this to surface a Cancel button
|
||||
// with explanatory copy.
|
||||
let stalled = if active {
|
||||
let last_at = update::DOWNLOAD_PROGRESS_AT
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if last_at > 0 {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
now.saturating_sub(last_at) > 30_000
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"current_version": state.current_version,
|
||||
@@ -158,6 +196,15 @@ impl RpcHandler {
|
||||
"update_available": state.available_update.is_some(),
|
||||
"update_in_progress": state.update_in_progress,
|
||||
"rollback_available": state.rollback_available,
|
||||
"manifest_mirror": state.manifest_mirror,
|
||||
"download_progress": if active || completed {
|
||||
Some(serde_json::json!({
|
||||
"bytes_downloaded": downloaded,
|
||||
"total_bytes": total,
|
||||
"active": active,
|
||||
"stalled": stalled,
|
||||
}))
|
||||
} else { None },
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -177,6 +224,13 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Cancel an in-flight or stuck download. Clears the live counters
|
||||
/// and staging dir so the UI returns to the "Download Update" state.
|
||||
pub(super) async fn handle_update_cancel_download(&self) -> Result<serde_json::Value> {
|
||||
update::cancel_download(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "canceled": true }))
|
||||
}
|
||||
|
||||
/// Apply the staged update.
|
||||
pub(super) async fn handle_update_apply(&self) -> Result<serde_json::Value> {
|
||||
update::apply_update(&self.config.data_dir).await?;
|
||||
@@ -189,6 +243,89 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "rolled_back": true, "restart_required": true }))
|
||||
}
|
||||
|
||||
/// List configured update mirrors in priority order.
|
||||
pub(super) async fn handle_update_list_mirrors(&self) -> Result<serde_json::Value> {
|
||||
let list = update::load_mirrors(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "mirrors": list }))
|
||||
}
|
||||
|
||||
/// Add a mirror to the end of the list. Params: `{ url, label? }`.
|
||||
/// Duplicates (same URL) are replaced rather than added twice.
|
||||
pub(super) async fn handle_update_add_mirror(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing url"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
anyhow::bail!("url must start with http:// or https://");
|
||||
}
|
||||
let label = params
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let mut list = update::load_mirrors(&self.config.data_dir).await?;
|
||||
list.retain(|m| m.url != url);
|
||||
list.push(update::UpdateMirror { url, label });
|
||||
update::save_mirrors(&self.config.data_dir, &list).await?;
|
||||
Ok(serde_json::json!({ "mirrors": list }))
|
||||
}
|
||||
|
||||
/// Remove a mirror by URL. Params: `{ url }`.
|
||||
pub(super) async fn handle_update_remove_mirror(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
|
||||
let mut list = update::load_mirrors(&self.config.data_dir).await?;
|
||||
list.retain(|m| m.url != url);
|
||||
update::save_mirrors(&self.config.data_dir, &list).await?;
|
||||
Ok(serde_json::json!({ "mirrors": list }))
|
||||
}
|
||||
|
||||
/// Ping a mirror's manifest URL. Returns reachability, wall-clock
|
||||
/// latency, and HTTP status. Params: `{ url }`.
|
||||
pub(super) async fn handle_update_test_mirror(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
|
||||
let result = update::test_mirror(url).await;
|
||||
Ok(serde_json::to_value(result)?)
|
||||
}
|
||||
|
||||
/// Move a mirror to the top of the list so it's tried first.
|
||||
/// Params: `{ url }`.
|
||||
pub(super) async fn handle_update_set_primary_mirror(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
|
||||
let mut list = update::load_mirrors(&self.config.data_dir).await?;
|
||||
let Some(idx) = list.iter().position(|m| m.url == url) else {
|
||||
anyhow::bail!("mirror not in list");
|
||||
};
|
||||
let entry = list.remove(idx);
|
||||
list.insert(0, entry);
|
||||
update::save_mirrors(&self.config.data_dir, &list).await?;
|
||||
Ok(serde_json::json!({ "mirrors": list }))
|
||||
}
|
||||
|
||||
/// Get the current update schedule.
|
||||
pub(super) async fn handle_update_get_schedule(&self) -> Result<serde_json::Value> {
|
||||
let schedule = update::get_schedule(&self.config.data_dir).await?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -44,19 +44,26 @@ impl Default for RegistryConfig {
|
||||
Self {
|
||||
registries: vec![
|
||||
Registry {
|
||||
url: "git.tx1138.com/lfg2025".to_string(),
|
||||
name: "Archipelago Primary".to_string(),
|
||||
tls_verify: true,
|
||||
url: "23.182.128.160:3000/lfg2025".to_string(),
|
||||
name: "Server 1 (VPS)".to_string(),
|
||||
tls_verify: false,
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
},
|
||||
Registry {
|
||||
url: "23.182.128.160:3000/lfg2025".to_string(),
|
||||
name: "Archipelago Fallback".to_string(),
|
||||
tls_verify: false,
|
||||
url: "git.tx1138.com/lfg2025".to_string(),
|
||||
name: "Server 2 (tx1138)".to_string(),
|
||||
tls_verify: true,
|
||||
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,20 +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
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the image name from a full image reference.
|
||||
@@ -104,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() {
|
||||
@@ -113,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)
|
||||
}
|
||||
|
||||
@@ -133,69 +157,6 @@ pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try pulling an image from configured registries in priority order.
|
||||
/// Returns the image reference that succeeded.
|
||||
pub async fn pull_from_registries(data_dir: &Path, image: &str, tmpdir: &str) -> Result<String> {
|
||||
let config = load_registries(data_dir).await?;
|
||||
let candidates = config.image_candidates(image);
|
||||
|
||||
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::*;
|
||||
@@ -217,34 +178,29 @@ mod tests {
|
||||
#[test]
|
||||
fn test_rewrite_image() {
|
||||
let config = RegistryConfig::default();
|
||||
let fallback = &config.registries[1];
|
||||
// Default primary is now VPS (index 0). A tx1138-hardcoded image
|
||||
// rewrites to VPS when asked for the primary mirror.
|
||||
let primary = &config.registries[0];
|
||||
assert_eq!(
|
||||
config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", fallback),
|
||||
config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", primary),
|
||||
"23.182.128.160:3000/lfg2025/bitcoin-knots:latest"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_candidates() {
|
||||
let config = RegistryConfig::default();
|
||||
let candidates = config.image_candidates("git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
|
||||
assert!(candidates.len() >= 2);
|
||||
assert_eq!(candidates[0].0, "git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
|
||||
}
|
||||
|
||||
#[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]
|
||||
@@ -260,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>,
|
||||
|
||||
@@ -121,6 +121,7 @@ pub async fn accept_invite(
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
local_fips_npub: Option<&str>,
|
||||
local_name: Option<&str>,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<FederatedNode> {
|
||||
let ParsedInvite {
|
||||
@@ -131,6 +132,20 @@ pub async fn accept_invite(
|
||||
fips_npub,
|
||||
} = parse_invite(code)?;
|
||||
|
||||
// Refuse self-peering. If the invite's did / onion / pubkey matches
|
||||
// our own, adding it pollutes the federation list with a node that
|
||||
// sees itself as its own peer and causes sync loops. The user
|
||||
// almost certainly pasted the wrong invite.
|
||||
if did == local_did || pubkey == local_pubkey || {
|
||||
let a = onion.trim_end_matches(".onion");
|
||||
let b = local_onion.trim_end_matches(".onion");
|
||||
!a.is_empty() && a == b
|
||||
} {
|
||||
anyhow::bail!(
|
||||
"Refusing to federate with self — invite points at this node's own did / onion / pubkey"
|
||||
);
|
||||
}
|
||||
|
||||
// Make accept idempotent: drop any existing entry that conflicts with
|
||||
// this invite — same DID (same node, refreshing the link), same onion
|
||||
// (node rotated identity but kept its hidden service), or same pubkey
|
||||
@@ -190,6 +205,7 @@ pub async fn accept_invite(
|
||||
local_onion,
|
||||
local_pubkey,
|
||||
local_fips_npub,
|
||||
local_name,
|
||||
sign_fn,
|
||||
)
|
||||
.await;
|
||||
@@ -201,20 +217,22 @@ pub async fn accept_invite(
|
||||
/// Prefers FIPS (if the remote advertised an npub in their invite) and
|
||||
/// falls back to Tor. Signs the message with our ed25519 key so the
|
||||
/// remote peer can verify authenticity regardless of transport.
|
||||
async fn notify_join(
|
||||
pub(crate) async fn notify_join(
|
||||
remote_onion: &str,
|
||||
remote_fips_npub: Option<&str>,
|
||||
local_did: &str,
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
local_fips_npub: Option<&str>,
|
||||
local_name: Option<&str>,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<()> {
|
||||
// Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}"
|
||||
// Signature domain intentionally unchanged — fips_npub is carried
|
||||
// as an unsigned informational field. The FIPS daemon's own Noise
|
||||
// handshake authenticates the actual transport session, so a
|
||||
// stripped/substituted npub here merely downgrades the path to Tor.
|
||||
// Signature domain intentionally unchanged — fips_npub + name are
|
||||
// carried as unsigned informational fields. Name is display-only
|
||||
// (any identity claim is anchored on the signed did/pubkey); the
|
||||
// FIPS daemon's own Noise handshake authenticates the transport
|
||||
// session regardless of the advertised npub.
|
||||
let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey);
|
||||
let signature = sign_fn(sign_data.as_bytes());
|
||||
|
||||
@@ -227,6 +245,9 @@ async fn notify_join(
|
||||
if let Some(npub) = local_fips_npub {
|
||||
params["fips_npub"] = serde_json::Value::String(npub.to_string());
|
||||
}
|
||||
if let Some(name) = local_name {
|
||||
params["name"] = serde_json::Value::String(name.to_string());
|
||||
}
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "federation.peer-joined",
|
||||
@@ -321,6 +342,7 @@ mod tests {
|
||||
"local.onion",
|
||||
"localpub",
|
||||
None,
|
||||
None,
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
@@ -355,6 +377,7 @@ mod tests {
|
||||
"local.onion",
|
||||
"localpub",
|
||||
None,
|
||||
None,
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
@@ -388,6 +411,7 @@ mod tests {
|
||||
"local.onion",
|
||||
"localpub",
|
||||
None,
|
||||
None,
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
@@ -400,6 +424,7 @@ mod tests {
|
||||
"local.onion",
|
||||
"localpub",
|
||||
None,
|
||||
None,
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -17,5 +17,5 @@ pub use storage::{
|
||||
add_node, fips_npub_for_onion, load_nodes, record_peer_transport, remove_node, save_nodes,
|
||||
set_trust_level, update_node,
|
||||
};
|
||||
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer};
|
||||
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did};
|
||||
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
|
||||
|
||||
@@ -82,9 +82,35 @@ pub async fn sync_with_peer(
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Convenience wrapper: look up a federated peer by DID, derive our
|
||||
/// own local_did / signing context from the node identity on disk, and
|
||||
/// call sync_with_peer. Used by transitive-discovery code paths where
|
||||
/// the caller only knows the peer's DID (e.g. the peer-joined RPC's
|
||||
/// follow-up task).
|
||||
pub async fn sync_with_peer_by_did(
|
||||
data_dir: &Path,
|
||||
peer_did: &str,
|
||||
) -> Result<NodeStateSnapshot> {
|
||||
let nodes = super::storage::load_nodes(data_dir).await?;
|
||||
let peer = nodes
|
||||
.into_iter()
|
||||
.find(|n| n.did == peer_did)
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown federation peer: {}", peer_did))?;
|
||||
|
||||
let identity_dir = data_dir.join("identity");
|
||||
let node_identity =
|
||||
crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
let local_pubkey_hex = node_identity.pubkey_hex();
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&local_pubkey_hex)?;
|
||||
|
||||
sync_with_peer(data_dir, &peer, &local_did, |data| node_identity.sign(data)).await
|
||||
}
|
||||
|
||||
/// Merge peers advertised by a Trusted federated node into our own
|
||||
/// federation list. New peers are added at `Observer` trust (not
|
||||
/// Trusted — that requires a direct invite). Existing peers get their
|
||||
/// federation list. New peers are added at `Trusted` — hints only
|
||||
/// arrive from peers we already trust, and `build_local_state` only
|
||||
/// re-exports our Trusted list, so transitive membership carries the
|
||||
/// same trust the direct-invite path gives. Existing peers get their
|
||||
/// `fips_npub` refreshed if we hadn't learned it yet.
|
||||
///
|
||||
/// Peers we are (us) or that we already track by DID are skipped.
|
||||
@@ -118,7 +144,7 @@ async fn merge_transitive_peers(
|
||||
pubkey: hint.pubkey.clone(),
|
||||
onion: hint.onion.clone(),
|
||||
name: hint.name.clone(),
|
||||
trust_level: TrustLevel::Observer,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: chrono::Utc::now().to_rfc3339(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Seed-anchor management for FIPS bootstrap.
|
||||
//!
|
||||
//! A freshly-installed node can't reach the global mesh via npub
|
||||
//! routing until it's connected to at least one peer that's already in
|
||||
//! the DHT. Upstream `fips` solves this by dialing a public anchor
|
||||
//! (e.g. `fips.v0l.io`) on first start. That's a single point of
|
||||
//! failure and doesn't help nodes behind restrictive firewalls or
|
||||
//! intermittent networks — archipelago operators reported fresh
|
||||
//! installs failing to reach any public anchor.
|
||||
//!
|
||||
//! This module adds a local, operator-editable seed-anchor list. Each
|
||||
//! entry is a `{npub, address, transport}` triple that archipelago
|
||||
//! pushes into the running daemon via `fipsctl connect` on startup and
|
||||
//! periodically thereafter. If one anchor falls over, the next one
|
||||
//! seeds the DHT instead. A well-configured cluster (e.g. a VPS
|
||||
//! running fips in anchor mode + a couple of home nodes) stops
|
||||
//! depending on the global anchor entirely.
|
||||
//!
|
||||
//! The list is persisted at `<data_dir>/seed-anchors.json`. The
|
||||
//! archipelago service user owns that directory, so no sudo is needed
|
||||
//! to read or write it.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::process::Command;
|
||||
|
||||
/// On-disk filename under `data_dir/`.
|
||||
const SEED_ANCHORS_FILE: &str = "seed-anchors.json";
|
||||
|
||||
/// Public anchor (`fips.v0l.io`) carried as a default seed for fresh
|
||||
/// installs — the one the upstream daemon dials anyway. Operators can
|
||||
/// remove it from the UI once their own cluster has independent anchors.
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
|
||||
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "fips.v0l.io:8668";
|
||||
|
||||
/// One seed-anchor entry. `address` must be directly dialable (IP or
|
||||
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
|
||||
/// "tor", "ethernet" (the values upstream `fipsctl connect` accepts).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SeedAnchor {
|
||||
/// Bech32 `npub1...` of the anchor's FIPS identity.
|
||||
pub npub: String,
|
||||
/// Directly-dialable transport address, e.g. `192.168.1.116:8668`.
|
||||
pub address: String,
|
||||
/// Transport to use — almost always `"udp"`.
|
||||
#[serde(default = "default_transport")]
|
||||
pub transport: String,
|
||||
/// Human-readable note shown in the UI (e.g. "Home anchor", "VPS").
|
||||
#[serde(default)]
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
fn default_transport() -> String {
|
||||
"udp".to_string()
|
||||
}
|
||||
|
||||
fn anchors_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(SEED_ANCHORS_FILE)
|
||||
}
|
||||
|
||||
/// Load the seed-anchor list. Returns an empty list if the file
|
||||
/// doesn't exist yet — a first-boot node with no operator config.
|
||||
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
|
||||
let path = anchors_path(data_dir);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
.with_context(|| format!("read {}", path.display()))?;
|
||||
let anchors: Vec<SeedAnchor> = serde_json::from_slice(&bytes)
|
||||
.with_context(|| format!("parse {}", path.display()))?;
|
||||
Ok(anchors)
|
||||
}
|
||||
|
||||
/// Persist the list. Overwrites atomically via write-then-rename so a
|
||||
/// crashed archipelago never leaves a half-written config.
|
||||
pub async fn save(data_dir: &Path, anchors: &[SeedAnchor]) -> Result<()> {
|
||||
tokio::fs::create_dir_all(data_dir)
|
||||
.await
|
||||
.with_context(|| format!("mkdir -p {}", data_dir.display()))?;
|
||||
let path = anchors_path(data_dir);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let json = serde_json::to_vec_pretty(anchors).context("serialize seed anchors")?;
|
||||
tokio::fs::write(&tmp, json)
|
||||
.await
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
tokio::fs::rename(&tmp, &path)
|
||||
.await
|
||||
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add (or update) one anchor, keyed by npub. Returns the resulting list.
|
||||
pub async fn add(data_dir: &Path, anchor: SeedAnchor) -> Result<Vec<SeedAnchor>> {
|
||||
let mut list = load(data_dir).await?;
|
||||
if let Some(existing) = list.iter_mut().find(|a| a.npub == anchor.npub) {
|
||||
*existing = anchor;
|
||||
} else {
|
||||
list.push(anchor);
|
||||
}
|
||||
save(data_dir, &list).await?;
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Remove an anchor by npub. Returns the resulting list.
|
||||
pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
|
||||
let mut list = load(data_dir).await?;
|
||||
list.retain(|a| a.npub != npub);
|
||||
save(data_dir, &list).await?;
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Apply the seed anchors to the running FIPS daemon. For each entry,
|
||||
/// asks `fipsctl connect` to dial the peer. Errors are logged but don't
|
||||
/// fail the whole operation — a single unreachable anchor shouldn't
|
||||
/// block the others.
|
||||
///
|
||||
/// `fipsctl connect` is idempotent-ish: calling it for an already-
|
||||
/// connected peer is a no-op at the protocol layer, so re-applying on
|
||||
/// a timer is safe. Returns a list of per-anchor results for logging.
|
||||
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
let mut results = Vec::with_capacity(anchors.len());
|
||||
for anchor in anchors {
|
||||
let out = Command::new("fipsctl")
|
||||
.args([
|
||||
"connect",
|
||||
&anchor.npub,
|
||||
&anchor.address,
|
||||
&anchor.transport,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let result = match out {
|
||||
Ok(o) if o.status.success() => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: true,
|
||||
message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||
},
|
||||
Ok(o) => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!(
|
||||
"fipsctl exited {}: {}",
|
||||
o.status,
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
},
|
||||
Err(e) => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!("fipsctl launch failed: {}", e),
|
||||
},
|
||||
};
|
||||
if result.ok {
|
||||
tracing::debug!(npub = %result.npub, "Seed anchor applied");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
npub = %result.npub,
|
||||
message = %result.message,
|
||||
"Seed anchor apply failed (non-fatal)"
|
||||
);
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
/// Outcome of a single `fipsctl connect` call.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApplyResult {
|
||||
pub npub: String,
|
||||
pub ok: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mk(npub: &str) -> SeedAnchor {
|
||||
SeedAnchor {
|
||||
npub: npub.to_string(),
|
||||
address: "example.test:8668".to_string(),
|
||||
transport: "udp".to_string(),
|
||||
label: "test".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_missing_returns_empty() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_and_load_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = mk("npub1aaa");
|
||||
let b = mk("npub1bbb");
|
||||
save(dir.path(), &[a.clone(), b.clone()]).await.unwrap();
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert_eq!(got, vec![a, b]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_replaces_existing_by_npub() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut a = mk("npub1aaa");
|
||||
save(dir.path(), &[a.clone()]).await.unwrap();
|
||||
a.address = "newhost:8668".to_string();
|
||||
let list = add(dir.path(), a.clone()).await.unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].address, "newhost:8668");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_by_npub() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save(
|
||||
dir.path(),
|
||||
&[mk("npub1aaa"), mk("npub1bbb"), mk("npub1ccc")],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let list = remove(dir.path(), "npub1bbb").await.unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert!(list.iter().all(|a| a.npub != "npub1bbb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_anchor_uses_udp_by_default() {
|
||||
let json = r#"{"npub":"npub1x","address":"h:8668"}"#;
|
||||
let a: SeedAnchor = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(a.transport, "udp");
|
||||
assert_eq!(a.label, "");
|
||||
}
|
||||
}
|
||||
@@ -13,22 +13,27 @@ use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::{DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_UDP_PORT};
|
||||
use super::{DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT};
|
||||
|
||||
/// Write the FIPS daemon config based on the local npub and default
|
||||
/// transports. Overwrites any existing file — callers are expected to
|
||||
/// re-run this whenever the key or daemon version changes.
|
||||
///
|
||||
/// Schema is intentionally minimal: node identity comes from the key
|
||||
/// file on disk (the daemon handles it), transports enable UDP + Tor,
|
||||
/// IPv6 TUN + DNS on defaults. Static peer list is empty — archipelago
|
||||
/// feeds peers dynamically via federation updates.
|
||||
/// file on disk (the daemon handles it), transports enable UDP + TCP
|
||||
/// (matching upstream factory default), IPv6 TUN + DNS on defaults.
|
||||
/// Static peer list is empty — archipelago feeds peers dynamically via
|
||||
/// the seed-anchors apply loop and federation-invite hooks.
|
||||
pub fn render_config_yaml() -> String {
|
||||
// Schema matches upstream jmcorgan/fips as of 2026-04. With
|
||||
// `node.identity.persistent: true` the daemon reuses the key file at
|
||||
// config-dir/fips.key (= DAEMON_KEY_PATH). Transports take `bind_addr`
|
||||
// rather than `enabled: true / port: N`, and the upstream no longer
|
||||
// has a `tor:` transport — archipelago's own Tor fallback handles that.
|
||||
// rather than `enabled: true / port: N`. Both UDP and TCP are
|
||||
// enabled by default because the public anchor (fips.v0l.io)
|
||||
// currently answers on TCP/8443 only, and networks that block UDP
|
||||
// outbound can still bootstrap via TCP. Upstream fips no longer
|
||||
// has a `tor:` transport variant — archipelago's own Tor fallback
|
||||
// handles that layer.
|
||||
format!(
|
||||
"# Generated by archipelago — do not edit by hand.\n\
|
||||
# Regenerated on every key change and daemon upgrade.\n\
|
||||
@@ -44,9 +49,12 @@ pub fn render_config_yaml() -> String {
|
||||
bind_addr: \"127.0.0.1\"\n\
|
||||
transports:\n \
|
||||
udp:\n \
|
||||
bind_addr: \"0.0.0.0:{port}\"\n\
|
||||
bind_addr: \"0.0.0.0:{udp}\"\n \
|
||||
tcp:\n \
|
||||
bind_addr: \"0.0.0.0:{tcp}\"\n\
|
||||
peers: []\n",
|
||||
port = DEFAULT_UDP_PORT,
|
||||
udp = DEFAULT_UDP_PORT,
|
||||
tcp = DEFAULT_TCP_PORT,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,11 +86,65 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
|
||||
install_result?;
|
||||
|
||||
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
|
||||
// Heal a legacy fips_key.pub that was written as bech32 npub text
|
||||
// (pre-fix identity::write_fips_key_from_seed did this). Upstream
|
||||
// fips expects 32 raw bytes; a text file silently passes through
|
||||
// and then the daemon can't identify itself to peers. This
|
||||
// rewrites the source file in place with the correct binary form
|
||||
// derived from fips_key before staging it to /etc/fips/fips.pub.
|
||||
normalize_pub_file(&src_key, &src_pub).await?;
|
||||
sudo_install_file(&src_pub, DAEMON_PUB_PATH, "0644").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure `fips_key.pub` is 32 raw bytes. If it's a bech32 npub text
|
||||
/// file (from the pre-fix writer), decode it and rewrite in place. If
|
||||
/// the file is missing or its content doesn't match either format,
|
||||
/// re-derive the public key from `fips_key` and write that.
|
||||
pub async fn normalize_pub_file(key_path: &Path, pub_path: &Path) -> Result<()> {
|
||||
// Happy path: already 32 raw bytes.
|
||||
if let Ok(bytes) = tokio::fs::read(pub_path).await {
|
||||
if bytes.len() == 32 {
|
||||
return Ok(());
|
||||
}
|
||||
// bech32 npub text from the pre-fix writer: decode in place.
|
||||
if let Ok(s) = std::str::from_utf8(&bytes) {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.starts_with("npub1") {
|
||||
if let Ok(pk) = nostr_sdk::PublicKey::parse(trimmed) {
|
||||
let raw: [u8; 32] = pk.to_bytes();
|
||||
tokio::fs::write(pub_path, raw)
|
||||
.await
|
||||
.context("rewriting fips_key.pub as 32 raw bytes")?;
|
||||
tracing::info!(
|
||||
"Migrated legacy bech32 fips_key.pub to raw-byte form at {}",
|
||||
pub_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no pub file, or unreadable format. Re-derive from the
|
||||
// private key file (already validated by load_fips_keys).
|
||||
let secret_bytes = tokio::fs::read(key_path)
|
||||
.await
|
||||
.with_context(|| format!("read {} to derive public", key_path.display()))?;
|
||||
let text = std::str::from_utf8(&secret_bytes)
|
||||
.context("fips_key is not UTF-8 — can't derive public")?;
|
||||
let secret = nostr_sdk::SecretKey::parse(text.trim())
|
||||
.context("fips_key not parseable as bech32 nsec")?;
|
||||
let keys = nostr_sdk::Keys::new(secret);
|
||||
let raw: [u8; 32] = keys.public_key().to_bytes();
|
||||
tokio::fs::write(pub_path, raw)
|
||||
.await
|
||||
.context("writing re-derived fips_key.pub")?;
|
||||
tracing::info!("Re-derived fips_key.pub from fips_key");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sudo_install_dir(path: &str) -> Result<()> {
|
||||
let out = Command::new("sudo")
|
||||
.args(["install", "-d", "-m", "0755", path])
|
||||
@@ -131,7 +193,9 @@ mod tests {
|
||||
let yaml = render_config_yaml();
|
||||
assert!(yaml.contains("persistent: true"));
|
||||
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_UDP_PORT)));
|
||||
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_TCP_PORT)));
|
||||
assert!(yaml.contains("udp:"));
|
||||
assert!(yaml.contains("tcp:"));
|
||||
assert!(yaml.contains("tun:"));
|
||||
assert!(yaml.contains("name: fips0"));
|
||||
// Upstream fips dropped the `tor:` transport variant; archipelago
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
// the module is deliberately API-ready ahead of those call-sites.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod anchors;
|
||||
pub mod config;
|
||||
pub mod dial;
|
||||
pub mod iface;
|
||||
@@ -52,6 +53,14 @@ pub const UPSTREAM_REPO: &str = "jmcorgan/fips";
|
||||
/// Default UDP port the daemon listens on.
|
||||
pub const DEFAULT_UDP_PORT: u16 = 8668;
|
||||
|
||||
/// Default TCP port the daemon listens on. Used as a fallback when a
|
||||
/// peer can't be reached over UDP — common on networks that block UDP
|
||||
/// (corporate/guest wifi) and the path the public fips.v0l.io anchor
|
||||
/// currently accepts. Upstream factory default enables both transports
|
||||
/// and archipelago intentionally matches that baseline so fresh nodes
|
||||
/// can reach the broader FIPS mesh without operator config.
|
||||
pub const DEFAULT_TCP_PORT: u16 = 8443;
|
||||
|
||||
/// Upstream systemd unit shipped by the `fips` debian package. Archipelago
|
||||
/// prefers its own supervision (`archipelago-fips.service`) but respects an
|
||||
/// already-running upstream unit so legacy/dev nodes — where no seed-derived
|
||||
@@ -98,7 +107,13 @@ pub struct FipsStatus {
|
||||
|
||||
impl FipsStatus {
|
||||
/// Snapshot the current state across package, key, and service.
|
||||
pub async fn query(identity_dir: &Path) -> Self {
|
||||
///
|
||||
/// `data_dir` is the archipelago data-dir (used to load the
|
||||
/// operator-configured seed-anchor list so "anchor_connected" means
|
||||
/// "at least one authenticated peer matches a public or configured
|
||||
/// seed anchor", not just "fips.v0l.io specifically").
|
||||
pub async fn query(data_dir: &Path) -> Self {
|
||||
let identity_dir = identity_dir_from(data_dir);
|
||||
let installed = service::package_installed().await;
|
||||
let version = if installed {
|
||||
service::daemon_version().await.ok()
|
||||
@@ -109,17 +124,24 @@ impl FipsStatus {
|
||||
let upstream_service_state = service::unit_state(UPSTREAM_SERVICE_UNIT).await;
|
||||
let service_active =
|
||||
service_state == "active" || upstream_service_state == "active";
|
||||
let key_present = crate::identity::fips_key_exists(identity_dir);
|
||||
let key_present = crate::identity::fips_key_exists(&identity_dir);
|
||||
|
||||
// Prefer the seed-derived npub; otherwise read the daemon's own
|
||||
// key file at /etc/fips/fips.pub (world-readable per debian pkg).
|
||||
let npub = match crate::identity::fips_npub(identity_dir).await {
|
||||
let npub = match crate::identity::fips_npub(&identity_dir).await {
|
||||
Ok(Some(n)) => Some(n),
|
||||
_ => service::read_upstream_npub().await.ok().flatten(),
|
||||
};
|
||||
|
||||
let (authenticated_peer_count, anchor_connected) = if service_active {
|
||||
service::peer_connectivity_summary().await
|
||||
// Build the anchor-candidate list: hardcoded public anchor
|
||||
// plus every entry in the operator's seed-anchors.json.
|
||||
// The card lights up if any of them is authenticated.
|
||||
let mut anchor_npubs = vec![service::PUBLIC_ANCHOR_NPUB.to_string()];
|
||||
if let Ok(seed) = anchors::load(data_dir).await {
|
||||
anchor_npubs.extend(seed.into_iter().map(|a| a.npub));
|
||||
}
|
||||
service::peer_connectivity_summary(&anchor_npubs).await
|
||||
} else {
|
||||
(0, false)
|
||||
};
|
||||
@@ -152,10 +174,11 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_status_reports_no_key_pre_onboarding() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let id_dir = dir.path().join("identity");
|
||||
tokio::fs::create_dir_all(&id_dir).await.unwrap();
|
||||
|
||||
let status = FipsStatus::query(&id_dir).await;
|
||||
// query() now takes a data_dir (parent) rather than identity_dir,
|
||||
// since it also reads seed-anchors.json for the anchor check.
|
||||
// No identity/ subdir → no key; no seed-anchors.json → public
|
||||
// anchor is the only candidate.
|
||||
let status = FipsStatus::query(dir.path()).await;
|
||||
assert!(!status.key_present, "no key before onboarding");
|
||||
assert!(status.npub.is_none());
|
||||
// `installed`, `service_state`, `version` depend on the host and are
|
||||
|
||||
@@ -97,6 +97,27 @@ pub async fn restart(unit: &str) -> Result<()> {
|
||||
sudo_systemctl("restart", unit).await
|
||||
}
|
||||
|
||||
/// Resolve which systemd unit is actually supervising the fips daemon
|
||||
/// on this host. Nodes installed from the archipelago ISO run
|
||||
/// `archipelago-fips.service`; nodes that were apt-installed (or had
|
||||
/// fips running before archipelago took over) may only have the
|
||||
/// upstream `fips.service`. Restart/Reconnect must operate on whichever
|
||||
/// one is running, otherwise the UI button is a silent no-op.
|
||||
///
|
||||
/// Returns the archipelago-managed unit name if it's active,
|
||||
/// else the upstream unit name if that's active,
|
||||
/// else the archipelago-managed name as a default (so activate() can
|
||||
/// bring it up).
|
||||
pub async fn active_unit() -> &'static str {
|
||||
if unit_state(super::SERVICE_UNIT).await == "active" {
|
||||
return super::SERVICE_UNIT;
|
||||
}
|
||||
if unit_state(super::UPSTREAM_SERVICE_UNIT).await == "active" {
|
||||
return super::UPSTREAM_SERVICE_UNIT;
|
||||
}
|
||||
super::SERVICE_UNIT
|
||||
}
|
||||
|
||||
pub async fn mask(unit: &str) -> Result<()> {
|
||||
let _ = sudo_systemctl("stop", unit).await;
|
||||
let _ = sudo_systemctl("disable", unit).await;
|
||||
@@ -108,12 +129,19 @@ pub async fn mask(unit: &str) -> Result<()> {
|
||||
pub const PUBLIC_ANCHOR_NPUB: &str =
|
||||
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
|
||||
|
||||
/// Summarise peer connectivity from `fipsctl show peers` + `identity-cache`.
|
||||
/// Returns `(authenticated_peer_count, anchor_connected)`. Shells out rather
|
||||
/// than embedding a fips client because fipsctl is the daemon's own ground
|
||||
/// truth — the daemon can always rewrite its internal routing and we'd
|
||||
/// rather be consistent with `fipsctl` than snapshot it ourselves.
|
||||
pub async fn peer_connectivity_summary() -> (u32, bool) {
|
||||
/// Summarise peer connectivity from `fipsctl show peers`. Returns
|
||||
/// `(authenticated_peer_count, anchor_connected)`.
|
||||
///
|
||||
/// `anchor_candidates` is the operator-controlled list of npubs this
|
||||
/// node considers a valid mesh anchor — always includes the hard-coded
|
||||
/// public anchor, plus any entries from `seed-anchors.json`. A node is
|
||||
/// "anchor connected" when at least one currently-authenticated peer
|
||||
/// matches one of these npubs. We used to check the identity cache
|
||||
/// (which includes transient hearsay from other peers), but a cache
|
||||
/// hit on `fips.v0l.io` didn't mean we could actually route through
|
||||
/// it, and the card lied to users whose mesh was federated through
|
||||
/// their own seed anchors instead.
|
||||
pub async fn peer_connectivity_summary(anchor_candidates: &[String]) -> (u32, bool) {
|
||||
let peers_json = match Command::new("sudo")
|
||||
.args(["-n", "fipsctl", "show", "peers"])
|
||||
.output()
|
||||
@@ -122,39 +150,26 @@ pub async fn peer_connectivity_summary() -> (u32, bool) {
|
||||
Ok(o) if o.status.success() => o.stdout,
|
||||
_ => return (0, false),
|
||||
};
|
||||
let authenticated_peer_count =
|
||||
match serde_json::from_slice::<serde_json::Value>(&peers_json) {
|
||||
Ok(v) => v
|
||||
.get("peers")
|
||||
.and_then(|p| p.as_array())
|
||||
.map(|a| a.len() as u32)
|
||||
.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
let parsed: serde_json::Value =
|
||||
match serde_json::from_slice(&peers_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (0, false),
|
||||
};
|
||||
|
||||
// Anchor check: look in identity-cache (known node pubkeys the daemon
|
||||
// has heard about) rather than authenticated peers — the anchor may be
|
||||
// in the cache but not currently at session depth.
|
||||
let cache_json = match Command::new("sudo")
|
||||
.args(["-n", "fipsctl", "show", "identity-cache"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(o) if o.status.success() => o.stdout,
|
||||
_ => return (authenticated_peer_count, false),
|
||||
};
|
||||
let anchor_connected = match serde_json::from_slice::<serde_json::Value>(&cache_json) {
|
||||
Ok(v) => v
|
||||
.get("entries")
|
||||
.and_then(|e| e.as_array())
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.any(|e| e.get("npub").and_then(|n| n.as_str()) == Some(PUBLIC_ANCHOR_NPUB))
|
||||
})
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
};
|
||||
let peers = parsed
|
||||
.get("peers")
|
||||
.and_then(|p| p.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let authenticated_peer_count = peers.len() as u32;
|
||||
let anchor_connected = peers.iter().any(|p| {
|
||||
let npub = p.get("npub").and_then(|n| n.as_str()).unwrap_or_default();
|
||||
let connected = p
|
||||
.get("connectivity")
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s == "connected")
|
||||
.unwrap_or(true);
|
||||
connected && anchor_candidates.iter().any(|a| a == npub)
|
||||
});
|
||||
(authenticated_peer_count, anchor_connected)
|
||||
}
|
||||
|
||||
|
||||
@@ -219,14 +219,22 @@ async fn write_fips_key_from_seed(
|
||||
.await
|
||||
.context("Failed to set FIPS key permissions")?;
|
||||
}
|
||||
let npub = keys.public_key().to_bech32().unwrap_or_default();
|
||||
fs::write(&pub_path, format!("{npub}\n"))
|
||||
// Upstream fips daemon expects 32 raw bytes in /etc/fips/fips.pub —
|
||||
// not a bech32 npub string. Writing the bech32 form here meant the
|
||||
// installed .pub file was a 63-char text file the daemon parsed as
|
||||
// 63 raw bytes of garbage, so it couldn't identify itself to peers
|
||||
// and the anchor never handshook. Write the raw public-key bytes
|
||||
// (PublicKey::to_bytes returns a [u8; 32]) so the daemon reads
|
||||
// them directly.
|
||||
let raw_pub: [u8; 32] = keys.public_key().to_bytes();
|
||||
fs::write(&pub_path, raw_pub)
|
||||
.await
|
||||
.context("Failed to write FIPS public key")?;
|
||||
|
||||
let npub_for_log = keys.public_key().to_bech32().unwrap_or_default();
|
||||
tracing::info!(
|
||||
"Derived FIPS mesh key from seed (npub: {}...)",
|
||||
npub.chars().take(20).collect::<String>()
|
||||
npub_for_log.chars().take(20).collect::<String>()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -171,6 +172,11 @@ async fn main() -> Result<()> {
|
||||
update::run_update_scheduler(update_data_dir).await;
|
||||
});
|
||||
|
||||
// Synchronize host-side doctor artifacts (script + systemd units) with
|
||||
// what's embedded in this binary. Runs in the background so it never
|
||||
// delays server readiness; best-effort, warnings only.
|
||||
tokio::spawn(bootstrap::ensure_doctor_installed());
|
||||
|
||||
// Spawn periodic container snapshot (for crash recovery)
|
||||
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
|
||||
|
||||
@@ -229,5 +235,14 @@ async fn main() -> Result<()> {
|
||||
crash_recovery::remove_pid_marker(&config.data_dir).await;
|
||||
|
||||
info!("Archipelago shut down cleanly");
|
||||
Ok(())
|
||||
|
||||
// Hard-exit after logging. All business state is persisted by now
|
||||
// (connections drained, PID marker removed, disk flushes done via
|
||||
// tokio::fs awaits). Letting tokio try to drop the runtime instead
|
||||
// can stall for 15s+ on non-daemon OS threads we don't directly
|
||||
// own (mdns_sd daemon, reqwest resolver pool, etc.) — long enough
|
||||
// for systemd's TimeoutStopSec to SIGKILL us and mark the service
|
||||
// Failed, which makes an otherwise-successful update look like a
|
||||
// crash in `systemctl status`.
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -353,6 +363,34 @@ impl Server {
|
||||
});
|
||||
}
|
||||
|
||||
// FIPS seed-anchor apply loop — every 5 minutes we re-push the
|
||||
// configured seed anchors into the running fips daemon via
|
||||
// `fipsctl connect`. This keeps the mesh bootstrap resilient:
|
||||
// operators add cluster-local anchors in the UI, and a daemon
|
||||
// restart or a flaky public anchor can't strand the node.
|
||||
// First run is delayed 30s so fips has time to come up after
|
||||
// onboarding before we start dialing.
|
||||
{
|
||||
let data_dir = config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match crate::fips::anchors::load(&data_dir).await {
|
||||
Ok(list) if !list.is_empty() => {
|
||||
let _ = crate::fips::anchors::apply(&list).await;
|
||||
}
|
||||
Ok(_) => { /* no seed anchors configured yet */ }
|
||||
Err(e) => tracing::debug!(
|
||||
"Seed-anchor apply: load failed (non-fatal): {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// did:dht auto-refresh — re-publish DHT records every 2 hours
|
||||
if config.nostr_discovery_enabled {
|
||||
let data_dir = config.data_dir.clone();
|
||||
@@ -482,10 +520,37 @@ impl Server {
|
||||
tracing::warn!("FIPS key load/migrate failed: {}", e);
|
||||
return;
|
||||
}
|
||||
// Check if the installed fips.yaml matches what we'd
|
||||
// render now. If not, we need to restart the daemon after
|
||||
// reinstalling so it picks up schema changes (e.g. the
|
||||
// v1.7.25 re-addition of the TCP transport). Without this,
|
||||
// OTA'd nodes would be stuck on the old UDP-only config
|
||||
// until someone manually clicked Reconnect.
|
||||
let expected = crate::fips::config::render_config_yaml();
|
||||
let installed = tokio::fs::read_to_string("/etc/fips/fips.yaml")
|
||||
.await
|
||||
.ok();
|
||||
let config_changed = installed.as_deref() != Some(expected.as_str());
|
||||
|
||||
if let Err(e) = crate::fips::config::install(&identity_dir).await {
|
||||
tracing::warn!("FIPS config install failed on startup: {}", e);
|
||||
return;
|
||||
}
|
||||
if config_changed {
|
||||
tracing::info!(
|
||||
"FIPS config schema changed on disk — restarting daemon to pick up new transports"
|
||||
);
|
||||
// Restart whichever unit is actually supervising
|
||||
// the daemon (archipelago-fips vs upstream fips).
|
||||
let unit = crate::fips::service::active_unit().await;
|
||||
if let Err(e) = crate::fips::service::restart(unit).await {
|
||||
tracing::warn!(
|
||||
"FIPS restart after config migration failed on {}: {} — user can retry via fips.reconnect",
|
||||
unit,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
|
||||
tracing::warn!(
|
||||
"archipelago-fips activate failed on startup: {} — user can retry via fips.install RPC",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.3.5",
|
||||
"version": "1.7.38-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.3.5",
|
||||
"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.38-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>
|
||||
|
||||
@@ -525,7 +525,11 @@ class RPCClient {
|
||||
return this.call({
|
||||
method: 'package.install',
|
||||
params: { id, 'marketplace-url': marketplaceUrl, version },
|
||||
timeout: 900000, // 15 min — multi-GB stacks (IndeedHub, Bitcoin, Penpot) take time
|
||||
// 45 min — IndeedHub is 6 images and gitea raw-file throughput is
|
||||
// ~70 KB/s per image; 15 min was short enough to kill the install
|
||||
// mid-pull and land the user on a "didn't work" screen while the
|
||||
// backend kept working in the background.
|
||||
timeout: 2700000,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }[] {
|
||||
|
||||
@@ -13,15 +13,7 @@
|
||||
</div>
|
||||
<!-- Normal logo with audio viz ring -->
|
||||
<div v-else class="screensaver-content">
|
||||
<!-- Radial audio visualization - bars around the logo -->
|
||||
<div class="screensaver-viz-ring">
|
||||
<div
|
||||
v-for="(_, i) in segmentCount"
|
||||
:key="i"
|
||||
class="screensaver-viz-segment"
|
||||
:style="getSegmentStyle(i)"
|
||||
/>
|
||||
</div>
|
||||
<ScreensaverRing />
|
||||
<!-- Logo in center -->
|
||||
<div class="screensaver-logo-wrapper">
|
||||
<ScreensaverLogo />
|
||||
@@ -35,21 +27,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount } from 'vue'
|
||||
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
|
||||
import ScreensaverRing from '@/components/ScreensaverRing.vue'
|
||||
import BitcoinFaceAscii from '@/views/discover/BitcoinFaceAscii.vue'
|
||||
import { useScreensaverStore } from '@/stores/screensaver'
|
||||
|
||||
const store = useScreensaverStore()
|
||||
|
||||
const segmentCount = 48
|
||||
|
||||
function getSegmentStyle(i: number) {
|
||||
const deg = (i / segmentCount) * 360
|
||||
return {
|
||||
'--segment-index': i,
|
||||
'--segment-deg': `${deg}deg`,
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss on any key (except when typing)
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (store.isActive) {
|
||||
@@ -86,102 +69,15 @@ onBeforeUnmount(() => {
|
||||
|
||||
.screensaver-content {
|
||||
position: relative;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.screensaver-content {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.screensaver-content {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ring of segments around the logo - audio viz style (behind logo) */
|
||||
.screensaver-viz-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
--viz-radius: 140px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.screensaver-viz-ring {
|
||||
--viz-radius: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.screensaver-viz-ring {
|
||||
--viz-radius: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.screensaver-viz-segment {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 4px;
|
||||
height: 24px;
|
||||
margin-left: -2px;
|
||||
margin-top: -12px;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
|
||||
border-radius: 2px;
|
||||
/* Origin at segment center = ring center (segment is centered via left/top 50%) */
|
||||
transform-origin: center center;
|
||||
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius)));
|
||||
animation: segment-pulse 14s ease-in-out infinite;
|
||||
animation-delay: calc(var(--segment-index) * 0.02s);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.screensaver-viz-segment {
|
||||
height: 28px;
|
||||
margin-top: -14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 5 normal loops (10s) then stronger longer expression (4s) - total 14s */
|
||||
@keyframes segment-pulse {
|
||||
/* Loop 1 */
|
||||
0% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
7.1% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
14.3% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
/* Loop 2 */
|
||||
21.4% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
28.6% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
/* Loop 3 */
|
||||
35.7% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
42.9% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
/* Loop 4 */
|
||||
50% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
57.1% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
/* Loop 5 */
|
||||
64.3% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
71.4% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
/* Strong expression: ramp up (1.5s), hold (2s), ease back (0.5s) */
|
||||
78.6% { opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
|
||||
85.7% { opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
|
||||
92.9% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
100% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.screensaver-logo-wrapper {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 10;
|
||||
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="viz-ring" :class="sizeClass">
|
||||
<div
|
||||
v-for="(_, i) in segmentCount"
|
||||
:key="i"
|
||||
class="viz-segment"
|
||||
:style="getSegmentStyle(i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Visual size: 'default' matches the screensaver; 'compact' drops the
|
||||
* min-width breakpoints (useful inside overlays on narrower canvases). */
|
||||
size?: 'default' | 'compact'
|
||||
/** Override segment count. Defaults to 48 (screensaver standard). */
|
||||
segmentCount?: number
|
||||
}>(), { size: 'default', segmentCount: 48 })
|
||||
|
||||
const sizeClass = computed(() => props.size === 'compact' ? 'viz-ring-compact' : 'viz-ring-default')
|
||||
|
||||
function getSegmentStyle(i: number) {
|
||||
const deg = (i / props.segmentCount) * 360
|
||||
return {
|
||||
'--segment-index': i,
|
||||
'--segment-deg': `${deg}deg`,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.viz-ring {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viz-ring-default {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
--viz-radius: 140px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.viz-ring-default {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
--viz-radius: 180px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.viz-ring-default {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
--viz-radius: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.viz-ring-compact {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
--viz-radius: 120px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.viz-ring-compact {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
--viz-radius: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.viz-segment {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 4px;
|
||||
height: 24px;
|
||||
margin-left: -2px;
|
||||
margin-top: -12px;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
|
||||
border-radius: 2px;
|
||||
transform-origin: center center;
|
||||
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius)));
|
||||
animation: segment-pulse 14s ease-in-out infinite;
|
||||
animation-delay: calc(var(--segment-index) * 0.02s);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.viz-segment {
|
||||
height: 28px;
|
||||
margin-top: -14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 5 normal loops (10s) then stronger longer expression (4s) — total 14s */
|
||||
@keyframes segment-pulse {
|
||||
0% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
7.1% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
14.3%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
21.4%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
28.6%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
35.7%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
42.9%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
50% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
57.1%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
64.3%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
71.4%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
78.6%{ opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
|
||||
85.7%{ opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
|
||||
92.9%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
100% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
}
|
||||
</style>
|
||||
@@ -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",
|
||||
@@ -666,7 +668,7 @@
|
||||
"applyUpdate": "Install Update",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"checking": "Checking...",
|
||||
"rollback": "Rollback to Previous",
|
||||
"rollback": "Rollback Available",
|
||||
"backToSettings": "Back to Settings",
|
||||
"percentComplete": "{percent}% complete",
|
||||
"applyWarning": "Installing components and restarting services. Do not power off.",
|
||||
@@ -685,10 +687,26 @@
|
||||
"rollbackSuccess": "Rolled back to previous version. Service will restart.",
|
||||
"rollbackFailed": "Rollback failed.",
|
||||
"pullAndRebuild": "Pull & Rebuild",
|
||||
"finishingDownload": "Finishing download — verifying checksum…",
|
||||
"overlayApplying": "Installing update…",
|
||||
"overlayRestarting": "Restarting server…",
|
||||
"overlayReconnecting": "Reconnecting to the new version…",
|
||||
"overlayReady": "Update installed — reloading…",
|
||||
"overlayStalled": "Taking longer than expected",
|
||||
"overlayTarget": "Installing v{version}",
|
||||
"overlayReloadNow": "Reload now",
|
||||
"gitMethodHint": "This node builds from source. Update will git-pull, rebuild the backend and UI, then restart — takes a few minutes.",
|
||||
"gitApplyTitle": "Pull & Rebuild?",
|
||||
"gitApplyMessage": "Archipelago will pull the latest code, rebuild, and restart. This can take several minutes and the UI will be briefly unavailable.",
|
||||
"gitApplyStarted": "Update started. The backend will rebuild and restart — this can take a few minutes."
|
||||
"gitApplyStarted": "Update started. The backend will rebuild and restart — this can take a few minutes.",
|
||||
"cancelDownload": "Cancel Download",
|
||||
"cancelingDownload": "Canceling…",
|
||||
"cancelDownloadTitle": "Cancel Download?",
|
||||
"cancelDownloadConfirm": "This will stop the current download and discard the partial file. You can start again from scratch afterwards.",
|
||||
"cancelDownloadButton": "Cancel Download",
|
||||
"cancelDownloadSuccess": "Download canceled. You can try again.",
|
||||
"cancelDownloadFailed": "Failed to cancel download.",
|
||||
"downloadStalled": "Download appears stuck — try Cancel and start again."
|
||||
},
|
||||
"kiosk": {
|
||||
"pressEsc": "Press ESC to exit",
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"launch": "Abrir",
|
||||
"starting": "Iniciando...",
|
||||
"stopping": "Deteniendo...",
|
||||
"update": "Actualizar",
|
||||
"updating": "Actualizando...",
|
||||
"send": "Enviar",
|
||||
"sending": "Enviando...",
|
||||
"back": "Volver",
|
||||
@@ -665,7 +667,7 @@
|
||||
"applyUpdate": "Instalar actualizaci\u00f3n",
|
||||
"checkForUpdates": "Buscar actualizaciones",
|
||||
"checking": "Verificando...",
|
||||
"rollback": "Revertir a la versi\u00f3n anterior",
|
||||
"rollback": "Rollback disponible",
|
||||
"backToSettings": "Volver a configuraci\u00f3n",
|
||||
"percentComplete": "{percent}% completado",
|
||||
"applyWarning": "Instalando componentes y reiniciando servicios. No apague el equipo.",
|
||||
@@ -684,10 +686,26 @@
|
||||
"rollbackSuccess": "Se revirti\u00f3 a la versi\u00f3n anterior. El servicio se reiniciar\u00e1.",
|
||||
"rollbackFailed": "Error al revertir.",
|
||||
"pullAndRebuild": "Pull y Recompilar",
|
||||
"gitMethodHint": "Este nodo compila desde el c\u00f3digo fuente. La actualizaci\u00f3n har\u00e1 git-pull, recompilar\u00e1 y reiniciar\u00e1 — tarda unos minutos.",
|
||||
"finishingDownload": "Terminando descarga \u2014 verificando checksum\u2026",
|
||||
"overlayApplying": "Instalando actualizaci\u00f3n\u2026",
|
||||
"overlayRestarting": "Reiniciando servidor\u2026",
|
||||
"overlayReconnecting": "Reconectando a la nueva versi\u00f3n\u2026",
|
||||
"overlayReady": "Actualizaci\u00f3n instalada \u2014 recargando\u2026",
|
||||
"overlayStalled": "Tardando m\u00e1s de lo esperado",
|
||||
"overlayTarget": "Instalando v{version}",
|
||||
"overlayReloadNow": "Recargar ahora",
|
||||
"gitMethodHint": "Este nodo compila desde el c\u00f3digo fuente. La actualizaci\u00f3n har\u00e1 git-pull, recompilar\u00e1 y reiniciar\u00e1 \u2014 tarda unos minutos.",
|
||||
"gitApplyTitle": "\u00bfPull y Recompilar?",
|
||||
"gitApplyMessage": "Archipelago descargar\u00e1 el c\u00f3digo m\u00e1s reciente, lo compilar\u00e1 y reiniciar\u00e1. Puede tardar varios minutos y la UI estar\u00e1 brevemente no disponible.",
|
||||
"gitApplyStarted": "Actualizaci\u00f3n iniciada. El backend se recompilar\u00e1 y reiniciar\u00e1 — puede tardar unos minutos."
|
||||
"gitApplyStarted": "Actualizaci\u00f3n iniciada. El backend se recompilar\u00e1 y reiniciar\u00e1 \u2014 puede tardar unos minutos.",
|
||||
"cancelDownload": "Cancelar descarga",
|
||||
"cancelingDownload": "Cancelando\u2026",
|
||||
"cancelDownloadTitle": "\u00bfCancelar descarga?",
|
||||
"cancelDownloadConfirm": "Esto detendr\u00e1 la descarga actual y descartar\u00e1 el archivo parcial. Podr\u00e1s volver a empezar desde cero.",
|
||||
"cancelDownloadButton": "Cancelar descarga",
|
||||
"cancelDownloadSuccess": "Descarga cancelada. Puedes intentarlo de nuevo.",
|
||||
"cancelDownloadFailed": "No se pudo cancelar la descarga.",
|
||||
"downloadStalled": "La descarga parece atascada \u2014 prueba a cancelar y volver a empezar."
|
||||
},
|
||||
"kiosk": {
|
||||
"pressEsc": "Presione ESC para salir",
|
||||
|
||||
@@ -193,6 +193,11 @@ const router = createRouter({
|
||||
name: 'system-update',
|
||||
component: () => import('../views/SystemUpdate.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings/registries',
|
||||
name: 'app-registries',
|
||||
component: () => import('../views/AppRegistries.vue'),
|
||||
},
|
||||
{
|
||||
path: 'goals/:goalId',
|
||||
name: 'goal-detail',
|
||||
@@ -306,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': {
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">App registries</h1>
|
||||
<p class="text-white/70">
|
||||
Container registries this node pulls app images from. The primary is tried first; if it's
|
||||
slow or unreachable, the next one in the list is tried automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
<div
|
||||
v-if="statusMessage"
|
||||
class="mb-4 p-3 rounded-lg text-sm"
|
||||
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
|
||||
>
|
||||
{{ statusMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Registry list -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 class="text-lg font-semibold text-white">Registries</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
||||
@click="openAddRegistry"
|
||||
>+ Add registry</button>
|
||||
</div>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Registries are tried in priority order on every app install. Changing the primary takes
|
||||
effect on the next install — existing containers keep running on whatever image they
|
||||
already pulled.
|
||||
</p>
|
||||
<ul v-if="registries.length" class="space-y-2">
|
||||
<li
|
||||
v-for="r in sortedRegistries"
|
||||
:key="r.url"
|
||||
class="p-3 bg-white/5 rounded-lg"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||
<p class="text-sm font-medium text-white truncate">{{ r.name || r.url }}</p>
|
||||
<span
|
||||
v-if="r.priority === 0"
|
||||
class="text-[10px] font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-300"
|
||||
>PRIMARY</span>
|
||||
<span
|
||||
v-if="!r.tls_verify"
|
||||
class="text-[10px] font-mono px-2 py-0.5 rounded bg-amber-500/20 text-amber-300"
|
||||
title="TLS verification disabled — HTTP or self-signed registry"
|
||||
>HTTP</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 font-mono break-all">{{ r.url }}</p>
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="registryTests[r.url]?.testing"
|
||||
title="Test reachability"
|
||||
@click="testRegistry(r)"
|
||||
>
|
||||
<svg v-if="registryTests[r.url]?.testing" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25"></circle>
|
||||
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<svg v-else 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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="r.priority !== 0"
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-yellow-300 hover:bg-white/10 transition-colors"
|
||||
title="Make primary"
|
||||
@click="setPrimary(r.url)"
|
||||
>
|
||||
<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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="registries.length > 1"
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-red-300 hover:bg-red-400/10 transition-colors"
|
||||
title="Remove registry"
|
||||
@click="removeRegistry(r.url)"
|
||||
>
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="registryTests[r.url] && !registryTests[r.url]?.testing"
|
||||
class="mt-2 pt-2 border-t border-white/5 text-xs"
|
||||
>
|
||||
<span v-if="registryTests[r.url]?.reachable" class="inline-flex items-center gap-1.5 text-green-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Reachable (HTTP {{ registryTests[r.url]?.status }})
|
||||
</span>
|
||||
<span v-else class="inline-flex items-center gap-1.5 text-red-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span class="truncate">{{ registryTests[r.url]?.error || 'Unreachable' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Back link -->
|
||||
<RouterLink
|
||||
to="/dashboard/settings"
|
||||
class="glass-button rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center gap-2"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Settings
|
||||
</RouterLink>
|
||||
|
||||
<!-- Add-registry modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="addingRegistry"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md"
|
||||
@click.self="cancelAddRegistry"
|
||||
>
|
||||
<div class="glass-card p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Add app registry</h3>
|
||||
<p class="text-sm text-white/60 mb-5">
|
||||
The URL should be of the form <span class="font-mono text-white/80">host[:port]/namespace</span>
|
||||
— for example <span class="font-mono text-white/80">ghcr.io/myorg</span> or
|
||||
<span class="font-mono text-white/80">192.168.1.50:3000/apps</span>. Registries are
|
||||
added to the end of the list; use "Make primary" to reorder.
|
||||
</p>
|
||||
<form class="space-y-3" @submit.prevent="submitRegistry">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Name</label>
|
||||
<input
|
||||
v-model="registryDraft.name"
|
||||
type="text"
|
||||
placeholder="My private registry"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Registry URL</label>
|
||||
<input
|
||||
v-model="registryDraft.url"
|
||||
type="text"
|
||||
autofocus
|
||||
placeholder="host:port/namespace"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 cursor-pointer text-sm text-white/80">
|
||||
<input
|
||||
v-model="registryDraft.tls_verify"
|
||||
type="checkbox"
|
||||
class="accent-orange-400"
|
||||
/>
|
||||
Verify TLS certificate (uncheck for HTTP or self-signed)
|
||||
</label>
|
||||
<div class="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelAddRegistry"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="registrySaving || !registryDraft.url.trim()"
|
||||
>{{ registrySaving ? 'Adding…' : 'Add registry' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
interface Registry {
|
||||
url: string
|
||||
name: string
|
||||
tls_verify: boolean
|
||||
enabled: boolean
|
||||
priority: number
|
||||
}
|
||||
|
||||
interface RegistryTestState {
|
||||
testing?: boolean
|
||||
reachable?: boolean
|
||||
status?: number | null
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
const registries = ref<Registry[]>([])
|
||||
const sortedRegistries = computed(() =>
|
||||
[...registries.value].sort((a, b) => a.priority - b.priority)
|
||||
)
|
||||
const registryTests = ref<Record<string, RegistryTestState>>({})
|
||||
const statusMessage = ref('')
|
||||
const statusIsError = ref(false)
|
||||
|
||||
const addingRegistry = ref(false)
|
||||
const registrySaving = ref(false)
|
||||
const registryDraft = reactive({ url: '', name: '', tls_verify: true })
|
||||
|
||||
function showStatus(msg: string, isError = false) {
|
||||
statusMessage.value = msg
|
||||
statusIsError.value = isError
|
||||
setTimeout(() => { statusMessage.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function loadRegistries() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({ method: 'registry.list' })
|
||||
registries.value = res.registries
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('registry.list failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function openAddRegistry() {
|
||||
registryDraft.url = ''
|
||||
registryDraft.name = ''
|
||||
registryDraft.tls_verify = true
|
||||
addingRegistry.value = true
|
||||
}
|
||||
function cancelAddRegistry() {
|
||||
addingRegistry.value = false
|
||||
}
|
||||
|
||||
async function submitRegistry() {
|
||||
const url = registryDraft.url.trim()
|
||||
if (!url) return
|
||||
registrySaving.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({
|
||||
method: 'registry.add',
|
||||
params: {
|
||||
url,
|
||||
name: registryDraft.name.trim() || url,
|
||||
tls_verify: registryDraft.tls_verify,
|
||||
},
|
||||
})
|
||||
registries.value = res.registries
|
||||
addingRegistry.value = false
|
||||
showStatus('Registry added.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Add registry failed: ${msg}`, true)
|
||||
} finally {
|
||||
registrySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRegistry(url: string) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({
|
||||
method: 'registry.remove',
|
||||
params: { url },
|
||||
})
|
||||
registries.value = res.registries
|
||||
showStatus('Registry removed.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Remove failed: ${msg}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function setPrimary(url: string) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({
|
||||
method: 'registry.set-primary',
|
||||
params: { url },
|
||||
})
|
||||
registries.value = res.registries
|
||||
showStatus('Primary registry updated. Next install will try it first.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Set primary failed: ${msg}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function testRegistry(r: Registry) {
|
||||
registryTests.value = { ...registryTests.value, [r.url]: { testing: true } }
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
url: string
|
||||
reachable: boolean
|
||||
status: number | null
|
||||
error?: string | null
|
||||
}>({ method: 'registry.test', params: { url: r.url, tls_verify: r.tls_verify } })
|
||||
registryTests.value = {
|
||||
...registryTests.value,
|
||||
[r.url]: {
|
||||
testing: false,
|
||||
reachable: res.reachable,
|
||||
status: res.status,
|
||||
error: res.error ?? null,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
registryTests.value = {
|
||||
...registryTests.value,
|
||||
[r.url]: { testing: false, reachable: false, error: msg },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { void loadRegistries() })
|
||||
</script>
|
||||
@@ -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)
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useTransportStore } from '@/stores/transport'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
@@ -537,6 +537,8 @@ async function rotateDid(password: string) {
|
||||
}
|
||||
}
|
||||
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
loadNodes()
|
||||
loadDwnStatus()
|
||||
@@ -549,5 +551,16 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// Self DID not available
|
||||
}
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
loadNodes()
|
||||
loadPendingRequests()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -317,26 +317,35 @@ const torConnected = computed(() => {
|
||||
})
|
||||
const vpnStatus = ref({ connected: false, provider: '' })
|
||||
const vpnConnected = computed(() => vpnStatus.value.connected || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running))
|
||||
const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null)
|
||||
const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
|
||||
const fipsDotClass = computed(() => {
|
||||
const s = fipsStatus.value
|
||||
if (!s || !s.installed) return 'bg-white/40'
|
||||
if (s.service_active) return 'bg-green-400'
|
||||
return 'bg-white/40'
|
||||
if (!s.service_active) return 'bg-white/40'
|
||||
// Active but no anchor = degraded, not fully green
|
||||
if (s.anchor_connected === false) return 'bg-orange-400'
|
||||
return 'bg-green-400'
|
||||
})
|
||||
const fipsTextClass = computed(() => {
|
||||
const s = fipsStatus.value
|
||||
if (!s || !s.installed) return 'text-white/40'
|
||||
if (s.service_active) return 'text-green-400'
|
||||
return 'text-white/40'
|
||||
if (!s.service_active) return 'text-white/40'
|
||||
if (s.anchor_connected === false) return 'text-orange-400'
|
||||
return 'text-green-400'
|
||||
})
|
||||
const fipsStatusLabel = computed(() => {
|
||||
const s = fipsStatus.value
|
||||
if (!s) return '…'
|
||||
if (!s.installed) return 'Not installed'
|
||||
if (s.service_active) return 'Active'
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
return 'Inactive'
|
||||
if (!s.service_active) {
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
return 'Inactive'
|
||||
}
|
||||
// Service is active — reflect anchor reachability in the label so the
|
||||
// Home and Server rows flip in sync with the FIPS card.
|
||||
if (s.anchor_connected === false) return 'No anchor'
|
||||
const peers = s.authenticated_peer_count ?? 0
|
||||
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
|
||||
})
|
||||
const bitcoinSyncDisplay = computed(() => {
|
||||
if (!systemStats.bitcoinAvailable) return 'Not running'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -420,25 +420,31 @@ const networkData = ref({
|
||||
})
|
||||
|
||||
// FIPS status row for the Local Network card. Full FIPS card lives below.
|
||||
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null)
|
||||
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
|
||||
const fipsRowLabel = computed(() => {
|
||||
const s = fipsSummary.value
|
||||
if (!s) return '…'
|
||||
if (!s.installed) return 'Not installed'
|
||||
// Service-active wins even on legacy nodes with no seed-derived key.
|
||||
if (s.service_active) return 'Active'
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
return 'Inactive'
|
||||
if (!s.service_active) {
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
return 'Inactive'
|
||||
}
|
||||
// Service is active — reflect anchor reachability so the row flips in
|
||||
// sync with the full FIPS card below.
|
||||
if (s.anchor_connected === false) return 'No anchor'
|
||||
const peers = s.authenticated_peer_count ?? 0
|
||||
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
|
||||
})
|
||||
const fipsRowTextClass = computed(() => {
|
||||
const s = fipsSummary.value
|
||||
if (!s || !s.installed) return 'text-white/40'
|
||||
if (s.service_active) return 'text-green-400'
|
||||
return 'text-white/60'
|
||||
if (!s.service_active) return 'text-white/60'
|
||||
if (s.anchor_connected === false) return 'text-orange-400'
|
||||
return 'text-green-400'
|
||||
})
|
||||
async function loadFipsSummary() {
|
||||
try {
|
||||
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean }>({ method: 'fips.status' })
|
||||
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({ method: 'fips.status' })
|
||||
} catch { /* backend too old */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ t('systemUpdate.updateAvailable') }}</h2>
|
||||
<p class="text-sm text-white/60">Version {{ updateInfo.version }} — {{ updateInfo.release_date }}</p>
|
||||
<p v-if="manifestMirrorLabel" class="text-xs text-white/40 mt-1 flex items-center gap-1.5">
|
||||
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>Served by <span class="text-white/70">{{ manifestMirrorLabel }}</span></span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="px-3 py-1 bg-orange-500/20 text-orange-400 text-xs font-medium rounded-full">{{ t('systemUpdate.new') }}</span>
|
||||
</div>
|
||||
@@ -109,11 +115,31 @@
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.downloading') }}</h2>
|
||||
<div class="w-full h-3 bg-white/10 rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full bg-orange-400 rounded-full transition-all duration-500"
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="downloadStalled ? 'bg-amber-400' : 'bg-orange-400'"
|
||||
:style="{ width: downloadPercentFormatted + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-white/60">{{ t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }}</p>
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div v-if="downloadFinishing && !downloadStalled" class="w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin shrink-0"></div>
|
||||
<p class="text-xs" :class="downloadStalled ? 'text-amber-300' : 'text-white/60'">
|
||||
{{ downloadStalled
|
||||
? t('systemUpdate.downloadStalled')
|
||||
: downloadFinishing
|
||||
? t('systemUpdate.finishingDownload')
|
||||
: t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="requestCancelDownload"
|
||||
:disabled="cancelingDownload"
|
||||
class="glass-button rounded-lg px-4 py-1.5 text-xs font-medium disabled:opacity-40 shrink-0"
|
||||
:class="downloadStalled ? 'bg-amber-500/20 border-amber-400/40 text-amber-200' : ''"
|
||||
>
|
||||
{{ cancelingDownload ? t('systemUpdate.cancelingDownload') : t('systemUpdate.cancelDownload') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Applying -->
|
||||
@@ -152,6 +178,87 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mirrors -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 class="text-lg font-semibold text-white">Update mirrors</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
||||
@click="openAddMirror"
|
||||
>+ Add mirror</button>
|
||||
</div>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Servers this node checks for updates. The primary is tried first; if it's slow or unreachable, the next one in the list is tried automatically. Downloads always come from the mirror that served the manifest — switching primary switches where files come from.
|
||||
</p>
|
||||
<ul v-if="mirrors.length" class="space-y-2">
|
||||
<li v-for="(m, i) in mirrors" :key="m.url" class="p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||
<p class="text-sm font-medium text-white truncate">{{ m.label || `Mirror ${i + 1}` }}</p>
|
||||
<span v-if="i === 0" class="text-[10px] font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-300">PRIMARY</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 font-mono break-all">{{ m.url }}</p>
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="mirrorTests[m.url]?.testing"
|
||||
title="Test reachability"
|
||||
@click="testMirror(m.url)"
|
||||
>
|
||||
<svg v-if="mirrorTests[m.url]?.testing" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25"></circle>
|
||||
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<svg v-else 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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="i !== 0"
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-yellow-300 hover:bg-white/10 transition-colors"
|
||||
title="Make primary"
|
||||
@click="setPrimaryMirror(m.url)"
|
||||
>
|
||||
<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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="mirrors.length > 1"
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-red-300 hover:bg-red-400/10 transition-colors"
|
||||
title="Remove mirror"
|
||||
@click="removeMirror(m.url)"
|
||||
>
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mirrorTests[m.url] && !mirrorTests[m.url]?.testing" class="mt-2 pt-2 border-t border-white/5 text-xs">
|
||||
<span v-if="mirrorTests[m.url]?.reachable" class="inline-flex items-center gap-1.5 text-green-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Reachable · {{ mirrorTests[m.url]?.latency_ms }}ms
|
||||
</span>
|
||||
<span v-else class="inline-flex items-center gap-1.5 text-red-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span class="truncate">{{ mirrorTests[m.url]?.error || 'Unreachable' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Actions row -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.actions') }}</h2>
|
||||
@@ -176,6 +283,112 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Install progress overlay — covers the UI while the backend
|
||||
swaps files, restarts, and comes back up on the new version.
|
||||
Auto-reloads the page as soon as /health reports the target
|
||||
version. Styled to match the screensaver (ASCII logo, full-
|
||||
screen black). -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="installing"
|
||||
class="fixed inset-0 z-[3000] bg-black flex flex-col items-center justify-center overflow-hidden"
|
||||
>
|
||||
<!-- Centered ASCII logo — same asset used by the screensaver -->
|
||||
<div class="install-overlay-ascii">
|
||||
<BitcoinFaceAscii />
|
||||
</div>
|
||||
|
||||
<!-- Status text + progress bar underneath -->
|
||||
<div class="mt-8 w-[min(520px,80vw)] text-center">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">
|
||||
{{ installStage === 'applying' ? t('systemUpdate.overlayApplying')
|
||||
: installStage === 'restarting' ? t('systemUpdate.overlayRestarting')
|
||||
: installStage === 'reconnecting' ? t('systemUpdate.overlayReconnecting')
|
||||
: installStage === 'ready' ? t('systemUpdate.overlayReady')
|
||||
: t('systemUpdate.overlayStalled') }}
|
||||
</h2>
|
||||
<p v-if="installTargetVersion" class="text-sm text-white/60 mb-4">
|
||||
{{ t('systemUpdate.overlayTarget', { version: installTargetVersion }) }}
|
||||
</p>
|
||||
|
||||
<!-- Animated bar: indeterminate stripe while working; full
|
||||
orange when ready; steady at 50% (paused look) when
|
||||
stalled so it reads as "something needs the user". -->
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden mb-3 relative">
|
||||
<div
|
||||
v-if="installStage === 'ready'"
|
||||
class="absolute inset-0 bg-green-400"
|
||||
></div>
|
||||
<div
|
||||
v-else-if="installStage === 'stalled'"
|
||||
class="absolute inset-y-0 left-0 w-1/2 bg-orange-400/60"
|
||||
></div>
|
||||
<div
|
||||
v-else
|
||||
class="absolute inset-y-0 w-1/3 bg-orange-400 rounded-full install-overlay-bar-anim"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-white/40">{{ installElapsedLabel }}</p>
|
||||
|
||||
<button
|
||||
v-if="installStage === 'stalled'"
|
||||
@click="reloadNow"
|
||||
class="mt-5 glass-button rounded-lg px-5 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
|
||||
>
|
||||
{{ t('systemUpdate.overlayReloadNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Add-mirror modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="addingMirror" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelAddMirror">
|
||||
<div class="glass-card p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Add update mirror</h3>
|
||||
<p class="text-sm text-white/60 mb-5">
|
||||
The URL should point directly at a <span class="font-mono text-white/80">manifest.json</span> served by a Gitea mirror or equivalent. It's added to the end of the list; use "Make primary" to change order.
|
||||
</p>
|
||||
<form class="space-y-3" @submit.prevent="submitMirror">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Manifest URL</label>
|
||||
<input
|
||||
v-model="mirrorDraft.url"
|
||||
type="text"
|
||||
autofocus
|
||||
placeholder="https://host/.../manifest.json"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Label (optional)</label>
|
||||
<input
|
||||
v-model="mirrorDraft.label"
|
||||
type="text"
|
||||
placeholder="Home VPS"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelAddMirror"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>{{ t('common.cancel') }}</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
|
||||
>{{ mirrorSaving ? 'Adding…' : 'Add mirror' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Confirmation modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelConfirm">
|
||||
@@ -185,14 +398,18 @@
|
||||
? t('systemUpdate.rollbackTitle')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.gitApplyTitle')
|
||||
: t('systemUpdate.applyTitle') }}
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadTitle')
|
||||
: t('systemUpdate.applyTitle') }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/70 mb-6">
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackMessage')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.gitApplyMessage')
|
||||
: t('systemUpdate.applyMessage') }}
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadConfirm')
|
||||
: t('systemUpdate.applyMessage') }}
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
|
||||
@@ -201,13 +418,15 @@
|
||||
<button
|
||||
@click="executeConfirm"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
:class="confirmAction === 'rollback' ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
|
||||
:class="(confirmAction === 'rollback' || confirmAction === 'cancel-download') ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
|
||||
>
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackButton')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.pullAndRebuild')
|
||||
: t('systemUpdate.applyNow') }}
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadButton')
|
||||
: t('systemUpdate.applyNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,10 +436,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BitcoinFaceAscii from '@/views/discover/BitcoinFaceAscii.vue'
|
||||
|
||||
interface UpdateDetail {
|
||||
version: string
|
||||
@@ -244,7 +464,9 @@ const loading = ref(false)
|
||||
const downloading = ref(false)
|
||||
const downloaded = ref(false)
|
||||
const applying = ref(false)
|
||||
const confirmAction = ref<'apply' | 'git-apply' | 'rollback' | null>(null)
|
||||
const cancelingDownload = ref(false)
|
||||
const downloadStalled = ref(false)
|
||||
const confirmAction = ref<'apply' | 'git-apply' | 'rollback' | 'cancel-download' | null>(null)
|
||||
const currentVersion = ref('0.0.0')
|
||||
const lastCheck = ref<string | null>(null)
|
||||
const updateInfo = ref<UpdateDetail | null>(null)
|
||||
@@ -256,6 +478,231 @@ const statusIsError = ref(false)
|
||||
const downloadPercent = ref(0)
|
||||
const downloadPercentFormatted = computed(() => downloadPercent.value.toFixed(2))
|
||||
|
||||
// Mirrors — servers this node tries for the manifest, in priority
|
||||
// order. First entry is the primary. Add/remove/set-primary are wired
|
||||
// to update.*-mirror RPCs; downloads automatically go to the mirror
|
||||
// that served the manifest.
|
||||
interface UpdateMirror { url: string; label: string }
|
||||
const mirrors = ref<UpdateMirror[]>([])
|
||||
const addingMirror = ref(false)
|
||||
const mirrorSaving = ref(false)
|
||||
const mirrorDraft = reactive({ url: '', label: '' })
|
||||
|
||||
// URL of the mirror that served the currently-available-update manifest.
|
||||
// Backend reports it in update.status and update.check responses; the UI
|
||||
// resolves it to a friendly label by matching against the mirrors list.
|
||||
const manifestMirror = ref<string | null>(null)
|
||||
const manifestMirrorLabel = computed(() => {
|
||||
const url = manifestMirror.value
|
||||
if (!url) return null
|
||||
const match = mirrors.value.find(m => m.url === url)
|
||||
if (match && match.label) return match.label
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return u.host
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
})
|
||||
|
||||
// Per-mirror test state. Populated by testMirror(); each entry is either
|
||||
// { testing: true } while in flight or the backend response shape on
|
||||
// completion. Rendered inline under each mirror row.
|
||||
interface MirrorTestState {
|
||||
testing?: boolean
|
||||
reachable?: boolean
|
||||
latency_ms?: number
|
||||
http_status?: number | null
|
||||
error?: string | null
|
||||
}
|
||||
const mirrorTests = ref<Record<string, MirrorTestState>>({})
|
||||
|
||||
async function testMirror(url: string) {
|
||||
mirrorTests.value = { ...mirrorTests.value, [url]: { testing: true } }
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
reachable: boolean
|
||||
latency_ms: number
|
||||
http_status: number | null
|
||||
error: string | null
|
||||
}>({ method: 'update.test-mirror', params: { url } })
|
||||
mirrorTests.value = { ...mirrorTests.value, [url]: { ...res, testing: false } }
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
mirrorTests.value = { ...mirrorTests.value, [url]: { testing: false, reachable: false, error: msg } }
|
||||
}
|
||||
}
|
||||
|
||||
function openAddMirror() {
|
||||
mirrorDraft.url = ''
|
||||
mirrorDraft.label = ''
|
||||
addingMirror.value = true
|
||||
}
|
||||
function cancelAddMirror() {
|
||||
addingMirror.value = false
|
||||
}
|
||||
|
||||
async function loadMirrors() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({ method: 'update.list-mirrors' })
|
||||
mirrors.value = res.mirrors
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('update.list-mirrors failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitMirror() {
|
||||
const url = mirrorDraft.url.trim()
|
||||
if (!url) return
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
showStatus('Mirror URL must start with http:// or https://', true)
|
||||
return
|
||||
}
|
||||
mirrorSaving.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({
|
||||
method: 'update.add-mirror',
|
||||
params: { url, label: mirrorDraft.label.trim() },
|
||||
})
|
||||
mirrors.value = res.mirrors
|
||||
mirrorDraft.url = ''
|
||||
mirrorDraft.label = ''
|
||||
addingMirror.value = false
|
||||
showStatus('Mirror added.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Add mirror failed: ${msg}`, true)
|
||||
} finally {
|
||||
mirrorSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMirror(url: string) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({
|
||||
method: 'update.remove-mirror',
|
||||
params: { url },
|
||||
})
|
||||
mirrors.value = res.mirrors
|
||||
showStatus('Mirror removed.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Remove failed: ${msg}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function setPrimaryMirror(url: string) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({
|
||||
method: 'update.set-primary-mirror',
|
||||
params: { url },
|
||||
})
|
||||
mirrors.value = res.mirrors
|
||||
showStatus('Primary mirror updated. Next update check will try it first.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Set primary failed: ${msg}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the backend for the real bytes_downloaded / total_bytes so the
|
||||
// progress bar tracks actual download state (and survives route
|
||||
// changes). Returns true if a download is currently in progress.
|
||||
async function pollDownloadProgress(): Promise<boolean> {
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
download_progress?: {
|
||||
bytes_downloaded: number
|
||||
total_bytes: number
|
||||
active: boolean
|
||||
stalled?: boolean
|
||||
} | null
|
||||
}>({ method: 'update.status' })
|
||||
const p = res.download_progress
|
||||
if (p && p.total_bytes > 0) {
|
||||
downloadPercent.value = Math.min(100, (p.bytes_downloaded / p.total_bytes) * 100)
|
||||
downloadStalled.value = !!p.stalled
|
||||
return p.active
|
||||
}
|
||||
downloadStalled.value = false
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Shown next to the progress bar when the fake increment has maxed out
|
||||
// at 95% but the real RPC hasn't returned yet — lets the user know the
|
||||
// UI hasn't frozen while SHA verification and disk writes finish.
|
||||
const downloadFinishing = computed(() => downloading.value && downloadPercent.value >= 95)
|
||||
|
||||
// Install overlay state — drives the full-screen progress modal shown
|
||||
// while the backend swaps files, restarts, and comes back up on the
|
||||
// new version. The overlay polls /health and auto-reloads the browser
|
||||
// as soon as the backend reports the target version, so the user
|
||||
// doesn't need to manually refresh.
|
||||
type InstallStage = 'applying' | 'restarting' | 'reconnecting' | 'ready' | 'stalled'
|
||||
const installing = ref(false)
|
||||
const installStage = ref<InstallStage>('applying')
|
||||
const installTargetVersion = ref<string | null>(null)
|
||||
const installStartedAt = ref<number>(0)
|
||||
const installElapsedSec = ref(0)
|
||||
let installPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let installElapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
const installElapsedLabel = computed(() => {
|
||||
const s = installElapsedSec.value
|
||||
if (s < 60) return `Elapsed: ${s}s`
|
||||
return `Elapsed: ${Math.floor(s / 60)}m${s % 60 < 10 ? '0' : ''}${s % 60}s`
|
||||
})
|
||||
function startInstallOverlay(targetVersion: string) {
|
||||
installing.value = true
|
||||
installStage.value = 'applying'
|
||||
installTargetVersion.value = targetVersion
|
||||
installStartedAt.value = Date.now()
|
||||
installElapsedSec.value = 0
|
||||
// Tick an elapsed counter once per second for the UI.
|
||||
installElapsedTimer = setInterval(() => {
|
||||
installElapsedSec.value = Math.floor((Date.now() - installStartedAt.value) / 1000)
|
||||
// Stop polling after 3 min — surface the manual reload button.
|
||||
if (installElapsedSec.value >= 180 && installStage.value !== 'ready') {
|
||||
installStage.value = 'stalled'
|
||||
}
|
||||
}, 1000)
|
||||
// Start polling /health after a short delay — the backend restarts 2s
|
||||
// after replying to update.apply, so an immediate poll would see the
|
||||
// old backend and conclude nothing happened.
|
||||
setTimeout(() => {
|
||||
installStage.value = 'restarting'
|
||||
installPollTimer = setInterval(pollHealth, 1500)
|
||||
}, 2500)
|
||||
}
|
||||
async function pollHealth() {
|
||||
if (installStage.value === 'ready' || installStage.value === 'stalled') return
|
||||
try {
|
||||
const res = await fetch('/health', { signal: AbortSignal.timeout(2000) })
|
||||
if (!res.ok) throw new Error(`health ${res.status}`)
|
||||
const data = await res.json() as { version?: string }
|
||||
if (data.version && data.version === installTargetVersion.value) {
|
||||
installStage.value = 'ready'
|
||||
if (installPollTimer) { clearInterval(installPollTimer); installPollTimer = null }
|
||||
// Brief pause so the user sees the "Ready" state before the reload.
|
||||
setTimeout(() => { window.location.reload() }, 1200)
|
||||
} else {
|
||||
// Backend is up but still reporting the old version — frontend
|
||||
// and backend are mid-swap. Signal to the user.
|
||||
installStage.value = 'reconnecting'
|
||||
}
|
||||
} catch {
|
||||
// Fetch fails while the server is mid-restart. Stay in 'restarting'.
|
||||
}
|
||||
}
|
||||
function reloadNow() { window.location.reload() }
|
||||
// Cleanup if the component is torn down mid-install (unlikely but safe).
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
onBeforeUnmount(() => {
|
||||
if (installPollTimer) clearInterval(installPollTimer)
|
||||
if (installElapsedTimer) clearInterval(installElapsedTimer)
|
||||
})
|
||||
|
||||
const lastCheckDisplay = computed(() => {
|
||||
if (!lastCheck.value) return t('common.never')
|
||||
try {
|
||||
@@ -300,11 +747,13 @@ async function loadStatus() {
|
||||
update_available: boolean
|
||||
update_in_progress: boolean
|
||||
rollback_available: boolean
|
||||
manifest_mirror: string | null
|
||||
}>({ method: 'update.status' })
|
||||
currentVersion.value = res.current_version
|
||||
lastCheck.value = res.last_check
|
||||
updateInProgress.value = res.update_in_progress
|
||||
rollbackAvailable.value = res.rollback_available
|
||||
manifestMirror.value = res.manifest_mirror ?? null
|
||||
|
||||
if (res.update_in_progress) {
|
||||
downloaded.value = true
|
||||
@@ -324,11 +773,13 @@ async function checkForUpdates() {
|
||||
update_available: boolean
|
||||
update: UpdateDetail | null
|
||||
update_method?: string
|
||||
manifest_mirror?: string | null
|
||||
}>({ method: 'update.check' })
|
||||
currentVersion.value = res.current_version
|
||||
lastCheck.value = res.last_check
|
||||
updateInfo.value = res.update
|
||||
updateMethod.value = res.update_method === 'git' ? 'git' : 'manifest'
|
||||
manifestMirror.value = res.manifest_mirror ?? null
|
||||
if (!res.update_available) {
|
||||
showStatus(t('systemUpdate.upToDateMessage'))
|
||||
}
|
||||
@@ -345,21 +796,19 @@ async function downloadUpdate() {
|
||||
downloadPercent.value = 0
|
||||
statusMessage.value = ''
|
||||
|
||||
// Simulate incremental progress while waiting for the RPC. Capped at
|
||||
// 95% so the bar never shows >100% before the real completion jumps it
|
||||
// to 100 — previously the random increment could overshoot.
|
||||
const progressInterval = setInterval(() => {
|
||||
if (downloadPercent.value < 95) {
|
||||
downloadPercent.value = Math.min(95, downloadPercent.value + Math.random() * 3)
|
||||
}
|
||||
}, 500)
|
||||
// Poll the backend's real byte counter every second instead of
|
||||
// faking progress. The backend exposes bytes_downloaded/total_bytes
|
||||
// via update.status, updated per chunk. This also means the bar
|
||||
// resumes correctly after navigating away and back — no more
|
||||
// "95% for some time" mystery.
|
||||
const progressInterval = setInterval(() => { void pollDownloadProgress() }, 1000)
|
||||
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
total_bytes: number
|
||||
downloaded_bytes: number
|
||||
components_downloaded: number
|
||||
}>({ method: 'update.download', timeout: 1_800_000 })
|
||||
}>({ method: 'update.download', timeout: 3_900_000 })
|
||||
downloadPercent.value = 100
|
||||
downloaded.value = true
|
||||
const sizeMB = (res.downloaded_bytes / 1_048_576).toFixed(1)
|
||||
@@ -385,6 +834,10 @@ function requestRollback() {
|
||||
confirmAction.value = 'rollback'
|
||||
}
|
||||
|
||||
function requestCancelDownload() {
|
||||
confirmAction.value = 'cancel-download'
|
||||
}
|
||||
|
||||
function cancelConfirm() {
|
||||
confirmAction.value = null
|
||||
}
|
||||
@@ -395,40 +848,69 @@ async function executeConfirm() {
|
||||
if (action === 'apply') {
|
||||
await applyUpdate()
|
||||
} else if (action === 'git-apply') {
|
||||
await applyUpdateGit()
|
||||
await applyUpdateGitWithOverlay()
|
||||
} else if (action === 'rollback') {
|
||||
await rollbackUpdate()
|
||||
} else if (action === 'cancel-download') {
|
||||
await cancelDownload()
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUpdateGit() {
|
||||
applying.value = true
|
||||
statusMessage.value = ''
|
||||
async function cancelDownload() {
|
||||
cancelingDownload.value = true
|
||||
try {
|
||||
await rpcClient.call({ method: 'update.git-apply', timeout: 900_000 })
|
||||
showStatus(t('systemUpdate.gitApplyStarted'))
|
||||
updateInfo.value = null
|
||||
await rpcClient.call({ method: 'update.cancel-download' })
|
||||
downloading.value = false
|
||||
downloaded.value = false
|
||||
downloadPercent.value = 0
|
||||
downloadStalled.value = false
|
||||
showStatus(t('systemUpdate.cancelDownloadSuccess'))
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.applyFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Git apply failed', e)
|
||||
showStatus(t('systemUpdate.cancelDownloadFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Cancel download failed', e)
|
||||
} finally {
|
||||
applying.value = false
|
||||
cancelingDownload.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUpdate() {
|
||||
applying.value = true
|
||||
statusMessage.value = ''
|
||||
const target = updateInfo.value?.version || null
|
||||
try {
|
||||
await rpcClient.call({ method: 'update.apply', timeout: 300_000 })
|
||||
showStatus(t('systemUpdate.applySuccess'))
|
||||
updateInfo.value = null
|
||||
downloaded.value = false
|
||||
await loadStatus()
|
||||
// Apply succeeded. Backend scheduled a restart 2s after returning;
|
||||
// show the full-screen overlay while we wait for the new backend
|
||||
// to report the target version, then auto-reload.
|
||||
applying.value = false
|
||||
if (target) {
|
||||
startInstallOverlay(target)
|
||||
} else {
|
||||
// No target version known (legacy path) — fall back to the old
|
||||
// flash-and-reload behaviour.
|
||||
showStatus(t('systemUpdate.applySuccess'))
|
||||
setTimeout(() => window.location.reload(), 3000)
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.applyFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Apply failed', e)
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUpdateGitWithOverlay() {
|
||||
// Git-apply (dev path) also restarts the service — reuse the overlay
|
||||
// so the UX matches the manifest path. Target version isn't known up
|
||||
// front for git-apply; we just wait for a version change on /health.
|
||||
applying.value = true
|
||||
statusMessage.value = ''
|
||||
try {
|
||||
await rpcClient.call({ method: 'update.git-apply', timeout: 900_000 })
|
||||
applying.value = false
|
||||
startInstallOverlay(updateInfo.value?.version || currentVersion.value)
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.applyFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Git apply failed', e)
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
@@ -465,7 +947,45 @@ async function setSchedule(value: ScheduleValue) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Promise.all([loadStatus(), loadSchedule(), checkForUpdates()])
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadStatus(), loadSchedule(), loadMirrors(), checkForUpdates()])
|
||||
// If a download was already running when the user navigated here
|
||||
// (or refreshed), pick up the progress bar where it is and keep
|
||||
// polling until the backend reports done. No RPC call to start the
|
||||
// download — the backend's already running it.
|
||||
const active = await pollDownloadProgress()
|
||||
if (active) {
|
||||
downloading.value = true
|
||||
const resumeInterval = setInterval(async () => {
|
||||
const stillActive = await pollDownloadProgress()
|
||||
if (!stillActive) {
|
||||
clearInterval(resumeInterval)
|
||||
downloading.value = false
|
||||
downloaded.value = true
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Centered ASCII logo — clamped so the overlay doesn't blow out on
|
||||
narrow viewports. :deep so the rule reaches BitcoinFaceAscii's
|
||||
inner <pre>. */
|
||||
.install-overlay-ascii :deep(pre) {
|
||||
font-size: clamp(6px, 1.2vw, 12px);
|
||||
line-height: 1.1;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Indeterminate progress stripe that slides left-to-right. */
|
||||
.install-overlay-bar-anim {
|
||||
animation: installBarSlide 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes installBarSlide {
|
||||
0% { transform: translateX(-100%); }
|
||||
50% { transform: translateX(120%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,13 +22,13 @@ let cachedCatalog: AppCatalog | null = null
|
||||
let catalogFetchedAt = 0
|
||||
const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache
|
||||
|
||||
/** Remote catalog URLs — tried in order. First success wins. */
|
||||
/** Catalog URLs tried in order. First success wins.
|
||||
* Primary is the backend proxy (`/api/app-catalog`) — server-side fetch
|
||||
* bypasses CORS on git.tx1138.com and CSP restrictions on the IP-port
|
||||
* fallback. If the backend is offline (mid-restart etc.) we fall back
|
||||
* to the static copy baked into the frontend build. */
|
||||
const CATALOG_URLS = [
|
||||
// Primary: git.tx1138.com raw file (HTTPS, dynamic, updated without frontend rebuild)
|
||||
'https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json',
|
||||
// Fallback: direct IP (HTTP, only works if CSP allows http://$host:*)
|
||||
'http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json',
|
||||
// Last resort: local static file (baked into frontend build)
|
||||
'/api/app-catalog',
|
||||
'/catalog.json',
|
||||
]
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
|
||||
|
||||
for (const url of CATALOG_URLS) {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(5000) })
|
||||
const res = await fetch(url, { credentials: 'include', signal: AbortSignal.timeout(20000) })
|
||||
if (!res.ok) continue
|
||||
const data = await res.json() as AppCatalog
|
||||
if (!data.apps?.length) continue
|
||||
@@ -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',
|
||||
|
||||
@@ -9,15 +9,53 @@
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 class="text-xl font-semibold text-white">FIPS Mesh</h2>
|
||||
<div class="flex items-center gap-2" :title="statusLabel">
|
||||
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
|
||||
<span class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2" :title="statusLabel">
|
||||
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
|
||||
<span class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors"
|
||||
title="Seed anchors"
|
||||
aria-label="Open FIPS seed anchors settings"
|
||||
@click="showAnchorsModal = true"
|
||||
>
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm mb-4">Fast Nostr-keyed mesh routing</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seed anchors modal — operator-editable list of peers this node
|
||||
dials to bootstrap the mesh. Tucked behind the gear so it
|
||||
doesn't crowd the card but is still one click away. Close
|
||||
button and layout mirror the What's New modal (and the rest
|
||||
of the app) so it feels like a first-class modal. -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showAnchorsModal"
|
||||
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||
@click="showAnchorsModal = false"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div
|
||||
class="relative z-10 max-w-xl w-full"
|
||||
style="max-height: 90vh; overflow-y: auto"
|
||||
@click.stop
|
||||
>
|
||||
<FipsSeedAnchorsCard closable @close="showAnchorsModal = false" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3 shrink-0">
|
||||
<div class="p-3 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Daemon version</p>
|
||||
@@ -50,7 +88,7 @@
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="w-2 h-2 rounded-full" :class="status.anchor_connected ? 'bg-cyan-400' : 'bg-orange-400'"></span>
|
||||
<span class="text-white/70">Anchor (fips.v0l.io):</span>
|
||||
<span class="text-white/70">Anchor:</span>
|
||||
<span :class="status.anchor_connected ? 'text-cyan-300' : 'text-orange-300'">
|
||||
{{ status.anchor_connected ? 'connected' : 'not reached' }}
|
||||
</span>
|
||||
@@ -68,7 +106,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!status.anchor_connected" class="mt-2 text-[11px] text-white/40 leading-snug">
|
||||
Without the anchor, DHT routing to unknown npubs can't bootstrap; federation and messaging fall back to Tor until it reconnects. Reconnect restarts the FIPS daemon, which usually clears a stale identity cache.
|
||||
No known anchor is currently an authenticated peer. DHT routing to unknown npubs can't bootstrap; federation and messaging fall back to Tor until one reconnects. Reconnect restarts the FIPS daemon, which usually clears a stale identity cache. Add a cluster-local anchor in Seed Anchors if the public one is unreachable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -84,6 +122,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { safeClipboardWrite } from '@/views/web5/utils'
|
||||
import FipsSeedAnchorsCard from './FipsSeedAnchorsCard.vue'
|
||||
|
||||
interface FipsStatus {
|
||||
installed: boolean
|
||||
@@ -113,6 +152,7 @@ const reconnecting = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const statusIsError = ref(false)
|
||||
const copied = ref(false)
|
||||
const showAnchorsModal = ref(false)
|
||||
|
||||
async function copyNpub() {
|
||||
if (!status.value.npub) return
|
||||
@@ -180,21 +220,31 @@ async function installAndActivate() {
|
||||
}
|
||||
}
|
||||
|
||||
// Restart the FIPS daemon to kick it back onto the public anchor. Stale
|
||||
// identity-cache entries are the usual cause of "not reached"; systemctl
|
||||
// restart clears them and re-runs the bootstrap handshake.
|
||||
// Restart the FIPS daemon and wait for the anchor bootstrap window.
|
||||
// The backend runs a proper recovery sequence (stop → start → wait →
|
||||
// classify) and returns a structured diagnostic we can show the user
|
||||
// instead of a generic "still unreachable".
|
||||
async function reconnectAnchor() {
|
||||
reconnecting.value = true
|
||||
try {
|
||||
await rpcClient.call({ method: 'fips.restart', timeout: 45_000 })
|
||||
// Give the daemon a few seconds to come back and re-populate its
|
||||
// identity cache before we re-query status.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
await loadStatus()
|
||||
if (status.value.anchor_connected) {
|
||||
flash('Anchor reconnected')
|
||||
const res = await rpcClient.call<{
|
||||
recovered: boolean
|
||||
likely_cause: string
|
||||
hint: string
|
||||
after: FipsStatus
|
||||
}>({ method: 'fips.reconnect', timeout: 60_000 })
|
||||
// Update the card with the post-reconnect status returned by the
|
||||
// backend — avoids an extra status fetch race.
|
||||
status.value = { ...status.value, ...res.after }
|
||||
if (res.recovered) {
|
||||
flash('Anchor reconnected.')
|
||||
} else if (res.likely_cause === 'connected') {
|
||||
// Already connected, not a "recovery" per se.
|
||||
flash('Anchor is reachable.')
|
||||
} else {
|
||||
flash('FIPS restarted — anchor still reporting unreachable. Check network / firewall.', true)
|
||||
// Surface the backend's diagnostic hint verbatim — it's been
|
||||
// written for the fleet reader.
|
||||
flash(res.hint || 'Reconnect finished but anchor is still unreachable.', true)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1 relative">
|
||||
<button
|
||||
v-if="closable"
|
||||
type="button"
|
||||
class="absolute top-4 right-4 p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors z-10"
|
||||
aria-label="Close"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0" :class="{ 'pr-10': closable }">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<!-- Radio/broadcast icon — three concentric arcs radiating from a
|
||||
dot. Reads as mesh, signal, anchor-reaching-peers. -->
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0" />
|
||||
<circle cx="12" cy="18" r="1.25" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 class="text-xl font-semibold text-white">FIPS Seed Anchors</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors disabled:opacity-60"
|
||||
:disabled="applying"
|
||||
:title="applying ? 'Applying…' : 'Re-dial every anchor in the list'"
|
||||
@click="applyAll"
|
||||
>
|
||||
{{ applying ? 'Applying…' : 'Apply now' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm mb-4">
|
||||
Peers this node dials to bootstrap the FIPS mesh. A cluster with its own anchors doesn't depend on the global public anchor — if one is down, the next seeds the DHT instead.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
|
||||
|
||||
<div v-if="anchors.length === 0" class="p-4 rounded-lg bg-white/5 text-sm text-white/60 mb-3">
|
||||
<p>No seed anchors configured. The daemon will fall back to whatever the upstream FIPS build dials on its own — usually the single public anchor, which is fine until it isn't.</p>
|
||||
<p class="mt-2 text-white/50">Add at least one known-reachable peer (e.g. your VPS or a home node with port-forwarded UDP 8668) to make this cluster self-anchoring.</p>
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-2 mb-3">
|
||||
<li v-for="a in anchors" :key="a.npub" class="p-3 bg-white/5 rounded-lg flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-white truncate">{{ a.label || 'Unlabeled anchor' }}</p>
|
||||
<p class="text-xs text-white/60 font-mono break-all">{{ a.npub.slice(0, 20) }}…{{ a.npub.slice(-8) }}</p>
|
||||
<p class="text-xs text-white/50 mt-0.5">{{ a.address }} · {{ a.transport }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-xs px-2 py-1 rounded-md text-red-300 hover:bg-red-400/10 transition-colors"
|
||||
:title="`Remove ${a.label || a.npub.slice(0, 12)}`"
|
||||
@click="removeAnchor(a.npub)"
|
||||
>Remove</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-auto pt-3 border-t border-white/10 shrink-0" @submit.prevent="addAnchor">
|
||||
<label class="flex flex-col gap-1 sm:col-span-2">
|
||||
<span class="text-xs text-white/60">Anchor npub</span>
|
||||
<input v-model="draft.npub" type="text" placeholder="npub1…" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-white/60">Address (host:port)</span>
|
||||
<input v-model="draft.address" type="text" placeholder="192.168.1.116:8668" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-white/60">Label (optional)</span>
|
||||
<input v-model="draft.label" type="text" placeholder="Home anchor" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||
</label>
|
||||
<button type="submit" class="sm:col-span-2 min-h-[44px] glass-button rounded-lg text-sm font-medium disabled:opacity-60" :disabled="adding || !draft.npub || !draft.address">{{ adding ? 'Adding…' : 'Add anchor' }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
defineProps<{ closable?: boolean }>()
|
||||
defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
interface SeedAnchor {
|
||||
npub: string
|
||||
address: string
|
||||
transport: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ApplyResult {
|
||||
npub: string
|
||||
ok: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
const anchors = ref<SeedAnchor[]>([])
|
||||
const adding = ref(false)
|
||||
const applying = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const statusIsError = ref(false)
|
||||
|
||||
const draft = reactive<Pick<SeedAnchor, 'npub' | 'address' | 'label'>>({
|
||||
npub: '',
|
||||
address: '',
|
||||
label: '',
|
||||
})
|
||||
|
||||
function flash(msg: string, isError = false) {
|
||||
statusMessage.value = msg
|
||||
statusIsError.value = isError
|
||||
setTimeout(() => { statusMessage.value = '' }, 6000)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({ method: 'fips.list-seed-anchors' })
|
||||
anchors.value = res.seed_anchors
|
||||
} catch (e: unknown) {
|
||||
if (import.meta.env.DEV) console.warn('fips.list-seed-anchors failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function addAnchor() {
|
||||
if (!draft.npub.trim() || !draft.address.trim()) return
|
||||
adding.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[]; apply: ApplyResult[] }>({
|
||||
method: 'fips.add-seed-anchor',
|
||||
params: {
|
||||
npub: draft.npub.trim(),
|
||||
address: draft.address.trim(),
|
||||
transport: 'udp',
|
||||
label: draft.label.trim(),
|
||||
},
|
||||
})
|
||||
anchors.value = res.seed_anchors
|
||||
draft.npub = ''
|
||||
draft.address = ''
|
||||
draft.label = ''
|
||||
const applied = res.apply.find(r => r.ok)
|
||||
flash(applied ? 'Anchor added and dialed.' : 'Anchor saved — dial failed, will retry on the next apply cycle.', !applied)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
flash(`Add failed: ${msg}`, true)
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAnchor(npub: string) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({
|
||||
method: 'fips.remove-seed-anchor',
|
||||
params: { npub },
|
||||
})
|
||||
anchors.value = res.seed_anchors
|
||||
flash('Anchor removed.')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
flash(`Remove failed: ${msg}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAll() {
|
||||
applying.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ applied: number; results: ApplyResult[] }>({ method: 'fips.apply-seed-anchors' })
|
||||
const ok = res.results.filter(r => r.ok).length
|
||||
flash(`${ok} of ${res.applied} anchor${res.applied === 1 ? '' : 's'} dialed.`, ok === 0 && res.applied > 0)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
flash(`Apply failed: ${msg}`, true)
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -180,6 +180,469 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- 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">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.29-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>New App registries page in Settings — same experience as Update mirrors, but for the container registries your node pulls app images from. Add a mirror, test reachability with one click, pick the primary.</p>
|
||||
<p>New nodes default to the VPS registry as the primary for both app installs and the app catalog, with tx1138 as the automatic fallback if the VPS is slow or unreachable. Existing nodes keep whatever registry order they've already set.</p>
|
||||
<p>App installs now genuinely honor the primary registry: the first pull attempt rewrites the image URL to use your primary, and only falls through to the secondary if that fails. Before, installs always hit whichever registry the image was hardcoded to.</p>
|
||||
<p>Reboot screen now shows the animated "a" logo in the center of the ring — matching the screensaver's look so you get something nice to watch while the node comes back up.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.28-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.28-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>Reboot now shows a proper progress screen. Click Reboot and you'll see a full-screen overlay with the familiar pulsing ring animation, a rebooting / reconnecting / back-online status, and an elapsed counter — no more black screen of mystery while you wait.</p>
|
||||
<p>The overlay auto-reloads the page the moment your node is back up; if it takes longer than three minutes it surfaces a manual Reload button.</p>
|
||||
<p>New nodes now default to the VPS mirror as Server 1 (primary) and tx1138 as Server 2 (fallback). Existing nodes keep whatever mirror order they've already set — use Set Primary on the System Update page to change it.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.27-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.27-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>The Update page now shows which mirror delivered your update — a small "Served by" line under the new version tells you whether Server 1, Server 2, or a custom mirror was the one your node actually reached. Great for spot-checking that mirror fallback is doing its job.</p>
|
||||
<p>Every mirror row has a new lightning-bolt button that pings the mirror and shows whether it's reachable, plus the round-trip latency in milliseconds. No more guessing if a mirror you just added is responding.</p>
|
||||
<p>The Update mirrors section got a visual refresh: Set Primary, Remove, and the new Test action are compact icon buttons instead of crowded text, and adding a mirror now happens in a dedicated dialog that matches the rest of the UI.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.26-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.26-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>Update downloads now have a mirror list. If the primary update server is slow or unreachable, your node automatically tries the next mirror and downloads the files from there — no more waiting on a stalled server with no recourse.</p>
|
||||
<p>A new 'Update mirrors' section on the System Update page lets you see the list, add your own mirror URL, reorder which is tried first (Set primary), or remove one. The primary is tagged with a green PRIMARY pill.</p>
|
||||
<p>Downloads automatically follow the mirror that served the manifest. Previously every mirror served the same manifest, and the manifest's download URLs were hardcoded to a single server — so even picking a faster mirror couldn't speed up the actual download. Now the backend rewrites download URLs to match whichever mirror succeeded.</p>
|
||||
<p>Ships with two defaults: Server 1 (tx1138) and Server 2 (VPS). Add the URL format <code>https://host/.../releases/manifest.json</code> for custom mirrors.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.25-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.25-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>Your node can now reach the broader FIPS public mesh, not just your own federated cluster. The FIPS daemon now binds both UDP (fast mesh forwarding) and TCP (NAT-friendly bootstrap) transports — matching the upstream factory default. The public anchor currently answers on TCP, so UDP-only nodes couldn't reach it; this fixes that without any action needed on your end.</p>
|
||||
<p>Upgrading the config happens automatically. On next startup, if the installed FIPS yaml doesn't match the new two-transport schema, the node reinstalls and restarts the daemon so the TCP transport comes online. No manual Reconnect required.</p>
|
||||
<p>Side benefit: TCP also helps on networks that block outbound UDP (corporate, some guest wifi) — your node falls back to TCP/8443 automatically and still joins the mesh.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.24-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.24-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>Frontend updates now actually ship. Since roughly v1.7.17 the release pipeline had been rebuilding the backend every version but silently skipping the frontend bundle — a permissions issue on the build server meant vue-tsc failed before vite ever ran, and nobody noticed because the published tarballs still extracted cleanly. The result was the backend moving forward while the UI stayed frozen at its v1.7.9-era state, which is why the FIPS gear icon and the What's New entries for every release since then had been missing on your node.</p>
|
||||
<p>Once this update applies, your node gets the real v1.7.24 frontend: the FIPS Seed Anchors modal (gear icon on the FIPS Mesh card), the current What's New history, the cancel-download button, and every other UI touch from the releases in between.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.23-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.23-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>FIPS Seed Anchors are now one click away. A small gear icon sits next to the status pill on the FIPS Mesh card — click it to open a modal where you can add, remove, and re-apply anchors. No more needing to go digging for the card or editing JSON by hand.</p>
|
||||
<p>The modal lists each anchor with its label, truncated npub, address, and transport, plus an Apply button to force-redial the full list and a Remove button per entry. The add form right below validates that the address is host:port and the npub is bech32 before saving.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.22-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.22-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>The FIPS Reconnect and Restart buttons now work on every node, regardless of which systemd unit is actually supervising the daemon. Previously they targeted only the archipelago-managed unit — nodes that were running the upstream unit instead saw the buttons silently do nothing. Both paths now auto-detect which unit is up and act on that one.</p>
|
||||
<p>The FIPS anchor status no longer shows red just because one specific public anchor is unreachable. It now lights green whenever any authenticated peer is a recognised anchor — that's either the public anchor or something you added under Seed Anchors. A federated cluster that routes through its own seed anchor finally reports the truth.</p>
|
||||
<p>Reconnect also re-pushes your seed anchors after the restart, so you don't have to wait five minutes for the background apply loop to re-dial them.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.21-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.21-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>FIPS bootstrap no longer depends on a single public anchor. You can now add your own anchors — other archipelago nodes or a VPS you control — and the node will dial every one of them to join the mesh on startup. If one anchor is down, the next one seeds the routing layer instead, so a flaky public anchor no longer strands a fresh install.</p>
|
||||
<p>Anchors persist across restarts and are re-applied every five minutes, so a daemon that got temporarily isolated reconnects on its own without anyone having to SSH in. Each anchor carries an operator-editable label so you can remember which is which.</p>
|
||||
<p>No behavior change if you don't configure any — the upstream daemon's own defaults keep working as before. This purely adds an operator-controlled list on top.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.20-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.20-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>Fixed a critical bug where nodes on the automatic daily-update schedule could end up offline after their nightly update. The scheduler was killing the service a moment too early, before the built-in restart handler had a chance to bring the new version back up — leaving the node dead until someone SSH'd in and started it manually. The scheduler now hands off cleanly to the same restart path the 'Install Update' button uses, so auto-applied updates come back online on their own.</p>
|
||||
<p>Applies to any node configured for 'Check & Apply Daily' — no change required on your end, the fix ships with this update.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.19-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.19-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>Your node no longer offers a version you've already passed as an "available update". If you sideload or skip a release, any stored pointer to an earlier version is dropped on next restart, and the System Update page offers only the genuinely newer release — no more seeing an older version listed as something to install.</p>
|
||||
<p>Version comparison is now numeric, not alphabetic. 1.7.10 correctly outranks 1.7.9 (earlier naive string-order would have got this backwards once the patch number hits double digits), so update prompts and "up to date" checks stay accurate past the nines.</p>
|
||||
<p>A stale manifest from a slow cache or proxy can no longer downgrade your node. If the manifest reports a version equal to or behind what's running, your node treats that as "up to date" rather than offering the older version as an update.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.18-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.18-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Nodes discovered through a trusted peer now land as Trusted instead of Observer. When your federated peer shares its own peer list with you, those nodes get the same trust level as a direct invite — the link they came through is already one you vetted, so you no longer need to promote them by hand before they can be used normally.</p>
|
||||
<p>The update flow now writes clearer logs at every step. Start of download, cancel, and apply each emit a one-line entry to the system journal with the staging path and the affected files, so if a download misbehaves on your node it's easy to see exactly where it got to.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.17-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.17-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>When a download gets stuck, you can now cancel it. A new Cancel Download button sits next to the progress bar — it stops the transfer, clears the partial file, and returns you to a clean state so you can retry. No more staring at a frozen bar with no way to recover.</p>
|
||||
<p>Downloads that stall for 30 seconds or more now say so. The progress bar turns amber and shows 'Download appears stuck — try Cancel and start again' instead of just sitting silently at whatever percent it reached.</p>
|
||||
<p>Canceling is fast. It no longer has to wait out the retry timer — the download bails within half a second, so you're not stuck watching a stuck screen while you wait to unstick it.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.16-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.16-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Federation is now bidirectional and instant. When someone joins using your invite code, their node appears on your Federation page automatically — no need for the inviter to click Sync or wait for the next poll. Names and node details populate within seconds of the handshake finishing.</p>
|
||||
<p>New nodes can no longer federate with themselves. Accepting an invite that points back at the local node (by DID, public key, or onion address) is rejected up front, so self-peering no longer clutters the node list with a duplicate card.</p>
|
||||
<p>Transitive discovery: if nodes A and B are already federated and node C joins A, all three nodes now learn about each other. The new peer is pulled in as an Observer entry on existing federation members, so you can promote to Trusted with one click instead of trading a second invite code.</p>
|
||||
<p>The Federation page auto-refreshes every five seconds while it's open. Status changes, new peers, and incoming join requests surface on their own — clicking Sync remains available for an on-demand pull.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.15-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.15-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Updates survive network hiccups. Downloads now resume from exactly where a dropped connection left off, and retry up to 6 times with increasing gaps between attempts, instead of restarting from byte zero or giving up.</p>
|
||||
<p>The download progress bar now shows real progress. Instead of a fake number that creeps to 95% and freezes, you see the actual bytes arriving, and it continues to update correctly even if you navigate away and come back.</p>
|
||||
<p>Update check itself retries on slow responses. If git.tx1138.com is momentarily overloaded, the node tries three times with a five-second wait between attempts before concluding you're up to date.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.14-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.14-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Installing an update now shows a full-screen progress overlay with the Archipelago logo, a status message, and an animated bar. The page reloads itself automatically once the new version is up — no manual refresh. If something stalls, a 'Reload now' button appears after a few minutes.</p>
|
||||
<p>Download progress no longer looks frozen near the end. The bar pauses at 95% with a 'Finishing download — verifying checksum…' message and spinner while the last bytes arrive and are hashed.</p>
|
||||
<p>FIPS Reconnect now genuinely tries to fix the anchor. It runs a proper recovery sequence (stop → start → wait for the bootstrap window → check peers) and tells you the likely reason it's still unreachable — corrupt identity key, seed not unlocked, network blocking UDP, or the anchor server being down — instead of a generic 'try again'.</p>
|
||||
<p>Healed a latent FIPS identity bug: the public-key file was being written in text form (an 'npub1…' string) on some nodes, which the daemon couldn't parse and silently authenticated with a garbage key. The Reconnect button now rewrites the file in the correct binary format and re-installs the config before restarting — nodes stuck with no peers for 'no reason' should come back online.</p>
|
||||
<p>AIUI (Claude sidebar) is back. The installer now ships AIUI in the frontend bundle and preserves it across future updates — it was being wiped on every OTA because it lived outside the Vue build.</p>
|
||||
<p>Installing a big app (IndeedHub, Bitcoin, Penpot) no longer gives up early and shows 'didn't work' while the download is still running in the background. The client waits up to 45 minutes for the install pipeline to finish.</p>
|
||||
<p>'Rollback to Previous' is now labelled 'Rollback Available' — clearer that it's a choice you have, not a status you're stuck with.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.13-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.13-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>App catalog now loads reliably. Before, the Marketplace / Discover page couldn't fetch the catalog of apps because the upstream host wasn't sending the right CORS headers and the node's security policy didn't allow the fallback URL either. The node now fetches the catalog server-side and serves it same-origin to the browser — no more blank app lists.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.12-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.12-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Nothing new — version bump so freshly-installed nodes (from the 1.7.11 ISO) have something to OTA down, confirming the end-to-end update pipeline out of the box.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.11-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.11-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>OTA proof release — first version where Install Update should run clean from the UI with no manual steps. Click it and watch the sidebar flip to 1.7.11-alpha on its own.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.10-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.10-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Install Update actually applies now. The installer had to write into system folders that the backend service was sandboxed out of — every earlier 'Failed to apply update' was a layer of that onion. Fixed by running the file swaps in a separate system context.</p>
|
||||
<p>FIPS status on the Home and Server pages now reflects whether the public anchor is reachable. You'll see 'Active · N peers' (green) when healthy or 'No anchor' (orange) when the network is blocking the bootstrap — same signal as the full FIPS card.</p>
|
||||
<p>Pasting an https://… URL into the profile picture or banner now previews correctly. Before, if the URL failed to load, the UI would silently blank out instead of showing your initial as a placeholder.</p>
|
||||
<p>Uploaded profile pictures under 64 KB are now embedded directly in your Nostr profile (as a data URL), so any Nostr client can see them — not just ones routing over Tor. Larger uploads keep the onion URL for now, with a hint to paste a public URL for wider visibility.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.9-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.9-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>OTA verification release — nothing new to see. Click Install Update, grab a coffee, and watch the sidebar flip to 1.7.9-alpha on its own. If this one works end to end, the pipeline is solid and future updates will flow the same way.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.8-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.8-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Install Update finally works end-to-end over the air. The installer was trying to overwrite the running backend binary with a tool that fails on in-use files (ETXTBSY) — swapped it for an atomic rename, which the kernel allows on a live executable. Every previous 'Failed to apply update' attempt was this one root cause.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.7-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.7-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Over-the-air update test — no feature changes, just a version bump so your node can walk through the whole update flow end-to-end using the new robust installer. Safe to apply; nothing to do afterwards.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.6-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.6-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Install Update is now more robust. Each install gets its own uniquely-named staging folder and then moves files into place — the previous version had a small cleanup step that could hit a transient filesystem hiccup and bail out halfway. You'll also still see a rollback folder after a successful install.</p>
|
||||
<p>Dev-box OTA: nodes that build archipelago from source can now opt into the standard Download → Install flow instead of Pull & Rebuild, by setting ARCHIPELAGO_UPDATE_URL in the service environment. Useful when the dev machine has a checked-out repo but you want to test the regular update path.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.5-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.5-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Over-the-air update test — no feature changes, just a fresh version number so your node can walk through the whole update flow end-to-end: check, download, install, auto-restart. Safe to apply; nothing to do afterwards.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.4-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.4-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Install Update actually installs now. Before, the final step extracted the new UI into the wrong folder and bailed with 'Failed to apply update' — your node ended up backing up cleanly but never swapping in the new files. Fixed.</p>
|
||||
<p>Download progress no longer overshoots 100%. You'll see the bar climb smoothly to 95% and then jump to 100% when the download actually finishes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.3-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.3-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The version number in the sidebar now always matches the actual running version — no more lying to you about being on an older release after an update.</p>
|
||||
<p>FIPS Mesh card on the server page: cleaner layout on desktop (no more awkward gaps), and a one-click Reconnect button when the public anchor is unreachable — it restarts the FIPS daemon so it can re-bootstrap from the anchor.</p>
|
||||
<p>Profile pictures now show correctly in the identity list and editor. Before, uploaded images silently failed to render because the URL was only reachable over Tor; the UI now rewrites them to a local path while keeping the external URL for other Nostr clients.</p>
|
||||
<p>Identity rows now show your Display Name first (from your Nostr profile) with the internal identity name beside it in parentheses, so you see the name other people will see — not just the one you picked when creating it.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.2-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.2-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Install Update now actually installs. Before, the button would back up your current version then fail with 'Failed to apply update' because the installer couldn't write into system folders.</p>
|
||||
<p>The button's also been renamed to 'Install Update' (previously 'Apply Update') and the node restarts itself a moment after you click it — no more manual restart step.</p>
|
||||
<p>Your existing identities now show the generated avatar instead of just their initials — same look as freshly created ones.</p>
|
||||
<p>Everything from 1.7.0-alpha and 1.7.1-alpha carries over (default avatars on creation, one-click Save publishes to Nostr relays, public blob URLs for profile pictures, 30-minute download window, VPN peer restore on reboot, reconciler-only-repairs, filebrowser fix).</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.1-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.1-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Over-the-air update test — same features as 1.7.0, just a fresh version number so your node can try the new download-and-apply flow end-to-end. Safe to apply; nothing to do afterwards.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.0-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.0-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Every identity now gets a personal avatar the moment it's created. Your main node identity gets a distinctive hexagonal-network icon; other identities get a colourful generated pattern unique to each one.</p>
|
||||
<p>Profile editor: upload a profile picture and a banner, then tap Save — your Nostr profile now goes out to the relays in one step. No more 'Save' vs 'Save & Publish' confusion.</p>
|
||||
<p>Profile pictures and banners you upload are now reachable by other Nostr clients across the network — not just your own browser. Anyone who sees your profile on a relay can load the image.</p>
|
||||
<p>Update downloads on slow connections no longer cut out right at the end. The client waits up to 30 minutes for each component instead of giving up after 15 seconds.</p>
|
||||
<p>When you move a node to a new version without going through Check for Updates (for example via a reinstall or manual copy), it now reports the new version correctly instead of endlessly saying 'update available'.</p>
|
||||
<p>Your VPN peers come back automatically after a reboot. No more rescanning QR codes on your phone or laptop.</p>
|
||||
<p>Fresh installs stay lean — only File Browser is included out of the box. Other apps wait in the Marketplace until you pick them.</p>
|
||||
<p>File Browser stops rebooting itself every few hours — the housekeeper now leaves it alone once it's healthy.</p>
|
||||
<p>One-click 'Pull & Rebuild' button works for nodes that update from source (the development path), not just the standard download path.</p>
|
||||
<p>The download progress number is now clean (like 45.23%) instead of 45.270894%.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.3.5 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- App Registries Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">App registries</h2>
|
||||
<p class="text-sm text-white/60 mt-1">
|
||||
Choose the primary registry for app installs and add mirrors for fallback.
|
||||
</p>
|
||||
</div>
|
||||
<RouterLink
|
||||
to="/dashboard/settings/registries"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2"
|
||||
>
|
||||
<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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1M12 12V3m0 0l-4 4m4-4l4 4" />
|
||||
</svg>
|
||||
Manage registries
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import ScreensaverRing from '@/components/ScreensaverRing.vue'
|
||||
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
@@ -13,6 +15,59 @@ const rebooting = ref(false)
|
||||
const rebootPassword = ref('')
|
||||
const rebootError = ref('')
|
||||
|
||||
// Reboot overlay — full-screen progress shown once the reboot is committed.
|
||||
// Mirrors the update overlay pattern in SystemUpdate.vue: poll /health,
|
||||
// auto-reload when the backend returns, stall fallback at 3 min.
|
||||
type RebootStage = 'rebooting' | 'reconnecting' | 'ready' | 'stalled'
|
||||
const rebootOverlay = ref(false)
|
||||
const rebootStage = ref<RebootStage>('rebooting')
|
||||
const rebootStartedAt = ref(0)
|
||||
const rebootElapsedSec = ref(0)
|
||||
let rebootPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let rebootElapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
const rebootElapsedLabel = computed(() => {
|
||||
const s = rebootElapsedSec.value
|
||||
if (s < 60) return `Elapsed: ${s}s`
|
||||
return `Elapsed: ${Math.floor(s / 60)}m${s % 60 < 10 ? '0' : ''}${s % 60}s`
|
||||
})
|
||||
|
||||
function startRebootOverlay() {
|
||||
rebootOverlay.value = true
|
||||
rebootStage.value = 'rebooting'
|
||||
rebootStartedAt.value = Date.now()
|
||||
rebootElapsedSec.value = 0
|
||||
rebootElapsedTimer = setInterval(() => {
|
||||
rebootElapsedSec.value = Math.floor((Date.now() - rebootStartedAt.value) / 1000)
|
||||
if (rebootElapsedSec.value >= 180 && rebootStage.value !== 'ready') {
|
||||
rebootStage.value = 'stalled'
|
||||
}
|
||||
}, 1000)
|
||||
// Start health polling after 2.5s — the kernel has to go down before
|
||||
// /health can disappear, and we don't want to see the pre-reboot health
|
||||
// reply and mis-report "ready".
|
||||
setTimeout(() => {
|
||||
rebootStage.value = 'reconnecting'
|
||||
rebootPollTimer = setInterval(pollRebootHealth, 1500)
|
||||
}, 2500)
|
||||
}
|
||||
async function pollRebootHealth() {
|
||||
if (rebootStage.value === 'ready' || rebootStage.value === 'stalled') return
|
||||
try {
|
||||
const res = await fetch('/health', { signal: AbortSignal.timeout(2000) })
|
||||
if (!res.ok) throw new Error(`health ${res.status}`)
|
||||
rebootStage.value = 'ready'
|
||||
if (rebootPollTimer) { clearInterval(rebootPollTimer); rebootPollTimer = null }
|
||||
setTimeout(() => { window.location.reload() }, 1200)
|
||||
} catch {
|
||||
// Fetch failing is the normal state while the host is down.
|
||||
}
|
||||
}
|
||||
function rebootReloadNow() { window.location.reload() }
|
||||
onBeforeUnmount(() => {
|
||||
if (rebootPollTimer) clearInterval(rebootPollTimer)
|
||||
if (rebootElapsedTimer) clearInterval(rebootElapsedTimer)
|
||||
})
|
||||
|
||||
async function performReboot() {
|
||||
if (!rebootPassword.value) return
|
||||
rebooting.value = true
|
||||
@@ -21,6 +76,7 @@ async function performReboot() {
|
||||
await rpcClient.call({ method: 'system.reboot', params: { password: rebootPassword.value } })
|
||||
showRebootConfirm.value = false
|
||||
rebootPassword.value = ''
|
||||
startRebootOverlay()
|
||||
} catch (e) {
|
||||
rebootError.value = e instanceof Error ? e.message : 'Reboot failed'
|
||||
rebooting.value = false
|
||||
@@ -108,6 +164,55 @@ async function performFactoryReset() {
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Reboot Progress Overlay -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="rebootOverlay"
|
||||
class="fixed inset-0 z-[3000] bg-black flex flex-col items-center justify-center overflow-hidden"
|
||||
>
|
||||
<!-- Centered animated ring + logo — same composition as the screensaver -->
|
||||
<div class="reboot-ring-content">
|
||||
<ScreensaverRing />
|
||||
<div class="reboot-logo-wrapper">
|
||||
<ScreensaverLogo />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage text + progress bar underneath -->
|
||||
<div class="mt-8 w-[min(520px,80vw)] text-center">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">
|
||||
{{ rebootStage === 'rebooting' ? 'Rebooting…'
|
||||
: rebootStage === 'reconnecting' ? 'Reconnecting to your node…'
|
||||
: rebootStage === 'ready' ? 'Back online'
|
||||
: 'Reboot is taking longer than expected' }}
|
||||
</h2>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Your node is restarting. This page will refresh automatically once it's back.
|
||||
</p>
|
||||
|
||||
<!-- Animated progress bar: indeterminate stripe while working,
|
||||
solid green when ready, paused at half while stalled. -->
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden mb-3 relative">
|
||||
<div v-if="rebootStage === 'ready'" class="absolute inset-0 bg-green-400"></div>
|
||||
<div v-else-if="rebootStage === 'stalled'" class="absolute inset-y-0 left-0 w-1/2 bg-orange-400/60"></div>
|
||||
<div v-else class="absolute inset-y-0 w-1/3 bg-orange-400 rounded-full reboot-overlay-bar-anim"></div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-white/40">{{ rebootElapsedLabel }}</p>
|
||||
|
||||
<button
|
||||
v-if="rebootStage === 'stalled'"
|
||||
@click="rebootReloadNow"
|
||||
class="mt-5 glass-button rounded-lg px-5 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
|
||||
>
|
||||
Reload now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Factory Reset Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6 border border-red-500/30">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
@@ -148,3 +253,37 @@ async function performFactoryReset() {
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.reboot-ring-content {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.reboot-logo-wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 10;
|
||||
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
|
||||
.reboot-overlay-bar-anim {
|
||||
animation: rebootBarSlide 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes rebootBarSlide {
|
||||
0% { transform: translateX(-100%); }
|
||||
50% { transform: translateX(120%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@ import InterfaceModeSection from '@/views/settings/InterfaceModeSection.vue'
|
||||
import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
|
||||
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
|
||||
import SystemUpdatesSection from '@/views/settings/SystemUpdatesSection.vue'
|
||||
import AppRegistriesSection from '@/views/settings/AppRegistriesSection.vue'
|
||||
import WebhookSection from '@/views/settings/WebhookSection.vue'
|
||||
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
|
||||
import BackupSection from '@/views/settings/BackupSection.vue'
|
||||
@@ -14,6 +15,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
|
||||
<ClaudeAuthSection />
|
||||
<AIDataAccessSection />
|
||||
<SystemUpdatesSection />
|
||||
<AppRegistriesSection />
|
||||
<WebhookSection />
|
||||
<TelemetrySection />
|
||||
<BackupSection />
|
||||
|
||||
@@ -68,8 +68,13 @@
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
|
||||
<img v-if="identity.profile?.picture" :src="displayableUrl(identity.profile.picture)" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
|
||||
<div v-if="!identity.profile?.picture" class="w-full h-full flex items-center justify-center" :class="{
|
||||
<img
|
||||
v-if="identity.profile?.picture && !listPictureFailed[identity.id]"
|
||||
:src="displayableUrl(identity.profile.picture)"
|
||||
class="w-full h-full object-cover"
|
||||
@error="() => { listPictureFailed[identity.id] = true }"
|
||||
/>
|
||||
<div v-if="!identity.profile?.picture || listPictureFailed[identity.id]" class="w-full h-full flex items-center justify-center" :class="{
|
||||
'bg-blue-500/20': identity.purpose === 'personal',
|
||||
'bg-orange-500/20': identity.purpose === 'business',
|
||||
'bg-purple-500/20': identity.purpose === 'anonymous',
|
||||
@@ -302,8 +307,14 @@
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
|
||||
<img v-if="profileForm.picture" :src="displayableUrl(profileForm.picture)" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
v-if="profileForm.picture && !editorPictureFailed"
|
||||
:src="displayableUrl(profileForm.picture)"
|
||||
class="w-full h-full object-cover"
|
||||
@error="editorPictureFailed = true"
|
||||
@load="editorPictureFailed = false"
|
||||
/>
|
||||
<div v-if="!profileForm.picture || editorPictureFailed" class="w-full h-full flex items-center justify-center">
|
||||
<span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,7 +379,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { safeClipboardWrite } from './utils'
|
||||
@@ -409,6 +420,18 @@ const profilePublishing = ref(false)
|
||||
const avatarUploading = ref(false)
|
||||
const bannerUploading = ref(false)
|
||||
|
||||
// Track image load failures so the UI can fall back to the initial/
|
||||
// identicon placeholder instead of showing a blank square. Pasted URLs
|
||||
// that 404 (or point at an onion the local browser can't reach) were
|
||||
// previously silently hidden by a display:none handler that left the
|
||||
// fallback unrendered.
|
||||
const editorPictureFailed = ref(false)
|
||||
const listPictureFailed = reactive<Record<string, boolean>>({})
|
||||
|
||||
// Reset the failure flag when the URL changes so a freshly pasted URL
|
||||
// gets re-tried (the watcher fires once the form reacts).
|
||||
watch(() => profileForm.value.picture, () => { editorPictureFailed.value = false })
|
||||
|
||||
// The backend returns onion-based public URLs for uploaded profile
|
||||
// pictures (so they're fetchable by external Nostr clients), but the
|
||||
// local browser session isn't Tor-routed and can't resolve .onion hosts.
|
||||
@@ -423,10 +446,12 @@ function displayableUrl(url: string | null | undefined): string {
|
||||
return url
|
||||
}
|
||||
|
||||
// Upload to the node's blob store and drop the returned public URL into
|
||||
// the profile field. The /api/blob endpoint marks these blobs public, so
|
||||
// the URL served back (`public_url`, onion-rooted when Tor is up) is
|
||||
// reachable by external Nostr clients fetching kind:0 metadata.
|
||||
// Upload to the node's blob store and drop a URL into the profile field.
|
||||
// For small images (≤64KB) we inline the bytes as a data URL so external
|
||||
// Nostr clients can render the picture without needing to reach a tor
|
||||
// onion. Larger uploads fall back to the onion-rooted public_url.
|
||||
const INLINE_MAX = 64 * 1024
|
||||
|
||||
async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
||||
const input = ev.target as HTMLInputElement
|
||||
const file = input?.files?.[0]
|
||||
@@ -436,6 +461,14 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
||||
profileError.value = ''
|
||||
try {
|
||||
const buf = await file.arrayBuffer()
|
||||
// Inline small images as a data URL — universally fetchable by any
|
||||
// Nostr client and bypasses the "only reachable over Tor" limitation.
|
||||
if (buf.byteLength <= INLINE_MAX) {
|
||||
const mime = file.type || 'image/png'
|
||||
const b64 = btoa(Array.from(new Uint8Array(buf), (b) => String.fromCharCode(b)).join(''))
|
||||
profileForm.value[field] = `data:${mime};base64,${b64}`
|
||||
return
|
||||
}
|
||||
const resp = await fetch('/api/blob', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
@@ -451,6 +484,11 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
||||
const url = public_url || self_test_url
|
||||
if (!url) throw new Error('blob API returned no URL')
|
||||
profileForm.value[field] = url
|
||||
// Heads-up for large uploads: onion URLs only render on Tor-routed
|
||||
// clients. Not an error, but worth telling the user.
|
||||
if (url.includes('.onion/')) {
|
||||
profileError.value = 'Large image stored on this node. Pasting a public https://… URL is recommended for Nostr visibility.'
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
profileError.value = e instanceof Error ? e.message : `${field} upload failed`
|
||||
} finally {
|
||||
|
||||
@@ -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
|
||||
|
||||