Compare commits
@@ -137,7 +137,11 @@ fun WebViewScreen(
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
)
|
||||
).apply {
|
||||
// Required when launching from a non-Activity/binder
|
||||
// thread (the JS bridge below runs off the UI thread).
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
@@ -169,8 +173,29 @@ fun WebViewScreen(
|
||||
allowContentAccess = true
|
||||
allowFileAccess = false
|
||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||
// Let JS open windows without a synchronous user-gesture
|
||||
// chain; without this, window.open() from a Vue click
|
||||
// handler silently no-ops and "Open in new tab" dies.
|
||||
javaScriptCanOpenWindowsAutomatically = true
|
||||
}
|
||||
|
||||
// Deterministic bridge for "open in the phone's browser".
|
||||
// The web UI calls window.ArchipelagoNative.openExternal(url)
|
||||
// when present (companion app), falling back to window.open
|
||||
// in a plain mobile browser. This avoids relying on the
|
||||
// window.open → onCreateWindow path, which noopener/noreferrer
|
||||
// can suppress in the WebView.
|
||||
val webViewRef = this
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun openExternal(url: String) {
|
||||
webViewRef.post { openExternalUrl(url) }
|
||||
}
|
||||
},
|
||||
"ArchipelagoNative",
|
||||
)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
isLoading = true
|
||||
|
||||
@@ -1,5 +1,88 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.97-alpha (2026-06-16)
|
||||
|
||||
- The Bitcoin sync status on the home screen no longer disappears for a moment when it refreshes. If the node was briefly busy, the panel used to vanish and pop back; it now stays put and simply shows "Updating…" until the next reading arrives, while a genuinely stopped node still correctly shows as not running.
|
||||
- Bitcoin sync progress on the home screen now updates more promptly, so the percentage and block height keep pace with the node instead of lagging behind.
|
||||
- The Lightning wallet "connect your wallet" screen loads its details and QR code again across all nodes, instead of failing to fetch them.
|
||||
- Your list of trusted nodes is now clean: the same node no longer appears several times under different names, and removed nodes stay removed. In chat, a node that previously showed up as two separate contacts now appears just once.
|
||||
- Browsing another node's cloud is smoother: music and video files from a peer now preview and play properly (including seeking partway through), and the connection now shows a small badge telling you whether it's using the fast encrypted mesh or the slower Tor network.
|
||||
- Opening "My Folders" in the cloud now shows a clear, friendly message when the file app isn't running, instead of a confusing error.
|
||||
- The Electrum server app opens on its own once it's ready, instead of sometimes leaving a loading spinner stuck on top of the screen.
|
||||
- The Fedimint app now displays with its proper styling and icons, instead of appearing unstyled with a missing image.
|
||||
- The Mempool app now connects to your Bitcoin node whether the node is Bitcoin Core or Bitcoin Knots, instead of only working with one of them.
|
||||
- Nodes start up cleanly after a reboot. On some boots the node's main service was trying to start before its data drive had finished mounting, so it failed and retried about twenty times over roughly five minutes — showing a wall of "Failed to start" messages — before finally coming up. It now waits for the data drive to be ready first, so it starts on the first try.
|
||||
- The background images throughout the interface now load faster — they've been made significantly smaller with no loss of quality.
|
||||
|
||||
## v1.7.96-alpha (2026-06-15)
|
||||
|
||||
- The screen attached to your node now shows the normal Archipelago interface and your dashboard after you sign in, instead of a separate, stripped-down grid of app icons that could appear in its place. That extra screen has been removed so the attached display matches what you see everywhere else.
|
||||
- On a brand-new node, the attached screen now walks through the same welcome and setup steps you'd see on a phone or laptop, and shows the normal sign-in screen once the node is set up — so the on-device display always matches the rest of the interface.
|
||||
- When adding a FIPS network anchor, you can now choose whether it connects over TCP (for a public anchor reached across the internet) or UDP (for one on your local network), instead of it always assuming the local-network option.
|
||||
- Behind the scenes, a new automated two-node test now exercises real node-to-node features — browsing another node's shared files and handling a removed node — against live nodes before each release, so node-to-node problems are caught earlier.
|
||||
|
||||
## v1.7.95-alpha (2026-06-15)
|
||||
|
||||
- Browsing another node's shared files now works over the fast encrypted mesh. Opening a peer's cloud could fail with a generic "Operation failed" message because the request for their file list wasn't permitted over the mesh and came back as "not found" — and it never retried over Tor. The mesh now serves the file list directly, and if a peer can't answer over the mesh the node automatically falls back to Tor instead of giving up.
|
||||
- Nodes you remove from your federation now stay removed. Previously a deleted node could quietly come back the next time you synced with another node that still listed it. Removed nodes are now remembered as removed and won't reappear on their own — only if you add them back yourself.
|
||||
- The app credentials pop-up now appears as a normal centred box with a dimmed background over the whole screen, instead of stretching to fill the entire screen.
|
||||
|
||||
## v1.7.94-alpha (2026-06-15)
|
||||
|
||||
- Your node now joins the private encrypted mesh network on its own. A wrong built-in setting meant nodes were quietly never reaching the shared mesh meeting point, so everything between nodes fell back to the slower Tor network. Every node now connects to the mesh automatically on startup, so node-to-node features like file sharing use the faster encrypted mesh first and only fall back to Tor when a peer is genuinely offline. (Confirmed live: a node with its mesh setting wiped re-connected to the mesh by itself within a second of starting.)
|
||||
- You can now bring the mesh networking software up to the latest stable version straight from the node, with one action — it fetches the new version, checks it's genuine before installing, and restarts the mesh on its own. (Confirmed live end to end: a node on an older build was upgraded to the current stable release and rejoined the mesh automatically.)
|
||||
- The Lightning wallet screen connects again on nodes where it was showing a "failed to fetch" error instead of your balance and channels. The wallet app and the node now talk to each other correctly, and the connection quietly repairs itself if its details drift after a restart.
|
||||
|
||||
## v1.7.93-alpha (2026-06-14)
|
||||
|
||||
- Receiving Bitcoin and Lightning works again on nodes where the Lightning wallet was stuck locked. After some updates the wallet could come back locked with a password the node no longer had, so "generate a receive address" kept failing with a "wallet is locked" message that nothing could clear. The node now detects this and repairs itself automatically.
|
||||
- Each node now secures its Lightning wallet with its own unique, randomly generated password instead of a shared built-in one, and remembers it safely so the wallet unlocks on its own after every restart or update — no more getting stuck locked.
|
||||
- If a wallet is found locked with an unrecoverable password, the node rebuilds it cleanly so Bitcoin and Lightning start working again. (On these early-access nodes the wallet holds no funds, so nothing is lost — a wallet locked with an unknown password was already inaccessible.)
|
||||
- The self-repair was validated end to end on live nodes: a stuck, locked wallet was detected, rebuilt, and came back unlocked on its own, and stayed unlocked across restarts.
|
||||
|
||||
## v1.7.92-alpha (2026-06-14)
|
||||
|
||||
- The Electrum server app no longer flashes a "can't connect, try again" error over its loading screen while it's still catching up. If ElectrumX is building its index or waiting on the Bitcoin node, you now just see the sync progress, and the app opens on its own once it's ready.
|
||||
- Behind the scenes, the reboot-survival test now confirms the whole system is genuinely healthy after a restart — every app reachable, updates not stuck, core services answering — instead of only checking that containers came back, so update-related problems are caught before shipping.
|
||||
- Settings → What's New now lists the notes for every recent release again. The screen had quietly fallen several versions behind, so the last eight releases of changes weren't showing up there — they're all back now, and a release check keeps it from drifting again.
|
||||
|
||||
## v1.7.91-alpha (2026-06-14)
|
||||
|
||||
- Apps you've installed now reliably show their "Open" button again. Some apps — including Jellyfin, BTCPay Server, Fedimint, Gitea and Portainer — were running fine but their launch link sometimes went missing, so there was no way to open them from the home screen. They now open correctly.
|
||||
- Receiving Bitcoin is more dependable: if the wallet's internal connection details drift after a restart, it now repairs them on its own, and any error it does hit is reported clearly instead of as a generic failure or a misleading "wallet locked" message.
|
||||
- Installing Bitcoin now sets itself up correctly without manual help — a security credential that could previously be missing and stop Bitcoin from starting is created automatically before it launches.
|
||||
- The Electrum server app is back on the home screen and can be launched again.
|
||||
- Behind the scenes, the release now runs an expanded automated test suite before shipping, so these kinds of issues are caught earlier.
|
||||
|
||||
## v1.7.90-alpha (2026-06-13)
|
||||
|
||||
- Generating a Bitcoin receive address works again — the wallet now requests the correct address type, fixing the "400 Bad Request" error when creating an address.
|
||||
- In the companion app, the on-screen pointer can now click into apps and type — including the app store search box — instead of clicks and keystrokes not reaching app content.
|
||||
- "Open in a new tab" from the companion app now opens the app in your phone's browser, instead of doing nothing. The normal mobile browser keeps working as before.
|
||||
- The login/credentials pop-up on phones is once again a centered, properly sized window rather than stretching the full height of the screen.
|
||||
- The Electrum server now recovers on its own if its index ever gets corrupted, and shows a clear progress screen (with percent complete and block height) while it builds its index, instead of a blank or broken page.
|
||||
- Software updates are more reliable on slow internet connections — downloads are given much more time to finish before giving up.
|
||||
|
||||
## v1.7.89-alpha (2026-06-12)
|
||||
|
||||
- The AI assistant looks the way it always did again: no extra back button or close button on phones, and the desktop view fills the whole screen without a gap at the bottom.
|
||||
- System updates are much more reliable: updates that previously got stuck partway or failed to install now complete cleanly, and a failed update can no longer block all future updates.
|
||||
- After an update, the system now checks itself correctly on every node type, so working updates are no longer mistakenly undone.
|
||||
- Generating a Bitcoin receive address works again on nodes where a network proxy previously got in the way.
|
||||
- The Lightning wallet now recovers and unlocks itself properly after restarts.
|
||||
|
||||
## v1.7.88-alpha (2026-06-12)
|
||||
|
||||
- AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression.
|
||||
- Bitcoin receive now uses LND's GET-based newaddress flow with the native SegWit address type, fixing the `501 Method Not Allowed` response from the previous POST attempt.
|
||||
- Validation pending on the AIUI rollback; the rest of the release train remains unchanged.
|
||||
|
||||
## v1.7.87-alpha (2026-06-12)
|
||||
|
||||
- Bitcoin receive now calls LND's on-chain address endpoint with the correct REST method, and backend failures keep the specific address-generation error instead of collapsing into the generic operation-failed message.
|
||||
- App launch credential interstitials now render as true full-screen overlays, and the launcher loading indicator uses the neutral brand palette instead of a blue spinner.
|
||||
- Validation passed with `git diff --check`, `npm run type-check`, and the focused frontend tests for `bitcoinReceive` and `AppIconGrid`.
|
||||
|
||||
## v1.7.86-alpha (2026-06-12)
|
||||
|
||||
- Fleet now preserves the last known node list, alerts, and selection locally while telemetry refreshes in the background, so the dashboard no longer blanks on tab switches or update scans.
|
||||
|
||||
@@ -51,6 +51,20 @@ app:
|
||||
- CACHE_MB=1024
|
||||
- MAX_SEND=10000000
|
||||
|
||||
# The ElectrumX dashboard tile is served by the host-networked companion UI
|
||||
# (archy-electrs-ui) on port 50002, NOT by this container. Declaring it here
|
||||
# lets the catalog generator emit electrumx -> 50002 into GENERATED_APP_PORTS
|
||||
# so the tile resolves a launch URL without relying on the hand-maintained
|
||||
# override in appSessionConfig.ts (which the generator can clobber). The
|
||||
# backend only validates this block — it does not proxy/health-check it.
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: ElectrumX server status and connection details
|
||||
type: ui
|
||||
port: 50002
|
||||
protocol: http
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:50001
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
app:
|
||||
id: fips-ui
|
||||
name: FIPS Mesh
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Archipelago-native dashboard for the FIPS mesh transport. Runs nginx
|
||||
inside a container with host networking, serves a static dashboard on
|
||||
:8336, and reverse-proxies /rpc/v1 to the archipelago backend on
|
||||
127.0.0.1:5678. All FIPS controls (status, seed anchors, reconnect,
|
||||
restart, and stable-channel daemon updates) go through the existing
|
||||
fips.* RPC methods, authenticated by the browser's own archipelago
|
||||
session — there is no separate secret to manage.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/fips-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/fips-ui:local
|
||||
|
||||
resources:
|
||||
memory_limit: 128Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 8336 directly on the host IP and
|
||||
# proxies to 127.0.0.1:5678 (the archipelago RPC). `ports:` is
|
||||
# intentionally empty because host networking bypasses port mapping.
|
||||
ports: []
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8336
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -8,6 +8,12 @@ app:
|
||||
image: git.tx1138.com/lfg2025/mempool-backend:v3.0.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or
|
||||
# Core — resolved at apply time from host facts (B12). Hardcoding either
|
||||
# breaks mempool's RPC connection on the other.
|
||||
derived_env:
|
||||
- key: CORE_RPC_HOST
|
||||
template: "{{BITCOIN_HOST}}"
|
||||
secret_env:
|
||||
- key: CORE_RPC_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
@@ -47,7 +53,6 @@ app:
|
||||
- ELECTRUM_HOST=electrumx
|
||||
- ELECTRUM_PORT=50001
|
||||
- ELECTRUM_TLS_ENABLED=false
|
||||
- CORE_RPC_HOST=bitcoin-knots
|
||||
- CORE_RPC_PORT=8332
|
||||
- CORE_RPC_USERNAME=archipelago
|
||||
- DATABASE_ENABLED=true
|
||||
|
||||
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.86-alpha"
|
||||
version = "1.7.96-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.86-alpha"
|
||||
version = "1.7.97-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -202,6 +202,27 @@ impl ApiHandler {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A 401 that still carries CORS headers, for endpoints fetched
|
||||
/// cross-origin by same-node app UIs (e.g. the LND wallet UI on its own
|
||||
/// port). Without the ACAO header the browser surfaces an opaque CORS
|
||||
/// error instead of the 401, so the app can't tell it just needs auth.
|
||||
/// `origin` is the already-validated reflect value from `app_cors_origin`
|
||||
/// (empty string when the origin isn't allowed → no CORS header added).
|
||||
fn unauthorized_cors(origin: &str) -> Response<hyper::Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Vary", "Origin");
|
||||
if !origin.is_empty() {
|
||||
builder = builder
|
||||
.header("Access-Control-Allow-Origin", origin)
|
||||
.header("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
builder.body(hyper::Body::from(body_bytes)).unwrap()
|
||||
}
|
||||
|
||||
/// Allowed CORS origins derived from the config host IP.
|
||||
fn allowed_origins(&self) -> Vec<String> {
|
||||
let mut origins = vec![
|
||||
@@ -256,6 +277,45 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// CORS origin to echo for same-node app → backend calls (e.g. the LND
|
||||
/// wallet UI, served on its own APP_PORTS port). Such apps share the node's
|
||||
/// host but use a different port, so the strict allowlist (`host_ip`, no
|
||||
/// port) rejects them and the browser gets no `Access-Control-Allow-Origin`
|
||||
/// header ("blocked by CORS policy"). Reflect the Origin when its host
|
||||
/// matches the request's own `Host` header — i.e. the app lives on the same
|
||||
/// address the node is being reached by, which transparently covers the LAN
|
||||
/// IP, the Tailscale IP, localhost, and the `.onion` address without needing
|
||||
/// to enumerate them. Auth is still enforced by the session cookie; this
|
||||
/// only authorizes the browser to *read* the reply. Returns "" (no echoed
|
||||
/// origin) when there is no match.
|
||||
fn app_cors_origin(&self, headers: &hyper::HeaderMap) -> String {
|
||||
if let Some(origin) = self.validate_origin(headers) {
|
||||
return origin;
|
||||
}
|
||||
let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) else {
|
||||
return String::new();
|
||||
};
|
||||
// host portion (no scheme, no port) of an `scheme://host[:port]` value
|
||||
let host_of = |s: &str| -> Option<String> {
|
||||
let after_scheme = s.split_once("://").map(|(_, r)| r).unwrap_or(s);
|
||||
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
|
||||
let host = host_port
|
||||
.rsplit_once(':')
|
||||
.map(|(h, _)| h)
|
||||
.unwrap_or(host_port);
|
||||
(!host.is_empty()).then(|| host.to_string())
|
||||
};
|
||||
let origin_host = host_of(origin);
|
||||
let req_host = headers
|
||||
.get(hyper::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(host_of);
|
||||
match (origin_host, req_host) {
|
||||
(Some(o), Some(r)) if o == r => origin.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_request(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
@@ -265,9 +325,10 @@ impl ApiHandler {
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.header("Vary", "Origin");
|
||||
if let Some(origin) = self.validate_origin(req.headers()) {
|
||||
let preflight_origin = self.app_cors_origin(req.headers());
|
||||
if !preflight_origin.is_empty() {
|
||||
builder = builder
|
||||
.header("Access-Control-Allow-Origin", &origin)
|
||||
.header("Access-Control-Allow-Origin", &preflight_origin)
|
||||
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
|
||||
.header("Access-Control-Allow-Credentials", "true");
|
||||
@@ -448,7 +509,8 @@ impl ApiHandler {
|
||||
// No backend auth check here because the LND UI iframe fetches this
|
||||
// endpoint and the session cookie flow is validated at the nginx layer.
|
||||
(Method::GET, "/lnd-connect-info") => {
|
||||
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
|
||||
let origin = self.app_cors_origin(&headers);
|
||||
Self::handle_lnd_connect_info(self.rpc_handler.clone(), &origin).await
|
||||
}
|
||||
|
||||
// Container logs — requires session
|
||||
@@ -460,13 +522,26 @@ impl ApiHandler {
|
||||
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await
|
||||
}
|
||||
|
||||
// LND proxy — requires session
|
||||
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
|
||||
// Peer content streaming proxy — Range-streams a peer's media file
|
||||
// so <video>/<audio> can seek/play (B3). Same-origin, session-gated.
|
||||
(Method::GET, p) if p.starts_with("/api/peer-content/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
let origin = self.validate_origin(&headers).unwrap_or_default();
|
||||
Self::handle_lnd_proxy(path, &origin).await
|
||||
self.handle_peer_content_stream(p, &headers).await
|
||||
}
|
||||
|
||||
// LND proxy — requires session. The LND wallet UI calls this
|
||||
// cross-origin from its own app port, so even the 401 must carry
|
||||
// CORS headers; otherwise the browser reports a bare CORS failure
|
||||
// ("No 'Access-Control-Allow-Origin' header") instead of a
|
||||
// readable 401 the UI can act on.
|
||||
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
|
||||
let origin = self.app_cors_origin(&headers);
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized_cors(&origin));
|
||||
}
|
||||
Self::handle_lnd_proxy(self.rpc_handler.clone(), path, &origin).await
|
||||
}
|
||||
|
||||
// DWN health — unauthenticated
|
||||
|
||||
@@ -99,33 +99,61 @@ impl ApiHandler {
|
||||
|
||||
pub(super) async fn handle_lnd_connect_info(
|
||||
rpc: std::sync::Arc<super::super::rpc::RpcHandler>,
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// The LND wallet UI is served on its own APP_PORTS origin and fetches
|
||||
// this cross-origin, so it needs the CORS headers echoed back.
|
||||
let cors = |builder: hyper::http::response::Builder| {
|
||||
builder
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
};
|
||||
match rpc.handle_lnd_connect_info().await {
|
||||
Ok(val) => {
|
||||
let body = serde_json::to_vec(&val).unwrap_or_default();
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(body),
|
||||
))
|
||||
Ok(cors(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json"),
|
||||
)
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
|
||||
}
|
||||
Err(e) => Ok(Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(
|
||||
serde_json::json!({"error": e.to_string()}).to_string(),
|
||||
))
|
||||
.unwrap()),
|
||||
Err(e) => Ok(cors(
|
||||
Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json"),
|
||||
)
|
||||
.body(hyper::Body::from(
|
||||
serde_json::json!({"error": e.to_string()}).to_string(),
|
||||
))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_proxy(
|
||||
rpc: Arc<RpcHandler>,
|
||||
path: &str,
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||
match reqwest::get(&url).await {
|
||||
// LND REST serves a self-signed cert and requires the admin macaroon.
|
||||
// A bare reqwest::get() uses the default client, which rejects the
|
||||
// self-signed cert (TLS verify error -> 502 "failing to fetch") and
|
||||
// sends no macaroon. Use the shared authenticated client instead — the
|
||||
// same one lnd.getinfo and the wallet RPCs use.
|
||||
let request = match rpc.lnd_client().await {
|
||||
Ok((client, macaroon_hex)) => client
|
||||
.get(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.map_err(anyhow::Error::from),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match request {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let headers = resp.headers().clone();
|
||||
@@ -157,4 +185,76 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Range-streaming proxy for a peer's content file (B3). The browser's
|
||||
/// `<video>`/`<audio>` element makes Range requests; we forward the Range
|
||||
/// header to the peer's `/content/<id>` (which already returns 206 Partial
|
||||
/// Content) and pass the bytes + Content-Range/Content-Type straight back.
|
||||
/// This replaces the old path of downloading the whole file as base64 into
|
||||
/// a non-seekable Blob URL, which broke playback/seeking for video and
|
||||
/// large audio. Same-origin + session-authenticated (checked by caller).
|
||||
/// Path: `/api/peer-content/<onion>/<content_id>`.
|
||||
pub(super) async fn handle_peer_content_stream(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let bad = |msg: &str| {
|
||||
Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::json!({ "error": msg }).to_string()),
|
||||
))
|
||||
};
|
||||
let rest = path.strip_prefix("/api/peer-content/").unwrap_or("");
|
||||
let (onion, content_id) = match rest.split_once('/') {
|
||||
Some((o, c)) if !o.is_empty() && !c.is_empty() => (o, c),
|
||||
_ => return bad("expected /api/peer-content/<onion>/<content_id>"),
|
||||
};
|
||||
// Validate to prevent SSRF / path traversal.
|
||||
let onion_norm = onion.trim_end_matches(".onion");
|
||||
let onion_ok = onion_norm.len() == 56
|
||||
&& onion_norm
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
|
||||
let id_ok = !content_id.contains("..")
|
||||
&& content_id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'));
|
||||
if !onion_ok || !id_ok {
|
||||
return bad("invalid onion or content id");
|
||||
}
|
||||
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let peer_path = format!("/content/{}", content_id);
|
||||
let mut req = crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &peer_path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(60));
|
||||
if let Some(r) = headers.get("range").and_then(|v| v.to_str().ok()) {
|
||||
req = req.header("Range", r.to_string());
|
||||
}
|
||||
match req.send_get().await {
|
||||
Ok((resp, _transport)) => {
|
||||
let status = resp.status().as_u16();
|
||||
let rh = resp.headers().clone();
|
||||
let bytes = resp.bytes().await.unwrap_or_default();
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header("Accept-Ranges", "bytes");
|
||||
for h in ["content-type", "content-range", "content-length"] {
|
||||
if let Some(v) = rh.get(h).and_then(|v| v.to_str().ok()) {
|
||||
builder = builder.header(h, v);
|
||||
}
|
||||
}
|
||||
Ok(builder
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::empty())))
|
||||
}
|
||||
Err(e) => Ok(build_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::json!({ "error": e.to_string() }).to_string()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
mod handler;
|
||||
mod rpc;
|
||||
pub(crate) mod rpc;
|
||||
|
||||
pub use handler::ApiHandler;
|
||||
|
||||
@@ -33,6 +33,19 @@ impl RpcHandler {
|
||||
|
||||
tracing::info!("[onboarding] login successful");
|
||||
|
||||
// Best-effort: heal a LOCKED LND wallet created with an unknown/legacy
|
||||
// password by rotating it onto the per-node secret, using the password
|
||||
// the user just authenticated with as a candidate. Non-blocking so login
|
||||
// is never slowed or broken when LND isn't installed / already unlocked.
|
||||
let candidate = password.to_string();
|
||||
tokio::spawn(async move {
|
||||
match crate::container::lnd::migrate_locked_wallet(&[candidate]).await {
|
||||
Ok(true) => tracing::info!("[login] LND wallet healed / auto-unlocked"),
|
||||
Ok(false) => {} // not locked, or seed-recovery required
|
||||
Err(e) => tracing::debug!("[login] LND wallet migration skipped: {e}"),
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure NostrVPN config exists — covers the case where onboardingComplete
|
||||
// was never called (e.g., user took the "already set up" shortcut).
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
|
||||
@@ -107,7 +107,7 @@ struct TrustedRelayPeer {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TxRelayCredentials {
|
||||
pub(crate) struct TxRelayCredentials {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
@@ -648,7 +648,13 @@ async fn txrelay_credentials_available(data_dir: &Path) -> bool {
|
||||
&& fs::metadata(&client_env_path).await.is_ok()
|
||||
}
|
||||
|
||||
async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
|
||||
/// Idempotently ensure the tx-relay credential trio exists in the secrets dir:
|
||||
/// the random password, its derived `rpcauth` line, and the client env file.
|
||||
/// Bitcoin backend manifests reference `bitcoin-rpc-txrelay-rpcauth` as a
|
||||
/// required `secret_env`, so this must run before bitcoind starts — otherwise
|
||||
/// secret resolution hard-fails and the whole Bitcoin stack cascades (the .198
|
||||
/// failure). Safe to call repeatedly; it only writes what's missing or stale.
|
||||
pub(crate) async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
|
||||
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
|
||||
let password = match read_trimmed(&password_path).await {
|
||||
Some(value) => value,
|
||||
|
||||
@@ -234,7 +234,7 @@ impl RpcHandler {
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
let (response, _transport) =
|
||||
let (response, transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
@@ -242,6 +242,15 @@ impl RpcHandler {
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Record which transport actually reached the peer (B14) so the UI
|
||||
// reflects FIPS vs Tor truthfully instead of always showing Tor/none.
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||
@@ -294,13 +303,21 @@ impl RpcHandler {
|
||||
fips_npub.is_some()
|
||||
);
|
||||
|
||||
let (response, _transport) =
|
||||
let (response, transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Record which transport actually reached the peer (B14).
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
@@ -309,11 +326,20 @@ impl RpcHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = response
|
||||
let mut body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse peer catalog")?;
|
||||
|
||||
// Surface the transport that actually reached the peer so the cloud
|
||||
// browse UI can show a FIPS/Tor pill instead of always assuming Tor (B21).
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.insert(
|
||||
"transport".to_string(),
|
||||
serde_json::Value::String(transport.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
@@ -353,7 +379,7 @@ impl RpcHandler {
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
let (response, _transport) =
|
||||
let (response, transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
@@ -362,6 +388,14 @@ impl RpcHandler {
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Record which transport actually reached the peer (B14).
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
// Payment was rejected — token is spent but content not received
|
||||
@@ -418,13 +452,21 @@ impl RpcHandler {
|
||||
fips_npub.is_some()
|
||||
);
|
||||
|
||||
let (response, _transport) =
|
||||
let (response, transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer for preview")?;
|
||||
// Record which transport actually reached the peer (B14).
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
|
||||
@@ -533,6 +533,19 @@ impl RpcHandler {
|
||||
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
|
||||
}
|
||||
|
||||
// Respect operator removal: a peer the operator deleted must not
|
||||
// silently re-join via a stale invite. The tombstone is only cleared
|
||||
// by an explicit local action (manually adding the node or accepting
|
||||
// an incoming invite) — not by a remote-triggered join.
|
||||
if federation::load_removed_dids(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.contains(did)
|
||||
{
|
||||
info!(peer_did = %did, "Ignoring peer-joined for a removed (tombstoned) DID");
|
||||
return Ok(serde_json::json!({ "accepted": false, "removed": true }));
|
||||
}
|
||||
|
||||
let node = FederatedNode {
|
||||
did: did.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
|
||||
@@ -115,10 +115,12 @@ impl RpcHandler {
|
||||
} 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"
|
||||
// Daemon is up with a key but hasn't authenticated any peers —
|
||||
// almost always the outbound connection to the anchor being
|
||||
// dropped by the local firewall/router, or the anchor itself
|
||||
// being down. The public anchor is reached over TCP/8443 (not
|
||||
// UDP/8668 — that endpoint is dead).
|
||||
"no_outbound_or_anchor_down"
|
||||
} else {
|
||||
"peers_but_no_anchor"
|
||||
};
|
||||
@@ -126,8 +128,8 @@ impl RpcHandler {
|
||||
"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.",
|
||||
"no_outbound_or_anchor_down" =>
|
||||
"Daemon is running but no peers handshook. Your router or ISP may be blocking the outbound connection to the mesh anchor (TCP port 8443), or every configured anchor is down. The public anchor is added automatically — if it still won't connect, add another 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.",
|
||||
_ => "",
|
||||
|
||||
@@ -38,6 +38,7 @@ impl RpcHandler {
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -180,6 +181,7 @@ impl RpcHandler {
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
|
||||
@@ -63,6 +63,7 @@ impl RpcHandler {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
|
||||
@@ -9,29 +9,55 @@ use super::LND_REST_BASE_URL;
|
||||
impl RpcHandler {
|
||||
/// Generate a new on-chain Bitcoin address.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let (client, macaroon_hex) = self.lnd_client().await.map_err(|e| {
|
||||
tracing::warn!(error = %format!("{e:#}"), "LND newaddress: client/macaroon unavailable");
|
||||
receive_error(
|
||||
RECEIVE_WALLET_UNINITIALIZED,
|
||||
"The Lightning wallet isn't set up on this node yet. Finish wallet setup, then try again.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let resp = client
|
||||
let resp = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
|
||||
// LND's REST gateway parses `type` as the AddressType enum by its
|
||||
// proto name (or integer), NOT the lncli aliases. "p2wkh" is not a
|
||||
// valid enum value and returns 400 "parsing field type"; the native
|
||||
// SegWit (bech32) variant is WITNESS_PUBKEY_HASH (= 0).
|
||||
.query(&[("type", "WITNESS_PUBKEY_HASH")])
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
// The .116 case: LND container is up but its REST endpoint isn't
|
||||
// reachable on the expected port (e.g. published-port drift), so
|
||||
// the connection is refused. This is NOT a locked wallet — emit a
|
||||
// distinct code so the UI stops mislabelling it.
|
||||
tracing::warn!(error = %format!("{e:#}"), "LND newaddress: REST connection failed");
|
||||
return Err(receive_error(
|
||||
RECEIVE_REST_UNREACHABLE,
|
||||
"The Lightning wallet service isn't reachable yet. It may be starting up or recovering — please try again in a moment.",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
let raw_body = resp
|
||||
.text()
|
||||
.await
|
||||
.context("Failed to parse newaddress response")?;
|
||||
.context("Bitcoin address response could not be read")?;
|
||||
let body: serde_json::Value = serde_json::from_str(&raw_body).unwrap_or_else(|_| {
|
||||
serde_json::json!({
|
||||
"raw": raw_body,
|
||||
})
|
||||
});
|
||||
|
||||
if !status.is_success() {
|
||||
let message = lnd_error_message(&body);
|
||||
anyhow::bail!(
|
||||
"LND could not generate a Bitcoin address ({}): {}",
|
||||
status,
|
||||
message
|
||||
);
|
||||
let code = classify_lnd_address_error(&message);
|
||||
tracing::warn!(%status, lnd_message = %message, code, "LND newaddress returned an error");
|
||||
return Err(receive_error(code, default_receive_detail(code)));
|
||||
}
|
||||
|
||||
if let Some(error) = body
|
||||
@@ -39,14 +65,21 @@ impl RpcHandler {
|
||||
.or_else(|| body.get("message"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
anyhow::bail!("LND could not generate a Bitcoin address: {}", error);
|
||||
let code = classify_lnd_address_error(error);
|
||||
tracing::warn!(lnd_message = %error, code, "LND newaddress returned an error body");
|
||||
return Err(receive_error(code, default_receive_detail(code)));
|
||||
}
|
||||
|
||||
let address = body
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|addr| !addr.trim().is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("LND did not return a Bitcoin address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync."))?
|
||||
.ok_or_else(|| {
|
||||
receive_error(
|
||||
RECEIVE_WALLET_UNINITIALIZED,
|
||||
"The wallet didn't return an address yet. It may still be unlocking or waiting for Bitcoin to sync — please try again shortly.",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
Ok(serde_json::json!({ "address": address }))
|
||||
@@ -519,12 +552,20 @@ impl RpcHandler {
|
||||
let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(entropy);
|
||||
entropy.zeroize();
|
||||
|
||||
// Use the per-node secret as the LND wallet password (NOT the
|
||||
// caller-supplied one) so the unattended boot path can auto-unlock this
|
||||
// wallet. The wallet stays recoverable from the Archipelago seed via the
|
||||
// derived entropy above. This unifies both init paths on one password
|
||||
// source — the divergence here is what left wallets locked fleet-wide.
|
||||
let _ = wallet_password; // accepted for API compat; superseded by the per-node secret
|
||||
let node_wallet_pw = crate::container::lnd::ensure_wallet_password().await?;
|
||||
let wallet_password_b64 =
|
||||
base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(node_wallet_pw.as_bytes());
|
||||
|
||||
// Call LND REST API to initialize wallet with derived entropy.
|
||||
// LND must be running but NOT yet initialized (no existing wallet).
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -573,9 +614,75 @@ fn lnd_error_message(body: &serde_json::Value) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// Stable, machine-readable reason codes for receive-address failures. They are
|
||||
// embedded in the error message as a `[CODE]` token so the frontend
|
||||
// (neode-ui/src/utils/bitcoinReceive.ts) can show an accurate explanation
|
||||
// instead of guessing wallet state by substring-matching — which is what made
|
||||
// .228 report "wallet is locked" when LND's REST was merely unreachable.
|
||||
//
|
||||
// Every receive error string starts with "Bitcoin address" so it survives the
|
||||
// RPC error sanitizer (api/rpc/middleware.rs) unchanged rather than being
|
||||
// flattened to the generic "Operation failed" message (the .116 symptom).
|
||||
pub(crate) const RECEIVE_REST_UNREACHABLE: &str = "LND_REST_UNREACHABLE";
|
||||
pub(crate) const RECEIVE_WALLET_LOCKED: &str = "LND_WALLET_LOCKED";
|
||||
pub(crate) const RECEIVE_WALLET_UNINITIALIZED: &str = "LND_WALLET_UNINITIALIZED";
|
||||
pub(crate) const RECEIVE_SYNCING: &str = "LND_SYNCING";
|
||||
pub(crate) const RECEIVE_LND_ERROR: &str = "LND_ERROR";
|
||||
|
||||
/// Build a receive-address error carrying a reason code the UI can map.
|
||||
fn receive_error(code: &str, detail: &str) -> anyhow::Error {
|
||||
anyhow::anyhow!("Bitcoin address unavailable [{code}]: {detail}")
|
||||
}
|
||||
|
||||
/// A sensible default human message per code (used for non-UI callers and logs;
|
||||
/// the frontend renders its own copy from the code).
|
||||
fn default_receive_detail(code: &str) -> &'static str {
|
||||
match code {
|
||||
RECEIVE_REST_UNREACHABLE => {
|
||||
"The Lightning wallet service isn't reachable yet. It may be starting up or recovering — please try again in a moment."
|
||||
}
|
||||
RECEIVE_WALLET_LOCKED => {
|
||||
"The Lightning wallet is locked. Unlock it (or finish wallet setup), then try again."
|
||||
}
|
||||
RECEIVE_WALLET_UNINITIALIZED => {
|
||||
"The Lightning wallet isn't set up yet. Finish wallet setup, then try again."
|
||||
}
|
||||
RECEIVE_SYNCING => {
|
||||
"The wallet is still syncing with the Bitcoin network. Please try again once it has caught up."
|
||||
}
|
||||
_ => "Couldn't generate a Bitcoin address right now. Please try again shortly.",
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a non-2xx LND error body/message into a reason code. The wording of
|
||||
/// LND's REST errors is stable enough to bucket: a locked wallet, an
|
||||
/// uninitialized wallet, a syncing chain, or some other failure.
|
||||
fn classify_lnd_address_error(message: &str) -> &'static str {
|
||||
let m = message.to_lowercase();
|
||||
if m.contains("locked") || m.contains("unlock") {
|
||||
RECEIVE_WALLET_LOCKED
|
||||
} else if m.contains("synchroniz")
|
||||
|| m.contains("syncing")
|
||||
|| m.contains("not yet ready")
|
||||
|| m.contains("in the process of starting")
|
||||
{
|
||||
RECEIVE_SYNCING
|
||||
} else if m.contains("wallet not found")
|
||||
|| m.contains("not exist")
|
||||
|| m.contains("uninitialized")
|
||||
|| m.contains("not initialized")
|
||||
|| m.contains("create a wallet")
|
||||
|| m.contains("no wallet")
|
||||
{
|
||||
RECEIVE_WALLET_UNINITIALIZED
|
||||
} else {
|
||||
RECEIVE_LND_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::lnd_error_message;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lnd_error_message_prefers_message_field() {
|
||||
@@ -594,4 +701,46 @@ mod tests {
|
||||
"unknown LND error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_locked_wallet() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("wallet locked, please unlock"),
|
||||
RECEIVE_WALLET_LOCKED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_uninitialized_wallet() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("wallet not found, create a wallet first"),
|
||||
RECEIVE_WALLET_UNINITIALIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_syncing() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("server is still in the process of starting"),
|
||||
RECEIVE_SYNCING
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_unknown_is_generic_error() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("some other failure"),
|
||||
RECEIVE_LND_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_error_starts_with_sanitizer_safe_prefix_and_embeds_code() {
|
||||
// Must start with "Bitcoin address" (survives the RPC error sanitizer)
|
||||
// and carry the [CODE] token the frontend parses.
|
||||
let err = receive_error(RECEIVE_REST_UNREACHABLE, "unreachable");
|
||||
let s = format!("{err}");
|
||||
assert!(s.starts_with("Bitcoin address"), "got: {s}");
|
||||
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"Failed to start",
|
||||
"Container",
|
||||
"Image",
|
||||
"Bitcoin address",
|
||||
];
|
||||
for prefix in &user_facing_prefixes {
|
||||
if msg.starts_with(prefix) {
|
||||
|
||||
@@ -32,6 +32,8 @@ fn is_platform_managed_app(app_id: &str) -> bool {
|
||||
| "fedimint-gateway"
|
||||
| "indeedhub"
|
||||
| "immich"
|
||||
| "fips"
|
||||
| "fips-ui"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -750,27 +752,33 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"mempool-api" => (
|
||||
vec!["8999:8999".to_string()],
|
||||
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||
vec![
|
||||
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||
"ELECTRUM_HOST=electrumx".to_string(),
|
||||
"ELECTRUM_PORT=50001".to_string(),
|
||||
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||
"CORE_RPC_HOST=bitcoin-knots".to_string(),
|
||||
"CORE_RPC_PORT=8332".to_string(),
|
||||
"CORE_RPC_USERNAME=archipelago".to_string(),
|
||||
format!("CORE_RPC_PASSWORD={}", rpc_pass),
|
||||
"DATABASE_ENABLED=true".to_string(),
|
||||
"DATABASE_HOST=archy-mempool-db".to_string(),
|
||||
"DATABASE_DATABASE=mempool".to_string(),
|
||||
"DATABASE_USERNAME=mempool".to_string(),
|
||||
format!("DATABASE_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"mempool-api" => {
|
||||
// CORE_RPC_HOST must resolve to the actual Bitcoin node container —
|
||||
// bitcoin-knots OR bitcoin-core — else mempool-api can't reach RPC
|
||||
// on a Core node (B12). Falls back to bitcoin-knots if undetected.
|
||||
let bitcoin_rpc_host = super::dependencies::detect_bitcoin_rpc_host().await;
|
||||
(
|
||||
vec!["8999:8999".to_string()],
|
||||
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||
vec![
|
||||
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||
"ELECTRUM_HOST=electrumx".to_string(),
|
||||
"ELECTRUM_PORT=50001".to_string(),
|
||||
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||
format!("CORE_RPC_HOST={}", bitcoin_rpc_host),
|
||||
"CORE_RPC_PORT=8332".to_string(),
|
||||
"CORE_RPC_USERNAME=archipelago".to_string(),
|
||||
format!("CORE_RPC_PASSWORD={}", rpc_pass),
|
||||
"DATABASE_ENABLED=true".to_string(),
|
||||
"DATABASE_HOST=archy-mempool-db".to_string(),
|
||||
"DATABASE_DATABASE=mempool".to_string(),
|
||||
"DATABASE_USERNAME=mempool".to_string(),
|
||||
format!("DATABASE_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
"electrumx" | "mempool-electrs" | "electrs" => {
|
||||
(
|
||||
vec!["50001:50001".to_string()],
|
||||
|
||||
@@ -84,6 +84,78 @@ pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect the container name of the running Bitcoin node so dependent stacks
|
||||
/// (mempool) can point CORE_RPC_HOST at the right host. Bitcoin Knots and Bitcoin
|
||||
/// Core are both reachable on archy-net by their container name — only the name
|
||||
/// differs (`bitcoin-knots` vs `bitcoin-core`), so hardcoding one breaks the
|
||||
/// other. Returns the first running BITCOIN_NAMES match; falls back to the
|
||||
/// default `bitcoin-knots` if none is detected (callers gate on has_bitcoin).
|
||||
pub(super) async fn detect_bitcoin_rpc_host() -> String {
|
||||
let out = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
if let Ok(Ok(o)) = out {
|
||||
if o.status.success() {
|
||||
let running = String::from_utf8_lossy(&o.stdout);
|
||||
if let Some(name) = pick_bitcoin_host(&running) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
"bitcoin-knots".to_string()
|
||||
}
|
||||
|
||||
/// Pure host-selection step of [`detect_bitcoin_rpc_host`], split out so it can
|
||||
/// be unit-tested without a podman runtime. Returns the first `podman ps` line
|
||||
/// whose trimmed name is one of [`BITCOIN_NAMES`]. (The Quadlet orchestrator
|
||||
/// mirrors this in `prod_orchestrator::bitcoin_host`.)
|
||||
fn pick_bitcoin_host(podman_names: &str) -> Option<String> {
|
||||
podman_names
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|name| BITCOIN_NAMES.contains(name))
|
||||
.map(|name| name.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bitcoin_host_tests {
|
||||
use super::pick_bitcoin_host;
|
||||
|
||||
#[test]
|
||||
fn picks_knots() {
|
||||
let ps = "electrumx\nbitcoin-knots\narchy-mempool-db\n";
|
||||
assert_eq!(pick_bitcoin_host(ps).as_deref(), Some("bitcoin-knots"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_core() {
|
||||
let ps = "lnd\nbitcoin-core\nelectrumx\n";
|
||||
assert_eq!(pick_bitcoin_host(ps).as_deref(), Some("bitcoin-core"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_plain_bitcoin() {
|
||||
assert_eq!(pick_bitcoin_host("bitcoin\n").as_deref(), Some("bitcoin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_when_no_bitcoin_node() {
|
||||
let ps = "electrumx\nlnd\narchy-mempool-db\n";
|
||||
assert_eq!(pick_bitcoin_host(ps), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_substring_matches() {
|
||||
// A companion UI container must NOT be mistaken for the node itself.
|
||||
let ps = "archy-bitcoin-ui\nbitcoin-knots-foo\n";
|
||||
assert_eq!(pick_bitcoin_host(ps), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that required dependency services are running before installing an app.
|
||||
/// Returns an error with a user-friendly message if dependencies are missing.
|
||||
pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result<()> {
|
||||
|
||||
@@ -1152,6 +1152,9 @@ impl RpcHandler {
|
||||
let deps = super::dependencies::detect_running_deps().await?;
|
||||
super::dependencies::check_install_deps("mempool", &deps)?;
|
||||
let (_, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
// CORE_RPC_HOST must match the actual Bitcoin node container name —
|
||||
// bitcoin-knots OR bitcoin-core — else mempool-api can't reach RPC (B12).
|
||||
let bitcoin_rpc_host = super::dependencies::detect_bitcoin_rpc_host().await;
|
||||
|
||||
install_log("INSTALL START: mempool (stack: mariadb + mempool-api + mempool-web)").await;
|
||||
|
||||
@@ -1275,7 +1278,7 @@ impl RpcHandler {
|
||||
"-e",
|
||||
"ELECTRUM_TLS_ENABLED=false",
|
||||
"-e",
|
||||
"CORE_RPC_HOST=bitcoin-knots",
|
||||
&format!("CORE_RPC_HOST={}", bitcoin_rpc_host),
|
||||
"-e",
|
||||
"CORE_RPC_PORT=8332",
|
||||
"-e",
|
||||
|
||||
@@ -32,6 +32,11 @@ const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
|
||||
|
||||
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
|
||||
const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
|
||||
/// Per-app proxy snippet included by the HTTPS (:443) server block. Carries its
|
||||
/// own `/app/fedimint/` location, so it needs the same B13 asset-rewrite heal as
|
||||
/// the main conf — browsers reach fedimint over HTTPS via this snippet. Absent on
|
||||
/// HTTP-only nodes, in which case the bootstrap loop skips it.
|
||||
const NGINX_HTTPS_SNIPPET_PATH: &str = "/etc/nginx/snippets/archipelago-https-app-proxies.conf";
|
||||
const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
|
||||
|
||||
/// Inserted into every server block of the nginx config that lacks the
|
||||
@@ -41,6 +46,41 @@ const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backe
|
||||
|
||||
const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n proxy_pass http://127.0.0.1:5678/bitcoin-status;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// Inserted into every server block that lacks the `/proxy/lnd/` proxy. Nodes
|
||||
/// flashed before 2026-04-10 shipped an nginx config without this block, so the
|
||||
/// browser's wallet fetches to `/proxy/lnd/*` fell through to the SPA
|
||||
/// index.html and got HTML back instead of JSON ("failing to fetch"). Kept in
|
||||
/// sync with the canonical block in image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles auth + CORS\n location /proxy/lnd/ {\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 Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// Inserted into every server block lacking the peer-content streaming proxy.
|
||||
/// Without it, the browser's `<video>`/`<audio>` Range requests to
|
||||
/// `/api/peer-content/*` fall through to the SPA index.html (HTML, no Range)
|
||||
/// and peer media won't play (B3). Forwards Cookie (session auth) + Range and
|
||||
/// disables buffering so streaming works. Kept in sync with the canonical
|
||||
/// block in image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file\n location /api/peer-content/ {\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 Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 120s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// B13 — Fedimint UI asset rewrite. Pre-fix nodes proxy /app/fedimint/ with only
|
||||
/// the nostr-provider injection (`sub_filter_once on`), so the UI's root-rooted
|
||||
/// CSS/JS asset URLs (href="/…", url("/…")) miss the proxy and load the SPA shell
|
||||
/// → unstyled UI. We swap that single sub_filter for the full rewrite set that
|
||||
/// reroots every asset URL under /app/fedimint/. NEW matches the canonical block
|
||||
/// in image-recipe/configs/nginx-archipelago.conf byte-for-byte so self-healed
|
||||
/// nodes converge to the same config fresh ISOs ship with.
|
||||
const NGINX_FEDIMINT_OLD: &str = " sub_filter_once on;\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';\n }\n location /app/fedimint-gateway/ {";
|
||||
const NGINX_FEDIMINT_NEW: &str = " sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';\n }\n location /app/fedimint-gateway/ {";
|
||||
|
||||
/// B13 Style B — the HTTPS app-proxy snippet's fedimint block has NO sub_filter
|
||||
/// at all (older than the main conf's), and the directive that follows it varies
|
||||
/// per node (fedimint-gateway vs tailscale), so a full-block match is unreliable.
|
||||
/// Instead we anchor on the unique :8175 proxy_pass (fedimint is the only block
|
||||
/// proxying there) and insert the reroot set right after it — directive order
|
||||
/// inside a location block is irrelevant to nginx. Idempotent via the same
|
||||
/// `href="/app/fedimint/` marker the main-conf heal leaves behind.
|
||||
const NGINX_FEDIMINT_SNIPPET_ANCHOR: &str = "proxy_pass http://127.0.0.1:8175/;";
|
||||
const NGINX_FEDIMINT_SNIPPET_INSERT: &str = "proxy_pass http://127.0.0.1:8175/;\n proxy_set_header Accept-Encoding \"\";\n sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';";
|
||||
|
||||
/// 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() {
|
||||
@@ -476,6 +516,63 @@ async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
const ARCHIPELAGO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago.service";
|
||||
const MOUNT_REQUIRE_LINE: &str = "RequiresMountsFor=/var/lib/archipelago";
|
||||
|
||||
/// B17 self-heal: ensure the installed archipelago.service waits for the data
|
||||
/// volume to mount before it starts. On production nodes `/var/lib/archipelago`
|
||||
/// (the app data dir AND podman's graphroot) is a separate device-mapper volume;
|
||||
/// without a mount dependency the service can start before `var-lib-archipelago.mount`,
|
||||
/// write to the bare mountpoint on rootfs, fail every podman call, exit, and be
|
||||
/// restarted every 5s until the volume mounts (~5 min of "[FAILED] Failed to start"
|
||||
/// on cold boots). Fresh ISOs already ship the directive; this heals already-deployed
|
||||
/// nodes. The change is boot-ordering only — it takes effect on the NEXT reboot, so we
|
||||
/// never restart the running service here. Idempotent; no-op if the unit is absent
|
||||
/// (dev runs) or already patched. Harmless when the data dir is on rootfs (systemd maps
|
||||
/// the requirement to the always-mounted root).
|
||||
pub async fn ensure_archipelago_mount_ordering() {
|
||||
let current = match fs::read_to_string(ARCHIPELAGO_SERVICE_PATH).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"mount-ordering self-heal: {} not readable ({}) — skipping",
|
||||
ARCHIPELAGO_SERVICE_PATH,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if current.contains(MOUNT_REQUIRE_LINE) {
|
||||
return; // already healed
|
||||
}
|
||||
// Insert the directive into the [Unit] section, immediately before [Service].
|
||||
let Some(idx) = current.find("\n[Service]") else {
|
||||
tracing::warn!(
|
||||
"mount-ordering self-heal: no [Service] section in {} — skipping",
|
||||
ARCHIPELAGO_SERVICE_PATH
|
||||
);
|
||||
return;
|
||||
};
|
||||
let mut patched = String::with_capacity(current.len() + MOUNT_REQUIRE_LINE.len() + 96);
|
||||
patched.push_str(¤t[..idx]);
|
||||
patched.push_str("\n# B17: start only after the data volume (+ podman graphroot) is mounted\n");
|
||||
patched.push_str(MOUNT_REQUIRE_LINE);
|
||||
patched.push_str(¤t[idx..]);
|
||||
match write_root_if_needed(ARCHIPELAGO_SERVICE_PATH, &patched).await {
|
||||
Ok(true) => {
|
||||
info!(
|
||||
"B17: added '{}' to archipelago.service (effective next reboot)",
|
||||
MOUNT_REQUIRE_LINE
|
||||
);
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
tracing::warn!("B17 self-heal: daemon-reload failed: {:#}", e);
|
||||
}
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!("B17 mount-ordering self-heal failed: {:#}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
|
||||
/// configs shipped individual per-endpoint `location` blocks, so missing
|
||||
/// endpoints silently fell through to the SPA `index.html` and the frontend
|
||||
@@ -496,7 +593,11 @@ async fn run_nginx() -> Result<bool> {
|
||||
|
||||
let mut changed = false;
|
||||
let mut patched_paths = Vec::<PathBuf>::new();
|
||||
for path in [NGINX_CONF_PATH, NGINX_ENABLED_CONF_PATH] {
|
||||
for path in [
|
||||
NGINX_CONF_PATH,
|
||||
NGINX_ENABLED_CONF_PATH,
|
||||
NGINX_HTTPS_SNIPPET_PATH,
|
||||
] {
|
||||
let candidate = Path::new(path);
|
||||
if !candidate.exists() {
|
||||
debug!("{} missing — skipping nginx bootstrap", path);
|
||||
@@ -514,18 +615,100 @@ async fn run_nginx() -> Result<bool> {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Reflective CORS add_headers that older configs placed inside the
|
||||
/// `/lnd-connect-info` location. The backend now sets a validated
|
||||
/// `Access-Control-Allow-Origin` for that endpoint (api/handler/proxy.rs), so
|
||||
/// leaving these in nginx emits a DUPLICATE header ("contains multiple values
|
||||
/// … but only one is allowed") and the LND wallet UI's cross-origin fetch is
|
||||
/// rejected. Stripped during nginx bootstrap so the backend solely owns CORS.
|
||||
const NGINX_LND_DUP_CORS: &str = " add_header Access-Control-Allow-Origin $http_origin always;\n add_header Access-Control-Allow-Credentials \"true\" always;\n";
|
||||
|
||||
async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
let content = fs::read_to_string(path)
|
||||
.await
|
||||
.with_context(|| format!("read {}", path))?;
|
||||
let missing_app_catalog = !content.contains("location /api/app-catalog");
|
||||
let missing_bitcoin_status = !content.contains("location /bitcoin-status");
|
||||
if !missing_app_catalog && !missing_bitcoin_status {
|
||||
// Each "missing" flag is gated on the splice anchor actually being present,
|
||||
// so an included snippet that legitimately has none of these endpoints (the
|
||||
// HTTPS app-proxy snippet) neither tries to patch them nor logs warn-skips on
|
||||
// every boot — it falls through to the fedimint heal alone.
|
||||
let has_lnd_anchor = content.contains(" location /lnd-connect-info {")
|
||||
|| content.contains(" location /electrs-status {");
|
||||
let missing_app_catalog = content
|
||||
.contains(" # DWN endpoints — peer access over Tor (no auth)")
|
||||
&& !content.contains("location /api/app-catalog");
|
||||
let missing_bitcoin_status = content.contains(" location /electrs-status {")
|
||||
&& !content.contains("location /bitcoin-status");
|
||||
let missing_lnd_proxy = has_lnd_anchor && !content.contains("location /proxy/lnd/");
|
||||
let missing_peer_content = has_lnd_anchor && !content.contains("location /api/peer-content");
|
||||
let has_lnd_dup_cors = content.contains(NGINX_LND_DUP_CORS);
|
||||
// B13: fedimint block present but lacking the asset-rewrite sub_filters.
|
||||
let needs_fedimint_css = content.contains("location /app/fedimint/")
|
||||
&& !content.contains("'href=\"/' 'href=\"/app/fedimint/'");
|
||||
if !missing_app_catalog
|
||||
&& !missing_bitcoin_status
|
||||
&& !missing_lnd_proxy
|
||||
&& !missing_peer_content
|
||||
&& !has_lnd_dup_cors
|
||||
&& !needs_fedimint_css
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut patched = content.clone();
|
||||
|
||||
if has_lnd_dup_cors {
|
||||
// Drop the redundant nginx-side CORS headers so the backend's single
|
||||
// validated Access-Control-Allow-Origin is the only one returned.
|
||||
patched = patched.replace(NGINX_LND_DUP_CORS, "");
|
||||
}
|
||||
|
||||
if needs_fedimint_css {
|
||||
// Style A (main conf): the block already injects nostr-provider, so swap
|
||||
// its single-sub_filter tail for the full asset-rewrite set. No-op if the
|
||||
// node's fedimint block doesn't match OLD.
|
||||
patched = patched.replace(NGINX_FEDIMINT_OLD, NGINX_FEDIMINT_NEW);
|
||||
// Style B (HTTPS app-proxy snippet): the block has no sub_filter to swap,
|
||||
// so insert the reroot set after the unique :8175 proxy_pass. Guarded on
|
||||
// the marker so it can never double-apply after Style A already healed.
|
||||
if !patched.contains("'href=\"/' 'href=\"/app/fedimint/'") {
|
||||
patched = patched.replace(NGINX_FEDIMINT_SNIPPET_ANCHOR, NGINX_FEDIMINT_SNIPPET_INSERT);
|
||||
}
|
||||
}
|
||||
|
||||
if missing_lnd_proxy {
|
||||
// Prefer the `/lnd-connect-info` anchor (present since 2026-03-17); fall
|
||||
// back to `/electrs-status` (since 2026-03-08) for even older configs.
|
||||
// Both appear once per archipelago server block, so the block is added
|
||||
// to every server block that proxies to the backend.
|
||||
let anchor = if patched.contains(" location /lnd-connect-info {") {
|
||||
" location /lnd-connect-info {"
|
||||
} else {
|
||||
" location /electrs-status {"
|
||||
};
|
||||
if !patched.contains(anchor) {
|
||||
warn!("nginx conf missing lnd-connect-info/electrs-status anchor — skipping /proxy/lnd patch");
|
||||
} else {
|
||||
let replacement = format!("{}{}", NGINX_LND_PROXY_BLOCK, anchor);
|
||||
patched = patched.replace(anchor, &replacement);
|
||||
}
|
||||
}
|
||||
|
||||
if missing_peer_content {
|
||||
// Same anchoring as the LND proxy: prepend the block to every server
|
||||
// block so /api/peer-content/* reaches the backend instead of the SPA.
|
||||
let anchor = if patched.contains(" location /lnd-connect-info {") {
|
||||
" location /lnd-connect-info {"
|
||||
} else {
|
||||
" location /electrs-status {"
|
||||
};
|
||||
if patched.contains(anchor) {
|
||||
let replacement = format!("{}{}", NGINX_PEER_CONTENT_BLOCK, anchor);
|
||||
patched = patched.replace(anchor, &replacement);
|
||||
} else {
|
||||
warn!("nginx conf missing anchor — skipping /api/peer-content patch");
|
||||
}
|
||||
}
|
||||
|
||||
if missing_bitcoin_status {
|
||||
let anchor = " location /electrs-status {";
|
||||
if !patched.contains(anchor) {
|
||||
|
||||
@@ -699,7 +699,7 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
|
||||
if !requires_reachable_launch(app_id) {
|
||||
return Some(url);
|
||||
}
|
||||
let Some(port) = url.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()) else {
|
||||
let Some(port) = launch_url_port(&url) else {
|
||||
return None;
|
||||
};
|
||||
if launch_port_reachable(port).await {
|
||||
@@ -710,6 +710,23 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the TCP port from a launch URL's authority.
|
||||
///
|
||||
/// The candidate URL can carry a path when it comes from a manifest
|
||||
/// `interfaces.main` declaration (e.g. `http://localhost:8096/`). A naive
|
||||
/// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently
|
||||
/// drops a reachable launch URL. Reading digits after the final colon mirrors
|
||||
/// `port_from_url` in the RPC layer and tolerates a trailing path.
|
||||
fn launch_url_port(url: &str) -> Option<u16> {
|
||||
let after_colon = url.rsplit_once(':')?.1;
|
||||
after_colon
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
}
|
||||
|
||||
async fn launch_port_reachable(port: u16) -> bool {
|
||||
matches!(
|
||||
tokio::time::timeout(
|
||||
@@ -788,3 +805,26 @@ fn package_state_str(state: &PackageState) -> &str {
|
||||
PackageState::Updating => "updating",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod launch_url_port_tests {
|
||||
use super::launch_url_port;
|
||||
|
||||
#[test]
|
||||
fn parses_port_with_trailing_path() {
|
||||
// Regression: manifest interfaces.main yields a path-suffixed URL.
|
||||
// The old rsplit(':') parse produced "8096/" and dropped the URL.
|
||||
assert_eq!(launch_url_port("http://localhost:8096/"), Some(8096));
|
||||
assert_eq!(launch_url_port("http://localhost:8175/admin"), Some(8175));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bare_authority_port() {
|
||||
assert_eq!(launch_url_port("http://localhost:8083"), Some(8083));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_url_without_port() {
|
||||
assert_eq!(launch_url_port("http://localhost/"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,16 @@ use crate::update::host_sudo;
|
||||
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd";
|
||||
pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf";
|
||||
const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
|
||||
pub const WALLET_PASSWORD: &str = "hellohello";
|
||||
|
||||
/// Per-node LND wallet password file (random, 0600). Replaces the old
|
||||
/// fleet-wide hardcoded constant: each node's wallet password is now unique,
|
||||
/// high-entropy, and recorded here so the unattended boot path can auto-unlock.
|
||||
const WALLET_PASSWORD_SECRET: &str = "/var/lib/archipelago/secrets/lnd-wallet-password";
|
||||
|
||||
/// Legacy fleet-wide wallet password (builds that hardcoded it). Kept ONLY as an
|
||||
/// unlock fallback so wallets created by those builds still open; new wallets
|
||||
/// never use it, and the login-path migration rotates away from it.
|
||||
const LEGACY_WALLET_PASSWORD: &str = "hellohello";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnsurePaths {
|
||||
@@ -76,18 +85,128 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||
if file_exists_as_root(wallet_db).await {
|
||||
if file_exists_as_root(admin_macaroon).await {
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
match unlock_existing_wallet().await? {
|
||||
true => {
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
false => {
|
||||
// Every candidate password was actively rejected: this wallet was
|
||||
// created with a password this node no longer has, so it can never
|
||||
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
|
||||
// locked with an unknown password is already inaccessible, so wipe +
|
||||
// recreate it on the per-node secret to self-heal at boot.
|
||||
recreate_wallet_destructively().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init_wallet_via_rest().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await
|
||||
}
|
||||
|
||||
/// LND data subdirectories holding wallet + channel + graph state. Removing them
|
||||
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
|
||||
/// so deletion is destructive — only done once the wallet is already unrecoverable.
|
||||
const LND_STATE_DIRS: &[&str] = &[
|
||||
"/var/lib/archipelago/lnd/data/chain",
|
||||
"/var/lib/archipelago/lnd/data/graph",
|
||||
];
|
||||
|
||||
/// Podman container name for the core LND app (see `compute_container_name`:
|
||||
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
|
||||
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
|
||||
const LND_CONTAINER: &str = "lnd";
|
||||
|
||||
/// Archipelago data dir (default; not overridden in prod). Holds the
|
||||
/// `user-stopped.json` that gates health-monitor auto-restart.
|
||||
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
||||
|
||||
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
|
||||
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
|
||||
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
|
||||
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
|
||||
/// candidate password can open the existing wallet.
|
||||
async fn recreate_wallet_destructively() -> Result<()> {
|
||||
tracing::warn!(
|
||||
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
|
||||
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
|
||||
);
|
||||
|
||||
// The health monitor restarts any container it sees stopped; mark LND
|
||||
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
|
||||
// Always cleared below so LND auto-recovers normally afterwards.
|
||||
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
|
||||
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
let result = wipe_and_reinit_wallet().await;
|
||||
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn wipe_and_reinit_wallet() -> Result<()> {
|
||||
podman_user_scoped(&["stop", LND_CONTAINER])
|
||||
.await
|
||||
.context("stopping lnd before wallet wipe")?;
|
||||
|
||||
for dir in LND_STATE_DIRS {
|
||||
let status = host_sudo(&["rm", "-rf", dir])
|
||||
.await
|
||||
.with_context(|| format!("removing {dir}"))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("removing {dir} exited with {status}");
|
||||
}
|
||||
}
|
||||
|
||||
podman_user_scoped(&["start", LND_CONTAINER])
|
||||
.await
|
||||
.context("restarting lnd after wallet wipe")?;
|
||||
|
||||
wait_for_wallet_state("NON_EXISTING").await?;
|
||||
init_wallet_via_rest().await
|
||||
}
|
||||
|
||||
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
|
||||
/// how the orchestrator/health-monitor manage rootless containers (keeps the
|
||||
/// container out of the archipelago service's cgroup).
|
||||
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
|
||||
let out = tokio::process::Command::new("systemd-run")
|
||||
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
|
||||
async fn wait_for_wallet_state(target: &str) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
for _ in 0..120 {
|
||||
if wallet_state(&client).await.as_deref() == Some(target) {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND did not reach state {target} after wallet wipe")
|
||||
}
|
||||
|
||||
async fn file_exists_as_root(path: &str) -> bool {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return true;
|
||||
@@ -121,68 +240,227 @@ async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn unlock_existing_wallet() -> Result<()> {
|
||||
/// Read the per-node wallet password from the secrets file, if present.
|
||||
/// Never generates one — absence means "fall back to legacy / not set yet".
|
||||
async fn read_wallet_password() -> Option<String> {
|
||||
let bytes = fs::read(WALLET_PASSWORD_SECRET).await.ok()?;
|
||||
let pw = String::from_utf8_lossy(&bytes).trim().to_string();
|
||||
(!pw.is_empty()).then_some(pw)
|
||||
}
|
||||
|
||||
/// Return the per-node wallet password, generating and persisting a fresh
|
||||
/// 256-bit one (base64, 0600) if none exists. Use ONLY when creating a NEW
|
||||
/// wallet — calling it merely to unlock an existing wallet would record a
|
||||
/// password that doesn't match it.
|
||||
pub(crate) async fn ensure_wallet_password() -> Result<String> {
|
||||
if let Some(pw) = read_wallet_password().await {
|
||||
return Ok(pw);
|
||||
}
|
||||
use rand::RngCore;
|
||||
let mut raw = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut raw);
|
||||
let pw = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
|
||||
let path = std::path::Path::new(WALLET_PASSWORD_SECRET);
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", dir.display()))?;
|
||||
}
|
||||
fs::write(path, &pw)
|
||||
.await
|
||||
.with_context(|| format!("writing {WALLET_PASSWORD_SECRET}"))?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await;
|
||||
Ok(pw)
|
||||
}
|
||||
|
||||
/// Candidate passwords to try when unlocking an EXISTING wallet, in order: the
|
||||
/// per-node secret (current scheme) first, then the legacy constant so wallets
|
||||
/// created by older builds still open.
|
||||
async fn unlock_password_candidates() -> Vec<String> {
|
||||
let mut v = Vec::new();
|
||||
if let Some(pw) = read_wallet_password().await {
|
||||
v.push(pw);
|
||||
}
|
||||
v.push(LEGACY_WALLET_PASSWORD.to_string());
|
||||
v
|
||||
}
|
||||
|
||||
/// Outcome of a single unlock attempt — lets the caller fail fast on a wrong
|
||||
/// password (no point retrying) vs keep waiting for LND to come up.
|
||||
enum UnlockAttempt {
|
||||
Unlocked,
|
||||
WrongPassword,
|
||||
NotReady,
|
||||
}
|
||||
|
||||
/// One unlock POST, no internal retry. Distinguishes "invalid passphrase"
|
||||
/// (WrongPassword — try the next candidate, don't retry) from transient
|
||||
/// not-ready / connection errors (NotReady — worth retrying).
|
||||
async fn try_unlock_once(client: &reqwest::Client, password: &str) -> UnlockAttempt {
|
||||
let body = serde_json::json!({
|
||||
"wallet_password": base64::engine::general_purpose::STANDARD.encode(password)
|
||||
});
|
||||
match client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/unlockwallet"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() || text.contains("already unlocked") {
|
||||
UnlockAttempt::Unlocked
|
||||
} else if text.contains("invalid passphrase") {
|
||||
UnlockAttempt::WrongPassword
|
||||
} else {
|
||||
UnlockAttempt::NotReady
|
||||
}
|
||||
}
|
||||
Err(_) => UnlockAttempt::NotReady,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlock an existing wallet. Ok(true) = unlocked; Ok(false) = every candidate
|
||||
/// password was actively rejected (unrecoverable — caller should recreate);
|
||||
/// Err = transient (LND not ready / timeout — caller should retry, NOT wipe).
|
||||
async fn unlock_existing_wallet() -> Result<bool> {
|
||||
unlock_existing_wallet_via_rest().await
|
||||
}
|
||||
|
||||
async fn unlock_existing_wallet_via_rest() -> Result<()> {
|
||||
async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
|
||||
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
|
||||
match post_lnd_unlocker_json::<serde_json::Value>(
|
||||
&client,
|
||||
"/v1/unlockwallet",
|
||||
serde_json::json!({ "wallet_password": wallet_password }),
|
||||
)
|
||||
.await
|
||||
.context("unlocking existing LND wallet")?
|
||||
{
|
||||
UnlockerResponse::Value(_) | UnlockerResponse::WalletAlreadyExists => Ok(()),
|
||||
let candidates = unlock_password_candidates().await;
|
||||
// Retry only while LND's unlocker isn't ready yet. If every candidate is
|
||||
// *actively rejected* (invalid passphrase), retrying can't help — fail fast
|
||||
// with a clear message instead of hanging the boot path for 60s+ (the wallet
|
||||
// was created with a password this node doesn't have → migration/recovery).
|
||||
for _ in 0..60 {
|
||||
let mut all_rejected = true;
|
||||
for pw in &candidates {
|
||||
match try_unlock_once(&client, pw).await {
|
||||
UnlockAttempt::Unlocked => return Ok(true),
|
||||
UnlockAttempt::WrongPassword => {}
|
||||
UnlockAttempt::NotReady => all_rejected = false,
|
||||
}
|
||||
}
|
||||
if all_rejected {
|
||||
tracing::warn!(
|
||||
"[lnd] none of the {} candidate password(s) unlock the wallet — it was created \
|
||||
with a password this node does not have",
|
||||
candidates.len()
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND wallet unlock timed out waiting for the unlocker to become ready")
|
||||
}
|
||||
|
||||
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
|
||||
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
||||
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/state"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let v: serde_json::Value = resp.json().await.ok()?;
|
||||
v.get("state")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// ChangePassword via WalletUnlocker (wallet must be LOCKED). Both passwords are
|
||||
/// base64-encoded. Ok(true) = current accepted and rotated; Ok(false) = current
|
||||
/// rejected (wrong password — try the next candidate); Err = transport/other.
|
||||
async fn change_wallet_password(
|
||||
client: &reqwest::Client,
|
||||
current: &str,
|
||||
new: &str,
|
||||
) -> Result<bool> {
|
||||
let body = serde_json::json!({
|
||||
"current_password": base64::engine::general_purpose::STANDARD.encode(current),
|
||||
"new_password": base64::engine::general_purpose::STANDARD.encode(new),
|
||||
});
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/changepassword"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("calling LND changepassword")?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() {
|
||||
Ok(true)
|
||||
} else if text.contains("invalid passphrase") {
|
||||
Ok(false)
|
||||
} else {
|
||||
anyhow::bail!("LND changepassword returned {status}: {text}")
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn unlock_existing_wallet_via_lncli() -> Result<()> {
|
||||
let mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["exec", "-i", "lnd", "lncli", "unlock", "--stdin"]);
|
||||
cmd.stdin(std::process::Stdio::piped());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
/// Best-effort migration of a LOCKED wallet onto the per-node secret. Called at
|
||||
/// login, when the onboarding password is available as a candidate. If the
|
||||
/// per-node secret already opens the wallet, just unlock. Otherwise try each
|
||||
/// candidate as the CURRENT password and ChangePassword it to a fresh per-node
|
||||
/// secret so all future boots auto-unlock. Ok(true) = healed/unlocked;
|
||||
/// Ok(false) = not locked, or no candidate worked (seed-recovery required).
|
||||
pub(crate) async fn migrate_locked_wallet(candidates: &[String]) -> Result<bool> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
|
||||
let mut child = cmd.spawn().context("spawning lncli wallet unlock")?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
stdin
|
||||
.write_all(format!("{}\n", WALLET_PASSWORD).as_bytes())
|
||||
.await
|
||||
.context("writing lncli password")?;
|
||||
}
|
||||
let out = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.context("waiting for lncli")?;
|
||||
if out.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let msg = format!("{stderr}{stdout}");
|
||||
if msg.contains("wallet already unlocked") || msg.contains("already unlocked") {
|
||||
return Ok(());
|
||||
}
|
||||
last_err = Some(msg);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
// Only act on a wallet that is actually LOCKED.
|
||||
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
|
||||
return Ok(false);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"lncli wallet unlock failed: {}",
|
||||
last_err.unwrap_or_else(|| "unknown error".to_string())
|
||||
)
|
||||
|
||||
// If the per-node secret already opens it, nothing to rotate — just unlock.
|
||||
if let Some(secret) = read_wallet_password().await {
|
||||
if matches!(
|
||||
try_unlock_once(&client, &secret).await,
|
||||
UnlockAttempt::Unlocked
|
||||
) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
// The wallet's new password becomes the per-node secret (generate if absent).
|
||||
let new_secret = ensure_wallet_password().await?;
|
||||
|
||||
// ChangePassword requires LOCKED; bail out if a prior step already unlocked.
|
||||
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
for cand in candidates {
|
||||
if cand.is_empty() || *cand == new_secret {
|
||||
continue;
|
||||
}
|
||||
match change_wallet_password(&client, cand, &new_secret).await {
|
||||
Ok(true) => {
|
||||
tracing::info!("[lnd-migrate] rotated locked wallet onto the per-node secret");
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false) => continue, // wrong current password — try next candidate
|
||||
Err(e) => tracing::debug!("[lnd-migrate] changepassword error: {e}"),
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
"[lnd-migrate] no candidate password opened the wallet — seed-recovery required"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -204,6 +482,7 @@ struct InitWalletRequest {
|
||||
|
||||
async fn init_wallet_via_rest() -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -223,7 +502,8 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
anyhow::bail!("LND genseed returned no seed words");
|
||||
}
|
||||
|
||||
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
|
||||
let wallet_password =
|
||||
base64::engine::general_purpose::STANDARD.encode(ensure_wallet_password().await?);
|
||||
let req = InitWalletRequest {
|
||||
wallet_password,
|
||||
cipher_seed_mnemonic: seed.cipher_seed_mnemonic,
|
||||
@@ -237,7 +517,9 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
.context("initializing LND wallet")?
|
||||
{
|
||||
UnlockerResponse::Value(_) => {}
|
||||
UnlockerResponse::WalletAlreadyExists => unlock_existing_wallet().await?,
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -305,12 +587,12 @@ async fn decode_lnd_unlocker_response<T: for<'de> Deserialize<'de>>(
|
||||
anyhow::bail!("LND REST {path} returned {status}: {text}")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
|
||||
let Ok(macaroon) = read_file_as_root(admin_macaroon).await else {
|
||||
return false;
|
||||
};
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -448,7 +730,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_password_is_valid_for_lncli() {
|
||||
assert!(WALLET_PASSWORD.len() > 8);
|
||||
fn legacy_wallet_password_is_valid_for_lncli() {
|
||||
// Legacy fallback must still be a valid lncli passphrase (>8 chars).
|
||||
assert!(LEGACY_WALLET_PASSWORD.len() > 8);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlock_candidates_always_include_legacy_fallback() {
|
||||
// With no per-node secret on disk in the test env, candidates fall back
|
||||
// to the legacy constant so old wallets still open.
|
||||
let cands = unlock_password_candidates().await;
|
||||
assert!(cands.iter().any(|p| p == LEGACY_WALLET_PASSWORD));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,53 @@ async fn wait_for_manifest_host_ports(manifest: &AppManifest, timeout_secs: u64)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pure published-port drift check. `port_bindings_json` is the JSON that
|
||||
/// `podman inspect --format '{{json .HostConfig.PortBindings}}'` emits, e.g.
|
||||
/// `{"8080/tcp":[{"HostIp":"","HostPort":"18080"}]}`. Returns true only when a
|
||||
/// manifest container-port is positively published to a *different* host port
|
||||
/// than the manifest now asks for. Absence of a binding is deliberately NOT
|
||||
/// treated as drift here — that case is handled by the host-port repair/restart
|
||||
/// path and by host-networked apps that publish nothing — so we never trigger a
|
||||
/// destructive recreate on a false positive.
|
||||
fn host_port_bindings_drifted(
|
||||
port_bindings_json: &str,
|
||||
manifest_ports: &[archipelago_container::manifest::PortMapping],
|
||||
) -> bool {
|
||||
let parsed: serde_json::Value = match serde_json::from_str(port_bindings_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let Some(map) = parsed.as_object() else {
|
||||
return false;
|
||||
};
|
||||
for port in manifest_ports {
|
||||
let proto = if port.protocol.is_empty() {
|
||||
"tcp"
|
||||
} else {
|
||||
port.protocol.as_str()
|
||||
};
|
||||
let key = format!("{}/{}", port.container, proto);
|
||||
let Some(bindings) = map.get(&key).and_then(|b| b.as_array()) else {
|
||||
// Container-port not currently published — not our case.
|
||||
continue;
|
||||
};
|
||||
if bindings.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let expected = port.host.to_string();
|
||||
let matches_expected = bindings.iter().any(|b| {
|
||||
b.get("HostPort")
|
||||
.and_then(|h| h.as_str())
|
||||
.map(|h| h == expected)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if !matches_expected {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn ensure_user_podman_socket() -> Result<()> {
|
||||
let socket_path = "/run/user/1000/podman/podman.sock";
|
||||
if podman_socket_accepts_connections(socket_path).await {
|
||||
@@ -725,6 +772,8 @@ pub struct ProdContainerOrchestrator {
|
||||
use_quadlet_backends: bool,
|
||||
#[cfg(test)]
|
||||
test_disk_gb: Option<u64>,
|
||||
#[cfg(test)]
|
||||
test_bitcoin_host: Option<String>,
|
||||
}
|
||||
|
||||
struct FileSecretsProvider {
|
||||
@@ -734,8 +783,19 @@ struct FileSecretsProvider {
|
||||
impl SecretsProvider for FileSecretsProvider {
|
||||
fn read(&self, name: &str) -> std::result::Result<String, ManifestError> {
|
||||
let path = self.root.join(name);
|
||||
let data = std::fs::read_to_string(&path).map_err(ManifestError::Io)?;
|
||||
Ok(data.trim().to_string())
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(data) => Ok(data.trim().to_string()),
|
||||
// Name the missing secret explicitly so the failure is actionable
|
||||
// instead of a bare "IO error: No such file or directory" that hides
|
||||
// which secret (and which app) is blocked.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
Err(ManifestError::Invalid(format!(
|
||||
"required secret '{name}' is missing (expected at {}); the app cannot start until it is generated",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
Err(e) => Err(ManifestError::Io(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,6 +834,8 @@ impl ProdContainerOrchestrator {
|
||||
use_quadlet_backends: config.use_quadlet_backends,
|
||||
#[cfg(test)]
|
||||
test_disk_gb: None,
|
||||
#[cfg(test)]
|
||||
test_bitcoin_host: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -792,6 +854,7 @@ impl ProdContainerOrchestrator {
|
||||
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
|
||||
use_quadlet_backends: false,
|
||||
test_disk_gb: None,
|
||||
test_bitcoin_host: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1054,6 +1117,7 @@ impl ProdContainerOrchestrator {
|
||||
let lock = self.app_lock(&app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
self.ensure_app_secrets(&app_id).await?;
|
||||
let mut resolved_manifest = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved_manifest)?;
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
@@ -1094,6 +1158,22 @@ impl ProdContainerOrchestrator {
|
||||
self.run_post_start_hooks(&app_id).await?;
|
||||
return Ok(ReconcileAction::Started);
|
||||
}
|
||||
// Published-port drift means the container exists but maps
|
||||
// its ports to the wrong host ports (e.g. lnd REST stuck on
|
||||
// host 8080 while the manifest/clients expect 18080, as seen
|
||||
// on .116). The container is already non-functional, so
|
||||
// recreate it even for restart-sensitive apps during boot —
|
||||
// leaving it "untouched" would perpetuate the breakage.
|
||||
if self
|
||||
.container_ports_drifted(&name, &resolved_manifest)
|
||||
.await
|
||||
{
|
||||
tracing::info!(app_id = %app_id, container = %name, "container published-port drift detected — recreating");
|
||||
let _ = self.runtime.stop_container(&name).await;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(lm).await?;
|
||||
return Ok(ReconcileAction::Installed);
|
||||
}
|
||||
if self.container_env_drifted(&name, &resolved_manifest).await {
|
||||
if mode == ReconcileMode::ExistingOnly
|
||||
&& is_restart_sensitive_app(&app_id)
|
||||
@@ -1154,8 +1234,12 @@ impl ProdContainerOrchestrator {
|
||||
// reading it. A Rewritten outcome is fine here — we're
|
||||
// about to start from a stopped state anyway.
|
||||
self.prepare_for_start(&resolved_manifest).await?;
|
||||
if self.container_env_drifted(&name, &resolved_manifest).await {
|
||||
tracing::info!(app_id = %app_id, container = %name, "stopped container env drift detected — recreating");
|
||||
if self.container_env_drifted(&name, &resolved_manifest).await
|
||||
|| self
|
||||
.container_ports_drifted(&name, &resolved_manifest)
|
||||
.await
|
||||
{
|
||||
tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating");
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(lm).await?;
|
||||
return Ok(ReconcileAction::Installed);
|
||||
@@ -1297,6 +1381,7 @@ impl ProdContainerOrchestrator {
|
||||
|
||||
/// Build-or-pull, create, start. Assumes the per-app mutex is already held.
|
||||
async fn install_fresh(&self, lm: &LoadedManifest) -> Result<()> {
|
||||
self.ensure_app_secrets(&lm.manifest.app.id).await?;
|
||||
let mut resolved_manifest = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved_manifest)?;
|
||||
|
||||
@@ -2234,9 +2319,45 @@ impl ProdContainerOrchestrator {
|
||||
host_ip,
|
||||
host_mdns,
|
||||
disk_gb,
|
||||
// Cheap default; resolve_dynamic_env fills the real node name on
|
||||
// demand (it costs a podman call) only for manifests that use
|
||||
// {{BITCOIN_HOST}}, rather than every app on every reconcile.
|
||||
bitcoin_host: "bitcoin-knots".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Container name of the running Bitcoin node (`bitcoin-knots` or
|
||||
/// `bitcoin-core`) for the `{{BITCOIN_HOST}}` derived-env placeholder.
|
||||
/// Synchronous `podman ps` to match the surrounding host-fact detection;
|
||||
/// defaults to `bitcoin-knots` when none is running (B12).
|
||||
fn bitcoin_host(&self) -> String {
|
||||
#[cfg(test)]
|
||||
if let Some(host) = &self.test_bitcoin_host {
|
||||
return host.clone();
|
||||
}
|
||||
// Mirrors api::rpc::package::dependencies (the legacy install path);
|
||||
// both Bitcoin node variants are reachable on archy-net by name.
|
||||
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||
let names = Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
.unwrap_or_default();
|
||||
names
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|name| BITCOIN_NAMES.contains(name))
|
||||
.map(|name| name.to_string())
|
||||
.unwrap_or_else(|| "bitcoin-knots".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_bitcoin_host_for_test(&mut self, host: &str) {
|
||||
self.test_bitcoin_host = Some(host.to_string());
|
||||
}
|
||||
|
||||
fn detect_host_ip() -> Option<String> {
|
||||
let output = Command::new("hostname").arg("-I").output().ok()?;
|
||||
if !output.status.success() {
|
||||
@@ -2300,8 +2421,38 @@ impl ProdContainerOrchestrator {
|
||||
Self::detect_disk_gb()
|
||||
}
|
||||
|
||||
/// Ensure app-specific secrets exist *before* env resolution. The Bitcoin
|
||||
/// backends reference `bitcoin-rpc-txrelay-rpcauth` as a required
|
||||
/// `secret_env`; it is normally created by the tx-relay flow, so nodes that
|
||||
/// never used tx-relay lack it and `resolve_secret_env` hard-fails — taking
|
||||
/// bitcoind (and everything that depends on it) down. Generating it here,
|
||||
/// idempotently, lets reconcile/install self-heal that state (the .198 case)
|
||||
/// instead of cascading. Must be called before every `resolve_dynamic_env`.
|
||||
async fn ensure_app_secrets(&self, app_id: &str) -> Result<()> {
|
||||
if cfg!(test) {
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(app_id, "bitcoin-knots" | "bitcoin-core" | "bitcoin") {
|
||||
crate::api::rpc::bitcoin_relay::ensure_txrelay_credentials(&self.data_dir)
|
||||
.await
|
||||
.context("ensuring bitcoin tx-relay credentials")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
|
||||
let facts = self.detect_host_facts();
|
||||
let mut facts = self.detect_host_facts();
|
||||
// Only pay the podman cost to detect Knots-vs-Core when this manifest
|
||||
// actually templates the Bitcoin node into its env (mempool — B12).
|
||||
if manifest
|
||||
.app
|
||||
.container
|
||||
.derived_env
|
||||
.iter()
|
||||
.any(|e| e.template.contains("{{BITCOIN_HOST}}"))
|
||||
{
|
||||
facts.bitcoin_host = self.bitcoin_host();
|
||||
}
|
||||
let mut env = manifest.app.environment.clone();
|
||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||
|
||||
@@ -2443,6 +2594,42 @@ impl ProdContainerOrchestrator {
|
||||
false
|
||||
}
|
||||
|
||||
/// True when a *running* container publishes a manifest container-port to a
|
||||
/// different host port than the manifest now asks for (published-port
|
||||
/// drift). This catches the class of failure seen on .116, where `lnd` was
|
||||
/// created mapping host 8080 -> container 8080 but the current manifest maps
|
||||
/// host 18080 -> container 8080, so every in-process REST client (which
|
||||
/// connects to the manifest port) gets connection-refused forever while the
|
||||
/// container looks "Up". `container_env_drifted` never inspects ports, and
|
||||
/// `wait_for_manifest_host_ports` only restarts the stale container (which
|
||||
/// republishes the wrong mapping), so without this check the drift is
|
||||
/// self-perpetuating.
|
||||
async fn container_ports_drifted(&self, name: &str, manifest: &AppManifest) -> bool {
|
||||
if cfg!(test) {
|
||||
return false;
|
||||
}
|
||||
if manifest.app.ports.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let inspect = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"inspect",
|
||||
name,
|
||||
"--format",
|
||||
"{{json .HostConfig.PortBindings}}",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let Ok(output) = inspect else {
|
||||
return false;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return false;
|
||||
}
|
||||
let bindings = String::from_utf8_lossy(&output.stdout);
|
||||
host_port_bindings_drifted(&bindings, &manifest.app.ports)
|
||||
}
|
||||
|
||||
async fn apply_data_uid(&self, manifest: &AppManifest) -> Result<()> {
|
||||
let Some(uid_gid) = manifest.app.container.data_uid.as_ref() else {
|
||||
return Ok(());
|
||||
@@ -2752,6 +2939,7 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
let lm = self.loaded(app_id).await?;
|
||||
let lock = self.app_lock(app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
self.ensure_app_secrets(app_id).await?;
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
let mut resolved_manifest = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved_manifest)?;
|
||||
@@ -2913,6 +3101,61 @@ mod tests {
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
fn port(host: u16, container: u16) -> archipelago_container::manifest::PortMapping {
|
||||
archipelago_container::manifest::PortMapping {
|
||||
host,
|
||||
container,
|
||||
protocol: "tcp".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_drift_detected_when_host_port_differs() {
|
||||
// The .116 case: container publishes container-port 8080 on host 8080,
|
||||
// but the manifest now asks for host 18080.
|
||||
let bindings = r#"{"8080/tcp":[{"HostIp":"","HostPort":"8080"}]}"#;
|
||||
assert!(host_port_bindings_drifted(bindings, &[port(18080, 8080)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_drift_when_host_port_matches() {
|
||||
let bindings = r#"{"8080/tcp":[{"HostIp":"0.0.0.0","HostPort":"18080"}]}"#;
|
||||
assert!(!host_port_bindings_drifted(bindings, &[port(18080, 8080)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_drift_when_binding_absent() {
|
||||
// Absence is handled elsewhere (host-port repair / host-networked apps);
|
||||
// never treat it as drift to avoid a destructive recreate on a false
|
||||
// positive.
|
||||
assert!(!host_port_bindings_drifted("{}", &[port(18080, 8080)]));
|
||||
assert!(!host_port_bindings_drifted("null", &[port(18080, 8080)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_drift_on_unparseable_bindings() {
|
||||
assert!(!host_port_bindings_drifted(
|
||||
"not json",
|
||||
&[port(18080, 8080)]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_secret_error_names_the_secret() {
|
||||
use archipelago_container::manifest::SecretsProvider;
|
||||
let provider = FileSecretsProvider {
|
||||
root: PathBuf::from("/nonexistent-secrets-dir-xyz"),
|
||||
};
|
||||
let err = provider
|
||||
.read("bitcoin-rpc-txrelay-rpcauth")
|
||||
.expect_err("missing secret must error");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("bitcoin-rpc-txrelay-rpcauth"),
|
||||
"error should name the missing secret, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Instrumented in-memory runtime. Every call is recorded so tests can assert
|
||||
/// the exact sequence of side effects.
|
||||
#[derive(Default)]
|
||||
@@ -3220,11 +3463,11 @@ app:
|
||||
- /data/.filebrowser.json
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /tmp/filebrowser-srv
|
||||
source: /var/lib/archipelago/filebrowser-srv
|
||||
target: /srv
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /tmp/filebrowser-data
|
||||
source: /var/lib/archipelago/filebrowser-data
|
||||
target: /data
|
||||
options: [rw]
|
||||
"#;
|
||||
@@ -3244,7 +3487,7 @@ app:
|
||||
secret_file: bitcoin-rpc-password
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /tmp/lnd
|
||||
source: /var/lib/archipelago/lnd
|
||||
target: /root/.lnd
|
||||
"#;
|
||||
AppManifest::parse(yaml).unwrap()
|
||||
@@ -3298,6 +3541,35 @@ app:
|
||||
assert!(!calls.iter().any(|c| c.starts_with("build_image:")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mempool_core_rpc_host_follows_bitcoin_node() {
|
||||
// B12: mempool's CORE_RPC_HOST must resolve to whichever Bitcoin node
|
||||
// container is running (Knots OR Core), not a hardcoded value.
|
||||
let yaml = "app:\n id: mempool-api\n name: mempool-api\n version: 1.0.0\n container:\n image: x:1\n derived_env:\n - key: CORE_RPC_HOST\n template: \"{{BITCOIN_HOST}}\"\n";
|
||||
|
||||
for (node, expected) in [
|
||||
("bitcoin-core", "bitcoin-core"),
|
||||
("bitcoin-knots", "bitcoin-knots"),
|
||||
] {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
orch.set_bitcoin_host_for_test(node);
|
||||
|
||||
let mut manifest = AppManifest::parse(yaml).unwrap();
|
||||
orch.resolve_dynamic_env(&mut manifest).unwrap();
|
||||
|
||||
assert!(
|
||||
manifest
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|e| e == &format!("CORE_RPC_HOST={expected}")),
|
||||
"node={node}: expected CORE_RPC_HOST={expected}, got {:?}",
|
||||
manifest.app.environment
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_fresh_build_when_image_absent() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
|
||||
@@ -14,8 +14,8 @@ mod types;
|
||||
pub use invites::{accept_invite, create_invite};
|
||||
#[allow(unused_imports)]
|
||||
pub use storage::{
|
||||
add_node, fips_npub_for_onion, load_nodes, record_peer_transport, remove_node, save_nodes,
|
||||
set_trust_level, update_node,
|
||||
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
|
||||
remove_node, save_nodes, set_trust_level, update_node,
|
||||
};
|
||||
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did};
|
||||
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
|
||||
|
||||
@@ -10,6 +10,9 @@ use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLeve
|
||||
pub(crate) const FEDERATION_DIR: &str = "federation";
|
||||
pub(crate) const NODES_FILE: &str = "nodes.json";
|
||||
pub(crate) const INVITES_FILE: &str = "invites.json";
|
||||
/// Tombstones: DIDs the operator explicitly removed. Kept so transitive
|
||||
/// federation discovery can't silently re-add a peer they deleted.
|
||||
pub(crate) const REMOVED_FILE: &str = "removed-nodes.json";
|
||||
|
||||
/// Top-level file structures.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
@@ -17,6 +20,17 @@ pub(crate) struct NodesFile {
|
||||
pub(crate) nodes: Vec<FederatedNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct RemovedFile {
|
||||
pub(crate) removed: Vec<RemovedNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct RemovedNode {
|
||||
pub(crate) did: String,
|
||||
pub(crate) removed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct InvitesFile {
|
||||
pub(crate) outgoing: Vec<FederationInvite>,
|
||||
@@ -44,7 +58,43 @@ pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
|
||||
.await
|
||||
.context("Failed to read federation nodes")?;
|
||||
let file: NodesFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file.nodes)
|
||||
Ok(dedup_nodes_by_onion(file.nodes))
|
||||
}
|
||||
|
||||
/// Collapse entries that share an onion. An onion is a node's stable, unique
|
||||
/// network identity, so two entries with the same onion are the SAME physical
|
||||
/// node lingering under two dids (e.g. after a did/key change). Returning both
|
||||
/// duplicates the node in the trusted-node list (B1) and the chat list (B2).
|
||||
/// Keep the first occurrence and merge any missing fips_npub/name/last_state
|
||||
/// from the duplicates into it, then drop them. Non-destructive to disk; the
|
||||
/// deduped list persists the next time nodes are saved (add/sync).
|
||||
fn dedup_nodes_by_onion(nodes: Vec<FederatedNode>) -> Vec<FederatedNode> {
|
||||
use std::collections::HashMap;
|
||||
let mut by_onion: HashMap<String, usize> = HashMap::new();
|
||||
let mut out: Vec<FederatedNode> = Vec::with_capacity(nodes.len());
|
||||
for node in nodes {
|
||||
let key = node.onion.trim_end_matches(".onion").to_string();
|
||||
if key.is_empty() {
|
||||
out.push(node);
|
||||
continue;
|
||||
}
|
||||
if let Some(&idx) = by_onion.get(&key) {
|
||||
let kept = &mut out[idx];
|
||||
if kept.fips_npub.is_none() {
|
||||
kept.fips_npub = node.fips_npub;
|
||||
}
|
||||
if kept.name.is_none() {
|
||||
kept.name = node.name;
|
||||
}
|
||||
if kept.last_state.is_none() {
|
||||
kept.last_state = node.last_state;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
by_onion.insert(key, out.len());
|
||||
out.push(node);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Look up a federated peer's FIPS npub given their onion address.
|
||||
@@ -114,6 +164,9 @@ pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<Federa
|
||||
if exists {
|
||||
anyhow::bail!("Node with DID {} is already federated", node.did);
|
||||
}
|
||||
// Explicitly (re-)adding a node clears any prior tombstone so the
|
||||
// operator can intentionally bring back a previously removed peer.
|
||||
let _ = untombstone_did(data_dir, &node.did).await;
|
||||
nodes.push(node);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
@@ -127,9 +180,70 @@ pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode
|
||||
anyhow::bail!("No federated node with DID {}", did);
|
||||
}
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
// Tombstone the DID so transitive federation discovery (a still-federated
|
||||
// peer advertising this DID as one of *its* trusted peers) can't silently
|
||||
// re-add it. Best-effort: a failed tombstone write must not fail the
|
||||
// remove the operator asked for.
|
||||
let _ = tombstone_did(data_dir, did).await;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Load the set of tombstoned (operator-removed) DIDs.
|
||||
pub async fn load_removed_dids(data_dir: &Path) -> Result<std::collections::HashSet<String>> {
|
||||
let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(std::collections::HashSet::new());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read removed nodes")?;
|
||||
let file: RemovedFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file.removed.into_iter().map(|r| r.did).collect())
|
||||
}
|
||||
|
||||
/// Record a DID as removed. Idempotent.
|
||||
pub async fn tombstone_did(data_dir: &Path, did: &str) -> Result<()> {
|
||||
let dir = ensure_dir(data_dir).await?;
|
||||
let path = dir.join(REMOVED_FILE);
|
||||
let mut file: RemovedFile = if path.exists() {
|
||||
serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
RemovedFile::default()
|
||||
};
|
||||
if !file.removed.iter().any(|r| r.did == did) {
|
||||
file.removed.push(RemovedNode {
|
||||
did: did.to_string(),
|
||||
removed_at: chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write removed nodes")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear a DID's tombstone (operator explicitly re-added it).
|
||||
pub async fn untombstone_did(data_dir: &Path, did: &str) -> Result<()> {
|
||||
let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut file: RemovedFile =
|
||||
serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
let before = file.removed.len();
|
||||
file.removed.retain(|r| r.did != did);
|
||||
if file.removed.len() != before {
|
||||
let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write removed nodes")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_trust_level(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
@@ -236,6 +350,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dedup_nodes_by_onion_collapses_same_onion() {
|
||||
// Two entries share an onion (same physical node under two dids) — must
|
||||
// collapse to one, keeping the first did and merging fips_npub/name (B1/B2).
|
||||
let mut dup = make_node("did:key:zDUP", "shared.onion");
|
||||
dup.fips_npub = Some("npub1merged".to_string());
|
||||
dup.name = Some("Sapien".to_string());
|
||||
let nodes = vec![
|
||||
make_node("did:key:zKEEP", "shared.onion"),
|
||||
dup,
|
||||
make_node("did:key:zOTHER", "other.onion"),
|
||||
];
|
||||
let out = dedup_nodes_by_onion(nodes);
|
||||
assert_eq!(out.len(), 2, "two distinct onions remain");
|
||||
let kept = out.iter().find(|n| n.onion == "shared.onion").unwrap();
|
||||
assert_eq!(kept.did, "did:key:zKEEP", "keeps first did for the onion");
|
||||
assert_eq!(
|
||||
kept.fips_npub.as_deref(),
|
||||
Some("npub1merged"),
|
||||
"merges fips_npub from the dropped duplicate"
|
||||
);
|
||||
assert_eq!(
|
||||
kept.name.as_deref(),
|
||||
Some("Sapien"),
|
||||
"merges name from the dup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dedup_onion_suffix_insensitive() {
|
||||
// The ".onion" suffix must not affect the match.
|
||||
let nodes = vec![
|
||||
make_node("did:key:z1", "abc"),
|
||||
make_node("did:key:z2", "abc.onion"),
|
||||
];
|
||||
assert_eq!(dedup_nodes_by_onion(nodes).len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_nodes_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -287,6 +439,36 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_tombstones_and_readd_clears_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
// No tombstones yet.
|
||||
assert!(load_removed_dids(dir.path()).await.unwrap().is_empty());
|
||||
|
||||
// Removing tombstones the DID so transitive discovery won't re-add it.
|
||||
remove_node(dir.path(), "did:key:z1").await.unwrap();
|
||||
let removed = load_removed_dids(dir.path()).await.unwrap();
|
||||
assert!(
|
||||
removed.contains("did:key:z1"),
|
||||
"removed DID must be tombstoned"
|
||||
);
|
||||
|
||||
// Explicitly re-adding clears the tombstone (intentional re-federate).
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!load_removed_dids(dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("did:key:z1"),
|
||||
"explicit re-add must clear the tombstone"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_trust_level() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -118,6 +118,12 @@ async fn merge_transitive_peers(
|
||||
return Ok(());
|
||||
}
|
||||
let mut nodes = super::storage::load_nodes(data_dir).await?;
|
||||
// Tombstoned DIDs: peers the operator explicitly removed. Never re-add
|
||||
// them via transitive discovery, or deleted (e.g. stale test) nodes
|
||||
// reappear on the next sync with any peer that still lists them.
|
||||
let removed = super::storage::load_removed_dids(data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut added = 0u32;
|
||||
let mut refreshed = 0u32;
|
||||
|
||||
@@ -127,6 +133,10 @@ async fn merge_transitive_peers(
|
||||
if hint.did == source_did || hint.did == local_did {
|
||||
continue;
|
||||
}
|
||||
// Skip anything the operator deliberately removed.
|
||||
if removed.contains(&hint.did) {
|
||||
continue;
|
||||
}
|
||||
if let Some(existing) = nodes.iter_mut().find(|n| n.did == hint.did) {
|
||||
// Already known — just refresh fips_npub if we didn't have one.
|
||||
if existing.fips_npub.is_none() && hint.fips_npub.is_some() {
|
||||
@@ -135,6 +145,27 @@ async fn merge_transitive_peers(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Same physical node advertised under a DIFFERENT did? Match on the
|
||||
// onion (its stable network identity). Without this, a node that
|
||||
// appears under two dids (e.g. after a key/did change) gets added
|
||||
// twice — showing up duplicated in the trusted-node list (B1) and as
|
||||
// two separate mesh chat contacts (B2). Merge into the existing entry.
|
||||
let hint_onion = hint.onion.trim_end_matches(".onion");
|
||||
if !hint_onion.is_empty() {
|
||||
if let Some(existing) = nodes
|
||||
.iter_mut()
|
||||
.find(|n| n.onion.trim_end_matches(".onion") == hint_onion)
|
||||
{
|
||||
if existing.fips_npub.is_none() && hint.fips_npub.is_some() {
|
||||
existing.fips_npub = hint.fips_npub.clone();
|
||||
}
|
||||
if existing.name.is_none() && hint.name.is_some() {
|
||||
existing.name = hint.name.clone();
|
||||
}
|
||||
refreshed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
nodes.push(FederatedNode {
|
||||
did: hint.did.clone(),
|
||||
pubkey: hint.pubkey.clone(),
|
||||
|
||||
@@ -28,12 +28,38 @@ 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.
|
||||
/// Public anchor (`fips.v0l.io`) carried as a default seed for every
|
||||
/// node — it bootstraps DHT routing so a fresh node isn't isolated.
|
||||
/// Operators can remove it from the UI once their own cluster has
|
||||
/// independent anchors (removal persists, see `load`/`remove`).
|
||||
///
|
||||
/// IMPORTANT transport details, learned the hard way (see git history /
|
||||
/// the 2026-06-15 debugging on .116):
|
||||
/// - The anchor answers ONLY on **TCP port 8443**. UDP 8668 is dead
|
||||
/// (host pings on both IP families but never completes a UDP FIPS
|
||||
/// handshake). `fips/config.rs` always knew this; the old default
|
||||
/// here (`fips.v0l.io:8668`/udp) silently never connected fleet-wide.
|
||||
/// - We use the **IPv4 literal** rather than the `fips.v0l.io` hostname
|
||||
/// on purpose: the hostname resolves IPv6-first, but the daemon binds
|
||||
/// its transports IPv4-only (`0.0.0.0:8443`), so a v6 target makes the
|
||||
/// daemon fail to send the handshake with `EAFNOSUPPORT (os error 97)`.
|
||||
/// An IPv4 literal sidesteps the resolver entirely.
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
|
||||
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "fips.v0l.io:8668";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp";
|
||||
|
||||
/// The default public anchor as a ready-to-apply `SeedAnchor`. Carried
|
||||
/// implicitly by `load()` on nodes that have never edited their anchor
|
||||
/// list, so every node dials it without operator action.
|
||||
pub fn default_public_anchor() -> SeedAnchor {
|
||||
SeedAnchor {
|
||||
npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(),
|
||||
address: DEFAULT_PUBLIC_ANCHOR_ADDR.to_string(),
|
||||
transport: DEFAULT_PUBLIC_ANCHOR_TRANSPORT.to_string(),
|
||||
label: "Public anchor (fips.v0l.io)".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One seed-anchor entry. `address` must be directly dialable (IP or
|
||||
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
|
||||
@@ -60,12 +86,15 @@ 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.
|
||||
/// Load the seed-anchor list. A node that has never edited its anchor
|
||||
/// list (no file yet) gets the default public anchor so it can bootstrap
|
||||
/// the mesh out of the box. Once the operator edits anchors — including
|
||||
/// removing the default — a file exists and is authoritative, so removal
|
||||
/// persists and we never silently re-add it.
|
||||
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
|
||||
let path = anchors_path(data_dir);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
return Ok(vec![default_public_anchor()]);
|
||||
}
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
@@ -121,11 +150,27 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
|
||||
/// `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.
|
||||
///
|
||||
/// Invoked through `sudo -n`: the upstream daemon's control socket
|
||||
/// (`/run/fips/control.sock`) is owned `root:fips` 0660, and the
|
||||
/// archipelago service user is not in the `fips` group, so a bare
|
||||
/// `fipsctl connect` fails with EACCES. This matches the privileged
|
||||
/// `sudo -n fipsctl show peers` call in `service::peer_connectivity_summary`.
|
||||
/// Without it, seed anchors persist to disk but never actually dial,
|
||||
/// leaving `anchor_connected=false` and every peer dial falling back to
|
||||
/// a slow Tor timeout.
|
||||
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])
|
||||
let out = Command::new("sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"fipsctl",
|
||||
"connect",
|
||||
&anchor.npub,
|
||||
&anchor.address,
|
||||
&anchor.transport,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let result = match out {
|
||||
@@ -138,7 +183,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!(
|
||||
"fipsctl exited {}: {}",
|
||||
"sudo fipsctl connect exited {}: {}",
|
||||
o.status,
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
@@ -146,7 +191,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
Err(e) => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!("fipsctl launch failed: {}", e),
|
||||
message: format!("sudo fipsctl launch failed: {}", e),
|
||||
},
|
||||
};
|
||||
if result.ok {
|
||||
@@ -185,10 +230,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_missing_returns_empty() {
|
||||
async fn load_missing_seeds_default_public_anchor() {
|
||||
// A node that has never edited its anchor list should still get
|
||||
// the public anchor so it can bootstrap the mesh out of the box.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.is_empty());
|
||||
assert_eq!(got, vec![default_public_anchor()]);
|
||||
// ...and the default must be the TCP/8443 form, not the dead udp:8668.
|
||||
assert_eq!(got[0].transport, "tcp");
|
||||
assert!(got[0].address.ends_with(":8443"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_default_persists_as_empty() {
|
||||
// Once the operator removes the default, a file exists and is
|
||||
// authoritative — we must not silently re-seed it on next load.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(list.is_empty());
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.is_empty(), "default must stay removed once edited");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -34,6 +34,17 @@ use tokio::net::UdpSocket;
|
||||
/// path filter can restrict the exposed surface.
|
||||
pub const PEER_PORT: u16 = 5679;
|
||||
|
||||
/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in
|
||||
/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener
|
||||
/// doesn't expose that path (e.g. a peer on an older build with a stricter
|
||||
/// `is_peer_allowed_path`), and `5xx` is a server-side error — both are
|
||||
/// worth retrying over Tor, which reaches a different (less-filtered) route.
|
||||
/// Success, redirects, and other 4xx (auth / bad request) are authoritative
|
||||
/// and are returned as-is so we neither mask real errors nor double latency.
|
||||
fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
|
||||
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
|
||||
}
|
||||
|
||||
/// DNS suffix appended to a peer's bech32 npub.
|
||||
pub const FIPS_DNS_SUFFIX: &str = "fips";
|
||||
|
||||
@@ -294,13 +305,22 @@ impl<'a> PeerRequest<'a> {
|
||||
let pref = self.preference().await;
|
||||
// FIPS-only or Auto: try FIPS first.
|
||||
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
||||
if let Some(resp) = self.try_fips_post_json(body).await? {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
match self.try_fips_post_json(body).await? {
|
||||
Some(resp) => {
|
||||
// Use the FIPS reply unless it's one a Tor retry could
|
||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||
// fall back. FIPS-only never falls back.
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let resp = self.send_tor_post_json(body).await?;
|
||||
@@ -312,13 +332,19 @@ impl<'a> PeerRequest<'a> {
|
||||
use crate::settings::transport::TransportPref;
|
||||
let pref = self.preference().await;
|
||||
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
||||
if let Some(resp) = self.try_fips_get().await? {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
match self.try_fips_get().await? {
|
||||
Some(resp) => {
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let resp = self.send_tor_get().await?;
|
||||
|
||||
@@ -1,156 +1,401 @@
|
||||
//! User-triggered FIPS upgrade from the upstream default branch.
|
||||
//! User-triggered FIPS upgrade from upstream GitHub releases.
|
||||
//!
|
||||
//! Flow (no auto-update, no background polling — user clicks a button):
|
||||
//! 1. Query GitHub for the upstream repo's default branch, then the
|
||||
//! latest commit on it. (jmcorgan/fips default is `master`, not
|
||||
//! `main` — we resolve it dynamically so a future rename Just Works.)
|
||||
//! 2. Compare with the installed daemon version reported by
|
||||
//! `fipsctl --version`. If identical, report "up to date".
|
||||
//! 3. Fetch the built .deb artefact for that commit + its SHA256.
|
||||
//! 4. SHA256-verify the download.
|
||||
//! 5. `sudo dpkg -i` the .deb, `sudo systemctl restart` the service.
|
||||
//! 1. Query GitHub for the latest *stable* release of `jmcorgan/fips`
|
||||
//! (`/releases/latest` returns the newest non-prerelease, non-draft
|
||||
//! tag, so release candidates like `v0.4.0-rc1` are skipped).
|
||||
//! 2. Compare its tag (e.g. `v0.3.0`) with the installed daemon version
|
||||
//! reported by `fipsctl --version`. A dev/pre-release build of the
|
||||
//! same number (`0.3.0-dev`) counts as older than the released tag.
|
||||
//! 3. Pick the Debian package asset matching the host architecture
|
||||
//! (`fips_<ver>_amd64.deb` / `_arm64.deb`) plus `checksums-linux.txt`.
|
||||
//! 4. Download both, SHA256-verify the .deb against the checksums file.
|
||||
//! 5. `sudo dpkg -i` the verified .deb, then restart the active fips unit.
|
||||
//!
|
||||
//! The artefact URL / SHA256 source is not yet fixed — upstream doesn't
|
||||
//! publish stable release assets for per-commit builds. This module
|
||||
//! currently implements steps 1–2 (the "is there anything newer?" query)
|
||||
//! and stubs out 3–5 so the RPC/UI can wire through. The apply path
|
||||
//! returns a clear "not yet available" error until the artefact source
|
||||
//! is decided.
|
||||
//! Upstream began publishing tagged releases with `.deb` artefacts and
|
||||
//! `checksums-linux.txt` (verified present as of v0.1.0 → v0.4.0-rc1), so
|
||||
//! the apply path is fully wired against those assets.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::{service, UPSTREAM_REPO};
|
||||
|
||||
const GITHUB_API: &str = "https://api.github.com";
|
||||
const USER_AGENT: &str = "archipelago-fips-updater";
|
||||
|
||||
/// Result of `check_update()` — what the dashboard renders.
|
||||
/// Result of `check()` — what the dashboard renders.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateCheck {
|
||||
/// Currently installed daemon version (from `fipsctl --version`).
|
||||
pub current: Option<String>,
|
||||
/// Short SHA of the latest commit on upstream `main`.
|
||||
pub latest_commit: String,
|
||||
/// True when the installed version string does not mention the latest SHA.
|
||||
/// Tag of the latest stable upstream release, e.g. `v0.3.0`.
|
||||
pub latest_version: String,
|
||||
/// True when the installed version is older than `latest_version`.
|
||||
pub update_available: bool,
|
||||
/// Release channel this check tracked. Currently always "stable".
|
||||
pub channel: String,
|
||||
/// Browser download URL of the architecture-matched .deb for the
|
||||
/// latest release, when one exists (informational; apply() re-resolves).
|
||||
pub asset_url: Option<String>,
|
||||
/// Human-readable note for the UI.
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
/// Query GitHub for the latest commit on the upstream default branch and
|
||||
/// compare to the installed version. Never errors on "no package installed"
|
||||
/// — that is itself a valid state where an update is available.
|
||||
/// One GitHub release as we consume it.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Release {
|
||||
tag_name: String,
|
||||
#[serde(default)]
|
||||
prerelease: bool,
|
||||
#[serde(default)]
|
||||
draft: bool,
|
||||
#[serde(default)]
|
||||
assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Asset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
fn http_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Build HTTP client")
|
||||
}
|
||||
|
||||
/// Debian architecture string for the host (`amd64` / `arm64`). Returns
|
||||
/// the raw `std::env::consts::ARCH` for anything we don't map, so the
|
||||
/// asset lookup simply finds nothing and surfaces a clear error.
|
||||
fn deb_arch() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Query GitHub for the latest stable release and compare to the installed
|
||||
/// version. Never errors on "no package installed" — that is itself a valid
|
||||
/// state where an update is available.
|
||||
pub async fn check() -> Result<UpdateCheck> {
|
||||
let current = service::daemon_version().await.ok();
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.context("Build HTTP client")?;
|
||||
let branch = fetch_default_branch(&client).await?;
|
||||
let latest = fetch_head_sha(&client, &branch).await?;
|
||||
let short = latest.chars().take(7).collect::<String>();
|
||||
let client = http_client()?;
|
||||
let release = fetch_latest_stable(&client).await?;
|
||||
|
||||
let update_available = match ¤t {
|
||||
Some(v) => !v.contains(&short),
|
||||
Some(v) => version_is_older(v, &release.tag_name),
|
||||
None => true,
|
||||
};
|
||||
|
||||
let asset_url = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| is_deb_for_arch(&a.name))
|
||||
.map(|a| a.browser_download_url.clone());
|
||||
|
||||
let notes = if update_available {
|
||||
format!(
|
||||
"Upstream {} is at {}; installed: {}",
|
||||
branch,
|
||||
short,
|
||||
"Update available: {} (installed: {})",
|
||||
release.tag_name,
|
||||
current.as_deref().unwrap_or("not installed")
|
||||
)
|
||||
} else {
|
||||
format!("Up to date ({} @ {})", branch, short)
|
||||
format!("Up to date ({})", release.tag_name)
|
||||
};
|
||||
|
||||
Ok(UpdateCheck {
|
||||
current,
|
||||
latest_commit: short,
|
||||
latest_version: release.tag_name,
|
||||
update_available,
|
||||
channel: "stable".to_string(),
|
||||
asset_url,
|
||||
notes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the update. Stubbed pending a stable artefact source for
|
||||
/// per-commit builds of the `fips` debian package. When this is wired
|
||||
/// up it must: download → SHA256-verify → `sudo dpkg -i` → restart.
|
||||
/// Download, verify, and install the latest stable FIPS release, then
|
||||
/// restart the daemon. Steps: resolve release → match .deb for this arch
|
||||
/// → download .deb + checksums → SHA256-verify → `sudo dpkg -i` → restart.
|
||||
pub async fn apply() -> Result<()> {
|
||||
anyhow::bail!(
|
||||
"FIPS auto-apply not yet wired — upstream does not publish stable \
|
||||
per-commit .deb artefacts for main. Upgrade manually for now: \
|
||||
`git pull && cargo deb && sudo dpkg -i target/debian/fips_*.deb`."
|
||||
)
|
||||
}
|
||||
let client = http_client()?;
|
||||
let release = fetch_latest_stable(&client).await?;
|
||||
|
||||
async fn fetch_default_branch(client: &reqwest::Client) -> Result<String> {
|
||||
let url = format!("{}/repos/{}", GITHUB_API, UPSTREAM_REPO);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
let deb = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| is_deb_for_arch(&a.name))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"release {} has no .deb for architecture {}",
|
||||
release.tag_name,
|
||||
deb_arch()
|
||||
)
|
||||
})?;
|
||||
let checksums = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == "checksums-linux.txt")
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("release {} has no checksums-linux.txt", release.tag_name)
|
||||
})?;
|
||||
|
||||
// Download the .deb (bytes) and the checksums (text).
|
||||
let deb_bytes = client
|
||||
.get(&deb.browser_download_url)
|
||||
.send()
|
||||
.await
|
||||
.context("GitHub repo API")?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("GitHub repo API returned {}", resp.status());
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.context("Parse repo JSON")?;
|
||||
body.get("default_branch")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("GitHub repo response missing default_branch"))
|
||||
}
|
||||
|
||||
async fn fetch_head_sha(client: &reqwest::Client, branch: &str) -> Result<String> {
|
||||
let url = format!("{}/repos/{}/commits/{}", GITHUB_API, UPSTREAM_REPO, branch);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.context("download .deb")?
|
||||
.error_for_status()
|
||||
.context(".deb download HTTP error")?
|
||||
.bytes()
|
||||
.await
|
||||
.context("read .deb body")?;
|
||||
let checksums_text = client
|
||||
.get(&checksums.browser_download_url)
|
||||
.send()
|
||||
.await
|
||||
.context("GitHub commits API")?;
|
||||
if !resp.status().is_success() {
|
||||
.context("download checksums")?
|
||||
.error_for_status()
|
||||
.context("checksums download HTTP error")?
|
||||
.text()
|
||||
.await
|
||||
.context("read checksums body")?;
|
||||
|
||||
// Verify SHA256 against the checksums manifest (sha256sum format:
|
||||
// "<hex>␠␠<filename>"). The filename column may include a leading
|
||||
// "*" (binary mode) or a path prefix, so match on the basename.
|
||||
let expected = checksums_text
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.split_whitespace();
|
||||
let hash = parts.next()?;
|
||||
let name = parts.next()?.trim_start_matches('*');
|
||||
let base = name.rsplit('/').next().unwrap_or(name);
|
||||
(base == deb.name).then(|| hash.to_lowercase())
|
||||
})
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("checksums-linux.txt has no entry for {}", deb.name))?;
|
||||
|
||||
let actual = {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&deb_bytes);
|
||||
hex::encode(hasher.finalize())
|
||||
};
|
||||
if actual != expected {
|
||||
anyhow::bail!(
|
||||
"GitHub commits API returned {} for branch {}",
|
||||
resp.status(),
|
||||
branch
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
deb.name,
|
||||
expected,
|
||||
actual
|
||||
);
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.context("Parse commits JSON")?;
|
||||
body.get("sha")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("GitHub commits response missing sha field"))
|
||||
|
||||
// Stage the verified .deb in /tmp (shared with the host — the
|
||||
// service runs with PrivateTmp=no) and install it.
|
||||
let dest = std::env::temp_dir().join(&deb.name);
|
||||
tokio::fs::write(&dest, &deb_bytes)
|
||||
.await
|
||||
.with_context(|| format!("write {}", dest.display()))?;
|
||||
|
||||
// Run dpkg via `systemd-run` rather than `sudo dpkg` directly. The
|
||||
// archipelago service runs under `ProtectSystem=strict`, so `/usr`
|
||||
// and `/var/lib/dpkg` are read-only *inside the service's mount
|
||||
// namespace* — and a `sudo` child inherits that namespace, so a
|
||||
// bare `sudo dpkg -i` fails with "Read-only file system" on the
|
||||
// dpkg database. `systemd-run` asks PID 1 to launch the command in
|
||||
// a fresh transient scope outside our sandbox, where the real
|
||||
// (writable) host filesystem is visible. `--wait` blocks until it
|
||||
// finishes and propagates the exit status; `--pipe` forwards
|
||||
// dpkg's output; `--collect` reaps the unit even on failure.
|
||||
//
|
||||
// dpkg flags, both load-bearing for this package specifically:
|
||||
// --force-confold: the fips package ships conffiles under
|
||||
// /etc/fips that archipelago rewrites at install time, so dpkg
|
||||
// hits an interactive "keep/replace?" conffile prompt. With our
|
||||
// closed stdin that aborts the configure step ("EOF on stdin at
|
||||
// conffile prompt") and leaves the package half-unpacked
|
||||
// (status `iU`), which `fips.status` then reports as
|
||||
// `installed:false`. confold = keep our managed config, no prompt.
|
||||
// --force-downgrade: ISO/dev nodes carry `0.3.0-dev-1`, which dpkg
|
||||
// orders as NEWER than the stable tag `0.3.0` (a trailing
|
||||
// `-dev` sorts above the bare release). Moving a dev build onto
|
||||
// the stable line is therefore a dpkg "downgrade"; without this
|
||||
// flag dpkg warns and exits non-zero. Our own version_is_older()
|
||||
// gate already decided this is the wanted direction.
|
||||
// DEBIAN_FRONTEND=noninteractive belt-and-suspenders against any
|
||||
// other maintainer-script prompt.
|
||||
let dpkg = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"systemd-run",
|
||||
"--collect",
|
||||
"--wait",
|
||||
"--quiet",
|
||||
"--pipe",
|
||||
"--",
|
||||
"env",
|
||||
"DEBIAN_FRONTEND=noninteractive",
|
||||
"dpkg",
|
||||
"--force-confold",
|
||||
"--force-downgrade",
|
||||
"-i",
|
||||
])
|
||||
.arg(&dest)
|
||||
.output()
|
||||
.await
|
||||
.context("sudo systemd-run dpkg -i failed to launch")?;
|
||||
// Best-effort cleanup regardless of dpkg result.
|
||||
let _ = tokio::fs::remove_file(&dest).await;
|
||||
if !dpkg.status.success() {
|
||||
anyhow::bail!(
|
||||
"dpkg -i {} exited {}: {}",
|
||||
deb.name,
|
||||
dpkg.status,
|
||||
String::from_utf8_lossy(&dpkg.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
// Restart whichever fips unit is supervising the daemon so the new
|
||||
// binary takes over.
|
||||
let unit = service::active_unit().await;
|
||||
service::restart(unit)
|
||||
.await
|
||||
.with_context(|| format!("restart {} after install", unit))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `/releases/latest` returns the most recent non-prerelease, non-draft
|
||||
/// release. We still re-check the flags defensively in case the endpoint
|
||||
/// or repo settings change.
|
||||
async fn fetch_latest_stable(client: &reqwest::Client) -> Result<Release> {
|
||||
let url = format!("{}/repos/{}/releases/latest", GITHUB_API, UPSTREAM_REPO);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await
|
||||
.context("GitHub releases/latest API")?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("GitHub releases/latest API returned {}", resp.status());
|
||||
}
|
||||
let release: Release = resp.json().await.context("Parse release JSON")?;
|
||||
if release.draft || release.prerelease {
|
||||
anyhow::bail!(
|
||||
"releases/latest returned a {} release ({})",
|
||||
if release.draft { "draft" } else { "prerelease" },
|
||||
release.tag_name
|
||||
);
|
||||
}
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
fn is_deb_for_arch(name: &str) -> bool {
|
||||
name.starts_with("fips_") && name.ends_with(&format!("_{}.deb", deb_arch()))
|
||||
}
|
||||
|
||||
/// Parse the leading `MAJOR.MINOR.PATCH` triple from a version string,
|
||||
/// plus whether a pre-release suffix (`-dev`, `-rc1`, …) follows it.
|
||||
fn parse_version(s: &str) -> Option<((u64, u64, u64), bool)> {
|
||||
// Take the first whitespace token, drop a leading 'v'.
|
||||
let tok = s.split_whitespace().next().unwrap_or(s);
|
||||
let tok = tok.strip_prefix('v').unwrap_or(tok);
|
||||
// Split off any pre-release / build suffix.
|
||||
let (core, rest) = match tok.find(|c: char| c == '-' || c == '+') {
|
||||
Some(i) => (&tok[..i], &tok[i..]),
|
||||
None => (tok, ""),
|
||||
};
|
||||
let mut it = core.split('.');
|
||||
let major = it.next()?.parse::<u64>().ok()?;
|
||||
let minor = it.next().unwrap_or("0").parse::<u64>().ok()?;
|
||||
let patch = it.next().unwrap_or("0").parse::<u64>().ok()?;
|
||||
let has_prerelease = rest.starts_with('-');
|
||||
Some(((major, minor, patch), has_prerelease))
|
||||
}
|
||||
|
||||
/// True when `installed` is strictly older than release tag `latest`.
|
||||
/// Same numeric triple but `installed` carries a pre-release suffix while
|
||||
/// `latest` doesn't ⇒ installed is older (e.g. `0.3.0-dev` < `v0.3.0`).
|
||||
/// If either side can't be parsed, fall back to "differs ⇒ update".
|
||||
fn version_is_older(installed: &str, latest: &str) -> bool {
|
||||
match (parse_version(installed), parse_version(latest)) {
|
||||
(Some((ic, ipre)), Some((lc, lpre))) => {
|
||||
if ic != lc {
|
||||
ic < lc
|
||||
} else {
|
||||
// Equal cores: a pre-release is older than the final release.
|
||||
ipre && !lpre
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unparseable: be conservative — offer the update unless the
|
||||
// installed string already mentions the latest tag.
|
||||
!installed.contains(latest.trim_start_matches('v'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_returns_clear_stub_error() {
|
||||
let err = apply().await.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("not yet wired"),
|
||||
"apply() should return an explicit not-yet-wired error, got: {}",
|
||||
err
|
||||
);
|
||||
#[test]
|
||||
fn test_deb_arch_maps_known() {
|
||||
// On the host running tests this is whatever the test arch is;
|
||||
// just assert it returns a non-empty, lowercase token.
|
||||
let a = deb_arch();
|
||||
assert!(!a.is_empty());
|
||||
assert_eq!(a, a.to_lowercase());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_older() {
|
||||
assert!(version_is_older("0.3.0-dev (rev abc123)", "v0.3.0"));
|
||||
assert!(version_is_older("0.2.1", "v0.3.0"));
|
||||
assert!(version_is_older("0.3.0-rc1", "v0.3.0"));
|
||||
assert!(!version_is_older("0.3.0", "v0.3.0"));
|
||||
assert!(!version_is_older("0.4.0", "v0.3.0"));
|
||||
assert!(!version_is_older("0.3.1", "v0.3.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version() {
|
||||
assert_eq!(parse_version("v0.3.0"), Some(((0, 3, 0), false)));
|
||||
assert_eq!(parse_version("0.3.0-dev (rev x)"), Some(((0, 3, 0), true)));
|
||||
assert_eq!(parse_version("0.4.0-rc1"), Some(((0, 4, 0), true)));
|
||||
assert_eq!(parse_version("1.2"), Some(((1, 2, 0), false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_deb_for_arch() {
|
||||
let arch = deb_arch();
|
||||
assert!(is_deb_for_arch(&format!("fips_0.3.0_{}.deb", arch)));
|
||||
assert!(!is_deb_for_arch("fips_0.3.0_someotherarch.deb"));
|
||||
assert!(!is_deb_for_arch("checksums-linux.txt"));
|
||||
assert!(!is_deb_for_arch(&format!(
|
||||
"fips-0.3.0-linux-{}.tar.gz",
|
||||
arch
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_check_serialises() {
|
||||
let uc = UpdateCheck {
|
||||
current: Some("0.2.0-abc1234".to_string()),
|
||||
latest_commit: "def5678".to_string(),
|
||||
current: Some("0.3.0-dev".to_string()),
|
||||
latest_version: "v0.3.0".to_string(),
|
||||
update_available: true,
|
||||
channel: "stable".to_string(),
|
||||
asset_url: Some("https://example/fips_0.3.0_amd64.deb".to_string()),
|
||||
notes: "test".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&uc).unwrap();
|
||||
assert!(json.contains("latest_commit"));
|
||||
assert!(json.contains("latest_version"));
|
||||
assert!(json.contains("update_available"));
|
||||
assert!(json.contains("stable"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -731,6 +731,89 @@ async fn restart_container(name: &str, state: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// ElectrumX/electrs on-disk data dir. Wiped to force a clean resync when its
|
||||
/// LevelDB is detected corrupt (see `maybe_recover_corrupt_electrumx`).
|
||||
const ELECTRUMX_DATA_DIR: &str = "/var/lib/archipelago/electrumx";
|
||||
/// Restart attempt at which we check for — and recover from — a corrupt
|
||||
/// ElectrumX database. Late enough that a transient restart won't trigger a
|
||||
/// destructive resync, early enough to self-heal before MAX_RESTART_ATTEMPTS.
|
||||
const ELECTRUMX_DB_RESET_ATTEMPT: u32 = 3;
|
||||
|
||||
fn is_electrumx(name: &str) -> bool {
|
||||
let id = name.strip_prefix("archy-").unwrap_or(name);
|
||||
matches!(id, "electrumx" | "electrs" | "mempool-electrs")
|
||||
}
|
||||
|
||||
/// True when a container's logs show the specific corrupt-LevelDB signature.
|
||||
/// ElectrumX exit-loops with a plyvel error when its `hist`/`utxo` LevelDB
|
||||
/// loses its CURRENT/MANIFEST pointer — typically after an unclean SIGKILL
|
||||
/// (e.g. the cgroup cascade on a service restart). We match the exact failure
|
||||
/// so a normal restart never triggers the destructive resync below.
|
||||
fn looks_like_corrupt_electrumx_db(logs: &str) -> bool {
|
||||
logs.contains("create_if_missing is false")
|
||||
|| logs.contains("Corruption:")
|
||||
|| (logs.contains("plyvel") && logs.contains("does not exist"))
|
||||
}
|
||||
|
||||
async fn electrumx_db_corrupt(name: &str) -> bool {
|
||||
let out = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["logs", "--tail", "60", name])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
match out {
|
||||
Ok(Ok(output)) => {
|
||||
let logs = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
looks_like_corrupt_electrumx_db(&logs)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wipe the ElectrumX LevelDB stores so the next start resyncs from scratch.
|
||||
/// Files are owned by the container's mapped UID, so removal needs host sudo.
|
||||
/// The mount point itself is preserved (the container expects it to exist).
|
||||
/// Returns true if the reset succeeded.
|
||||
async fn reset_electrumx_data() -> bool {
|
||||
let rm = format!(
|
||||
"rm -rf {dir}/hist {dir}/utxo {dir}/meta {dir}/COIN",
|
||||
dir = ELECTRUMX_DATA_DIR,
|
||||
);
|
||||
matches!(
|
||||
crate::update::host_sudo(&["sh", "-c", &rm]).await,
|
||||
Ok(status) if status.success()
|
||||
)
|
||||
}
|
||||
|
||||
/// Self-heal a wedged ElectrumX: if it's exit-looping on a corrupt database,
|
||||
/// wipe its data dir once (at `ELECTRUMX_DB_RESET_ATTEMPT`) so the impending
|
||||
/// restart resyncs cleanly. Bounded to electrs containers, gated on the exact
|
||||
/// corruption signature, and fired once per failure streak — when the resync
|
||||
/// stabilises the restart tracker clears, so a future corruption can heal too.
|
||||
async fn maybe_recover_corrupt_electrumx(name: &str, attempt: u32) {
|
||||
if attempt != ELECTRUMX_DB_RESET_ATTEMPT || !is_electrumx(name) {
|
||||
return;
|
||||
}
|
||||
if !electrumx_db_corrupt(name).await {
|
||||
return;
|
||||
}
|
||||
warn!(
|
||||
"ElectrumX {} is exit-looping on a corrupt database — resetting its data dir to force a clean resync",
|
||||
name
|
||||
);
|
||||
if reset_electrumx_data().await {
|
||||
info!("ElectrumX data dir reset; a fresh resync will begin on restart");
|
||||
} else {
|
||||
warn!("Failed to reset ElectrumX data dir (host sudo rm failed) — manual recovery may be needed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the health monitor background task.
|
||||
pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
@@ -993,6 +1076,10 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
.unwrap_or(&90)
|
||||
);
|
||||
|
||||
// Before restarting, self-heal a corrupt ElectrumX DB so
|
||||
// the restart resyncs cleanly instead of crash-looping.
|
||||
maybe_recover_corrupt_electrumx(&container.name, attempt).await;
|
||||
|
||||
let restarted = restart_container(&container.name, &container.state).await;
|
||||
|
||||
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
|
||||
@@ -1471,4 +1558,36 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_electrumx_matches_electrs_variants_only() {
|
||||
assert!(is_electrumx("electrumx"));
|
||||
assert!(is_electrumx("archy-electrumx"));
|
||||
assert!(is_electrumx("electrs"));
|
||||
assert!(is_electrumx("mempool-electrs"));
|
||||
assert!(!is_electrumx("bitcoin-knots"));
|
||||
assert!(!is_electrumx("lnd"));
|
||||
assert!(!is_electrumx("electrs-ui")); // the web UI, not the indexer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_db_detection_matches_plyvel_signature() {
|
||||
// The exact line ElectrumX exit-loops on (observed on .116).
|
||||
let corrupt = "plyvel._plyvel.Error: b'Invalid argument: hist: does not exist (create_if_missing is false)'";
|
||||
assert!(looks_like_corrupt_electrumx_db(corrupt));
|
||||
assert!(looks_like_corrupt_electrumx_db(
|
||||
"Corruption: bad block in hist"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_db_detection_ignores_healthy_logs() {
|
||||
let healthy = "INFO:BlockProcessor:our height: 117,009 daemon: 953,480 UTXOs 28MB\n\
|
||||
INFO:SessionManager:RPC server listening on 0.0.0.0:8000";
|
||||
assert!(!looks_like_corrupt_electrumx_db(healthy));
|
||||
// "catching up" / normal restart noise must not trigger a destructive wipe.
|
||||
assert!(!looks_like_corrupt_electrumx_db(
|
||||
"Prefetcher:catching up to daemon height 953,480"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +271,11 @@ async fn main() -> Result<()> {
|
||||
// delays server readiness; best-effort, warnings only.
|
||||
tokio::spawn(bootstrap::ensure_doctor_installed());
|
||||
|
||||
// B17: heal already-deployed nodes whose archipelago.service lacks a mount
|
||||
// dependency on the data volume, so cold boots stop flapping. Boot-ordering
|
||||
// only — effective next reboot; never restarts the running service.
|
||||
tokio::spawn(bootstrap::ensure_archipelago_mount_ordering());
|
||||
|
||||
// Spawn periodic container snapshot (for crash recovery)
|
||||
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
|
||||
|
||||
|
||||
@@ -99,7 +99,17 @@ pub(crate) async fn seed_federation_peers_into_mesh(
|
||||
Ok(n) => n,
|
||||
Err(_) => return,
|
||||
};
|
||||
// Skip nodes whose onion we've already seeded: the same physical node can
|
||||
// linger in the federation list under two dids (see B1/B2). Seeding both
|
||||
// would create two chat contacts for one node — one by name+logo and one
|
||||
// by raw did. One onion → one mesh contact.
|
||||
let mut seen_onions = std::collections::HashSet::new();
|
||||
for node in nodes {
|
||||
let onion_key = node.onion.trim_end_matches(".onion").to_string();
|
||||
if !onion_key.is_empty() && !seen_onions.insert(onion_key) {
|
||||
tracing::debug!(did = %node.did, onion = %node.onion, "skipping duplicate federation node (onion already seeded)");
|
||||
continue;
|
||||
}
|
||||
upsert_federation_peer(state, &node.pubkey, &node.did, node.name.as_deref()).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ const RESERVED_PORTS: &[u16] = &[
|
||||
8888, // SearXNG
|
||||
8096, 2342, 2283, // Jellyfin, Photoprism, Immich
|
||||
8443, // FIPS TCP fallback
|
||||
8336, // FIPS UI (fips-ui)
|
||||
];
|
||||
|
||||
/// Start of range for allocating web app ports when preferred is taken.
|
||||
|
||||
@@ -769,6 +769,13 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|
||||
| "/archipelago/mesh-typed"
|
||||
| "/dwn"
|
||||
| "/transport/inbox"
|
||||
// Content *catalog* — the peer-browse entry point. This is the
|
||||
// exact path `/content` (no trailing slash); the prefix match
|
||||
// below only covers `/content/<id>` item fetches, so without
|
||||
// this the catalog 404s over the mesh and `content.browse-peer`
|
||||
// fails with "Peer returned error: 404 Not Found" (and never
|
||||
// falls back to Tor, since a 404 is a successful HTTP exchange).
|
||||
| "/content"
|
||||
)
|
||||
// Prefix-matched content endpoints (peer file browse + fetch)
|
||||
|| path.starts_with("/content/")
|
||||
@@ -1378,6 +1385,25 @@ mod merge_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_path_filter_allows_content_catalog_and_items() {
|
||||
// Regression: the content *catalog* is exactly "/content" (no trailing
|
||||
// slash). It must be reachable over the peer (FIPS) listener, else
|
||||
// `content.browse-peer` 404s over the mesh. Item fetches are
|
||||
// "/content/<id>".
|
||||
assert!(is_peer_allowed_path("/content"), "catalog must be allowed");
|
||||
assert!(
|
||||
is_peer_allowed_path("/content/abc123"),
|
||||
"items must be allowed"
|
||||
);
|
||||
assert!(is_peer_allowed_path("/rpc/v1"));
|
||||
assert!(is_peer_allowed_path("/health"));
|
||||
// Not on the allow-list → rejected (no broad surface over the mesh).
|
||||
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
|
||||
assert!(!is_peer_allowed_path("/"));
|
||||
assert!(!is_peer_allowed_path("/rpc/v2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_transitional_state_on_merge() {
|
||||
// existing: user initiated a stop, spawn_transitional set Stopping.
|
||||
|
||||
@@ -66,10 +66,6 @@ fn is_newer(candidate: &str, current: &str) -> bool {
|
||||
|
||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
/// Secondary mirror on tx1138 gitea — independent network path so a
|
||||
/// single-provider outage doesn't knock out both mirrors.
|
||||
const DEFAULT_SECONDARY_MIRROR_URL: &str =
|
||||
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
const UPDATE_STATE_FILE: &str = "update_state.json";
|
||||
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
|
||||
/// Marker written by apply_update() just before the service restart and
|
||||
@@ -107,16 +103,10 @@ fn mirrors_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
fn default_mirrors() -> Vec<UpdateMirror> {
|
||||
vec![
|
||||
UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Server 1 (OVH)".to_string(),
|
||||
},
|
||||
UpdateMirror {
|
||||
url: DEFAULT_SECONDARY_MIRROR_URL.to_string(),
|
||||
label: "Server 2 (tx1138)".to_string(),
|
||||
},
|
||||
]
|
||||
vec![UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Server 1 (OVH)".to_string(),
|
||||
}]
|
||||
}
|
||||
|
||||
/// Load the operator-configured mirror list. Returns defaults if the
|
||||
@@ -144,14 +134,17 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
|
||||
return Ok(default_mirrors());
|
||||
}
|
||||
|
||||
// One-time migration: the Hetzner VPS at 23.182.128.160 was
|
||||
// decommissioned 2026-04-23. Existing nodes have it baked into their
|
||||
// saved mirror list (was the original Server 1). Strip it on load so
|
||||
// we don't spend seconds per install timing out against a dead host.
|
||||
// Exception to the usual "explicit removals stick" rule: the user
|
||||
// never chose to add this — it was a default.
|
||||
// One-time migrations: drop decommissioned release servers that may be
|
||||
// baked into existing nodes' saved mirror lists. Strip them on load so
|
||||
// we don't spend seconds per install timing out against a dead/stale host.
|
||||
// - 23.182.128.160: Hetzner VPS, decommissioned 2026-04-23.
|
||||
// - git.tx1138.com: retired as a release server 2026-06-13 — its main
|
||||
// branch had diverged and stopped receiving releases, so it only
|
||||
// ever served a stale manifest as the secondary mirror.
|
||||
// Exception to the usual "explicit removals stick" rule: the user never
|
||||
// chose to add these — they were defaults.
|
||||
let before = list.len();
|
||||
list.retain(|m| !m.url.contains("23.182.128.160"));
|
||||
list.retain(|m| !m.url.contains("23.182.128.160") && !m.url.contains("git.tx1138.com"));
|
||||
let mut changed = list.len() != before;
|
||||
|
||||
// Merge in any default URLs the saved config is missing.
|
||||
@@ -182,17 +175,13 @@ fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
|
||||
for mirror in list.iter_mut() {
|
||||
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
mirror.label = "Server 1 (OVH)".to_string();
|
||||
} else if mirror.url == DEFAULT_SECONDARY_MIRROR_URL {
|
||||
mirror.label = "Server 2 (tx1138)".to_string();
|
||||
}
|
||||
}
|
||||
list.sort_by_key(|m| {
|
||||
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
0
|
||||
} else if m.url == DEFAULT_SECONDARY_MIRROR_URL {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
1
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -367,12 +356,20 @@ async fn probe_frontend_once() -> Result<()> {
|
||||
.context("build probe client")?;
|
||||
// Prefer HTTPS since that's the failure mode we're catching (nginx
|
||||
// 500 on the PWA). HTTP usually redirects to HTTPS and would mask
|
||||
// the bug.
|
||||
let resp = client
|
||||
.get("https://127.0.0.1/")
|
||||
.send()
|
||||
.await
|
||||
.context("probe GET https://127.0.0.1/")?;
|
||||
// the bug. BUT not every node binds 443 on loopback (.116 serves
|
||||
// plain HTTP; 443 there belongs to tailscale) — on a *connect*
|
||||
// error, fall back to HTTP so a healthy node isn't "verified" into
|
||||
// a rollback. An HTTP error status stays fatal on whichever scheme
|
||||
// answered.
|
||||
let resp = match client.get("https://127.0.0.1/").send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) if e.is_connect() => client
|
||||
.get("http://127.0.0.1/")
|
||||
.send()
|
||||
.await
|
||||
.context("probe GET http://127.0.0.1/ (https not bound on loopback)")?,
|
||||
Err(e) => return Err(e).context("probe GET https://127.0.0.1/"),
|
||||
};
|
||||
let status = resp.status();
|
||||
if status.is_success() || status.is_redirection() {
|
||||
return Ok(());
|
||||
@@ -755,10 +752,15 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
.context("Failed to create staging dir")?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
// Per-request budget; each attempt gets the full hour. A retry
|
||||
// restarts the budget cleanly.
|
||||
.timeout(std::time::Duration::from_secs(3600))
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
// Per-request budget; each attempt gets the full window, and a retry
|
||||
// resumes via Range from the partial file (download_component_resumable),
|
||||
// so this is an upper bound per attempt, not the whole download. Sized
|
||||
// generously for slow machines on slow links: a ~200MB release at a
|
||||
// crawling ~50KB/s is ~70min, which the old 1h budget could cut off
|
||||
// mid-attempt. 3h leaves ample headroom; raising it cannot slow down or
|
||||
// break a fast download (those finish well inside the old limit).
|
||||
.timeout(std::time::Duration::from_secs(10800))
|
||||
.connect_timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
@@ -1078,6 +1080,17 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
let current_binary = Path::new("/usr/local/bin/archipelago");
|
||||
if current_binary.exists() {
|
||||
let backup_path = backup_dir.join("archipelago");
|
||||
// A leftover backup from an earlier rollback can be root-owned
|
||||
// (rollback used to chown it in place), and fs::copy O_TRUNCs the
|
||||
// existing file — EACCES as the service user, wedging every apply
|
||||
// (seen on .116, v1.7.86 OTA). Unlink first; the dir is
|
||||
// service-owned so unlink works even when the file isn't ours.
|
||||
if backup_path.exists() {
|
||||
if let Err(e) = fs::remove_file(&backup_path).await {
|
||||
tracing::warn!(error = %e, "unlink of stale binary backup failed, retrying via host_sudo");
|
||||
let _ = host_sudo(&["rm", "-f", &backup_path.to_string_lossy()]).await;
|
||||
}
|
||||
}
|
||||
fs::copy(current_binary, &backup_path)
|
||||
.await
|
||||
.context("Failed to backup current binary")?;
|
||||
@@ -1415,21 +1428,38 @@ pub async fn rollback_update(data_dir: &Path) -> Result<()> {
|
||||
|
||||
let backup_binary = backup_dir.join("archipelago");
|
||||
if backup_binary.exists() {
|
||||
// Use host_sudo + mv so we escape the archipelago service's
|
||||
// ProtectSystem=strict mount namespace. A plain fs::copy or
|
||||
// `sudo cp` from inside the service hits EROFS on /usr/local/bin,
|
||||
// which would silently orphan the rollback — exactly the
|
||||
// opposite of what auto-rollback is for. Pattern matches
|
||||
// apply_update()'s binary swap above.
|
||||
// Same two namespace gotchas as apply_update()'s binary swap:
|
||||
// `cp` straight onto the running binary is O_TRUNC and fails
|
||||
// ETXTBSY (exit 1 — exactly what broke the .116 rollback), and
|
||||
// plain sudo inherits ProtectSystem=strict, so everything goes
|
||||
// through host_sudo. Copy to a temp name on the same filesystem,
|
||||
// fix ownership on the TEMP file (never the stored backup — an
|
||||
// in-place chown is what later wedged apply_update), then mv,
|
||||
// which is an atomic rename and tolerates a busy destination.
|
||||
let backup_str = backup_binary.to_string_lossy().to_string();
|
||||
let _ = host_sudo(&["chmod", "0755", &backup_str]).await;
|
||||
let _ = host_sudo(&["chown", "root:root", &backup_str]).await;
|
||||
let status = host_sudo(&["cp", &backup_str, "/usr/local/bin/archipelago"])
|
||||
let tmp = format!(
|
||||
"/usr/local/bin/.archipelago.rollback.{}",
|
||||
chrono::Utc::now().timestamp_millis()
|
||||
);
|
||||
let copy = host_sudo(&["cp", &backup_str, &tmp])
|
||||
.await
|
||||
.context("Failed to stage backup binary via host_sudo")?;
|
||||
if !copy.success() {
|
||||
anyhow::bail!(
|
||||
"cp backup binary to {} failed (exit {:?})",
|
||||
tmp,
|
||||
copy.code()
|
||||
);
|
||||
}
|
||||
let _ = host_sudo(&["chmod", "0755", &tmp]).await;
|
||||
let _ = host_sudo(&["chown", "root:root", &tmp]).await;
|
||||
let status = host_sudo(&["mv", &tmp, "/usr/local/bin/archipelago"])
|
||||
.await
|
||||
.context("Failed to restore backup binary via host_sudo")?;
|
||||
if !status.success() {
|
||||
let _ = host_sudo(&["rm", "-f", &tmp]).await;
|
||||
anyhow::bail!(
|
||||
"cp backup binary into /usr/local/bin failed (exit {:?})",
|
||||
"mv backup binary into /usr/local/bin failed (exit {:?})",
|
||||
status.code()
|
||||
);
|
||||
}
|
||||
@@ -1647,9 +1677,38 @@ mod tests {
|
||||
async fn test_load_mirrors_returns_defaults_when_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = load_mirrors(dir.path()).await.unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list.len(), 1);
|
||||
assert!(list[0].url.contains("146.59.87.168"));
|
||||
assert!(list[1].url.contains("git.tx1138.com"));
|
||||
assert!(
|
||||
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
|
||||
"tx1138 was retired as a release server and must not be a default mirror"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_mirrors_strips_retired_tx1138_mirror() {
|
||||
// A node that was running before tx1138 was retired has it baked
|
||||
// into its saved mirror list. load_mirrors must strip it on load.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let saved = vec![
|
||||
UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Server 1 (OVH)".to_string(),
|
||||
},
|
||||
UpdateMirror {
|
||||
url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||
.to_string(),
|
||||
label: "Server 2 (tx1138)".to_string(),
|
||||
},
|
||||
];
|
||||
save_mirrors(dir.path(), &saved).await.unwrap();
|
||||
let list = load_mirrors(dir.path()).await.unwrap();
|
||||
assert!(
|
||||
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
|
||||
"retired tx1138 mirror should be stripped on load; got {:?}",
|
||||
list
|
||||
);
|
||||
assert!(list.iter().any(|m| m.url.contains("146.59.87.168")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1663,7 +1722,7 @@ mod tests {
|
||||
let back = load_mirrors(dir.path()).await.unwrap();
|
||||
// load_mirrors merges in any missing default mirrors so a node
|
||||
// that explicitly added a single custom mirror still gets the
|
||||
// built-in OVH + tx1138 fallbacks. The custom mirror is preserved.
|
||||
// built-in OVH default. The custom mirror is preserved.
|
||||
assert!(
|
||||
back.iter().any(|m| m.url == "https://example.com/m.json"),
|
||||
"custom mirror should round-trip; got {:?}",
|
||||
|
||||
@@ -858,6 +858,11 @@ pub struct HostFacts {
|
||||
/// `/` if the data partition is not yet mounted). Drives the
|
||||
/// prune-vs-full-node decision in bitcoin-knots custom_args.
|
||||
pub disk_gb: u64,
|
||||
/// Container name of the running Bitcoin node — `bitcoin-knots` or
|
||||
/// `bitcoin-core` — so dependents (mempool's CORE_RPC_HOST) reach the
|
||||
/// right host. Both are reachable on archy-net by their container name;
|
||||
/// only the name differs. Falls back to `bitcoin-knots` when undetected.
|
||||
pub bitcoin_host: String,
|
||||
}
|
||||
|
||||
impl HostFacts {
|
||||
@@ -868,13 +873,14 @@ impl HostFacts {
|
||||
host_ip: "192.168.1.116".to_string(),
|
||||
host_mdns: "archi-thinkpad.local".to_string(),
|
||||
disk_gb: 2000,
|
||||
bitcoin_host: "bitcoin-knots".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Supported placeholder names in `DerivedEnv::template`. Keep in sync
|
||||
/// with `HostFacts`. Centralized so validation and rendering agree.
|
||||
const DERIVED_PLACEHOLDERS: &[&str] = &["HOST_IP", "HOST_MDNS", "DISK_GB"];
|
||||
const DERIVED_PLACEHOLDERS: &[&str] = &["HOST_IP", "HOST_MDNS", "DISK_GB", "BITCOIN_HOST"];
|
||||
|
||||
fn validate_derived_template(key: &str, template: &str) -> Result<(), ManifestError> {
|
||||
// Walk `{{NAME}}` occurrences and ensure each NAME is recognized.
|
||||
@@ -957,7 +963,8 @@ impl ContainerConfig {
|
||||
.template
|
||||
.replace("{{HOST_IP}}", &facts.host_ip)
|
||||
.replace("{{HOST_MDNS}}", &facts.host_mdns)
|
||||
.replace("{{DISK_GB}}", &facts.disk_gb.to_string());
|
||||
.replace("{{DISK_GB}}", &facts.disk_gb.to_string())
|
||||
.replace("{{BITCOIN_HOST}}", &facts.bitcoin_host);
|
||||
format!("{}={}", e.key, value)
|
||||
})
|
||||
.collect()
|
||||
@@ -1463,6 +1470,10 @@ app:
|
||||
key: "INFO".to_string(),
|
||||
template: "{{HOST_IP}}-{{DISK_GB}}".to_string(),
|
||||
},
|
||||
DerivedEnv {
|
||||
key: "CORE_RPC_HOST".to_string(),
|
||||
template: "{{BITCOIN_HOST}}".to_string(),
|
||||
},
|
||||
],
|
||||
secret_env: vec![],
|
||||
data_uid: None,
|
||||
@@ -1471,11 +1482,13 @@ app:
|
||||
host_ip: "192.168.1.116".to_string(),
|
||||
host_mdns: "archi-thinkpad.local".to_string(),
|
||||
disk_gb: 2000,
|
||||
bitcoin_host: "bitcoin-core".to_string(),
|
||||
};
|
||||
|
||||
let out = c.resolve_derived_env(&facts);
|
||||
assert_eq!(out[0], "FM_API_URL=ws://archi-thinkpad.local:8174");
|
||||
assert_eq!(out[1], "INFO=192.168.1.116-2000");
|
||||
assert_eq!(out[2], "CORE_RPC_HOST=bitcoin-core");
|
||||
}
|
||||
|
||||
struct MapSecretsProvider {
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 987 KiB |
|
Before Width: | Height: | Size: 976 KiB After Width: | Height: | Size: 869 KiB |
|
Before Width: | Height: | Size: 976 KiB After Width: | Height: | Size: 869 KiB |
@@ -0,0 +1,19 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
# Static site content.
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
#
|
||||
# FIPS UI talks only to the archipelago RPC on 127.0.0.1:5678, using the
|
||||
# browser's own archipelago session — there is NO per-node secret to
|
||||
# substitute, so (unlike bitcoin-ui) the nginx config is baked straight
|
||||
# into the image rather than bind-mounted/rendered at container-create.
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
#
|
||||
# Run nginx as root to avoid chown failures in rootless Podman user
|
||||
# namespaces. The rest of the nginx image is unchanged.
|
||||
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 8336
|
||||
ENTRYPOINT []
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,478 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FIPS Mesh</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0e1116;
|
||||
--panel: #161b22;
|
||||
--panel-2: #1c232c;
|
||||
--border: #2a323d;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--accent: #2f81f7;
|
||||
--ok: #2ea043;
|
||||
--warn: #d29922;
|
||||
--bad: #f85149;
|
||||
--radius: 10px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
.wrap { max-width: 860px; margin: 0 auto; padding: 24px 18px 64px; }
|
||||
header { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
||||
header h1 { font-size: 20px; margin: 0; font-weight: 600; }
|
||||
.sub { color: var(--muted); margin: 0 0 20px; font-size: 13px; }
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 13px; text-transform: uppercase; letter-spacing: .04em;
|
||||
color: var(--muted); margin: 0 0 14px; font-weight: 600;
|
||||
}
|
||||
.row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; gap: 12px; }
|
||||
.row + .row { border-top: 1px solid var(--border); }
|
||||
.row .k { color: var(--muted); }
|
||||
.row .v { font-variant-numeric: tabular-nums; text-align: right; word-break: break-all; }
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.pill::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||
.pill.ok { color: var(--ok); background: rgba(46,160,67,.12); }
|
||||
.pill.warn { color: var(--warn); background: rgba(210,153,34,.12); }
|
||||
.pill.bad { color: var(--bad); background: rgba(248,81,73,.12); }
|
||||
.pill.muted { color: var(--muted); background: rgba(139,148,158,.12); }
|
||||
.btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
|
||||
button {
|
||||
font: inherit; font-weight: 600; cursor: pointer;
|
||||
background: var(--panel-2); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 8px 14px;
|
||||
}
|
||||
button:hover:not(:disabled) { border-color: var(--accent); }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
button.danger { color: var(--bad); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
input, select {
|
||||
font: inherit; background: var(--bg); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; width: 100%;
|
||||
}
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.grid .full { grid-column: 1 / -1; }
|
||||
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||
.anchor-item { padding: 10px 0; }
|
||||
.anchor-item + .anchor-item { border-top: 1px solid var(--border); }
|
||||
.anchor-item .top { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; }
|
||||
.anchor-item .addr { color: var(--muted); font-size: 12px; word-break: break-all; }
|
||||
.anchor-item .npub { font-size: 12px; color: var(--muted); word-break: break-all; }
|
||||
.notice { padding: 10px 12px; border-radius: 8px; font-size: 13px; margin-top: 10px; display: none; }
|
||||
.notice.show { display: block; }
|
||||
.notice.info { background: rgba(47,129,247,.12); color: var(--accent); }
|
||||
.notice.good { background: rgba(46,160,67,.12); color: var(--ok); }
|
||||
.notice.error { background: rgba(248,81,73,.12); color: var(--bad); }
|
||||
.spin { display: inline-block; width: 13px; height: 13px; border: 2px solid currentColor;
|
||||
border-right-color: transparent; border-radius: 50%; animation: r .7s linear infinite; vertical-align: -2px; }
|
||||
@keyframes r { to { transform: rotate(360deg); } }
|
||||
.muted-note { color: var(--muted); font-size: 12px; margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1>FIPS Mesh</h1>
|
||||
<span id="anchorPill" class="pill muted">Loading…</span>
|
||||
</header>
|
||||
<p class="sub">Encrypted mesh transport. This node reaches the network through seed anchors; a connected anchor keeps FIPS routing fast instead of degrading to Tor.</p>
|
||||
|
||||
<div class="card">
|
||||
<h2>Status</h2>
|
||||
<div class="row"><span class="k">Daemon installed</span><span class="v" id="sInstalled">—</span></div>
|
||||
<div class="row"><span class="k">Version</span><span class="v" id="sVersion">—</span></div>
|
||||
<div class="row"><span class="k">Service</span><span class="v" id="sService">—</span></div>
|
||||
<div class="row"><span class="k">Seed key present</span><span class="v" id="sKey">—</span></div>
|
||||
<div class="row"><span class="k">Authenticated peers</span><span class="v" id="sPeers">—</span></div>
|
||||
<div class="row"><span class="k">Anchor connected</span><span class="v" id="sAnchor">—</span></div>
|
||||
<div class="row"><span class="k">This node's npub</span><span class="v" id="sNpub">—</span></div>
|
||||
<div class="btns">
|
||||
<button id="btnRefresh">Refresh</button>
|
||||
<button id="btnReconnect">Reconnect</button>
|
||||
<button id="btnRestart">Restart daemon</button>
|
||||
<button id="btnInstall">Install / repair</button>
|
||||
</div>
|
||||
<div id="actionNotice" class="notice"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>This node as an anchor</h2>
|
||||
<p class="muted-note" style="margin-top:0">Share these with another node's operator so they can add this node as a seed anchor (their <em>Seed Anchors → Add</em> form). The address is whatever host you reached this dashboard at, so it's reachable the same way you got here.</p>
|
||||
<div class="row">
|
||||
<span class="k">npub</span>
|
||||
<span class="v" style="display:flex;gap:8px;align-items:center">
|
||||
<code id="ownNpub" style="font-size:12px">—</code>
|
||||
<button data-copy="ownNpub">Copy</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="k">Address</span>
|
||||
<span class="v" style="display:flex;gap:8px;align-items:center">
|
||||
<code id="ownAddr" style="font-size:12px">—</code>
|
||||
<button data-copy="ownAddr">Copy</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="ownReach" class="muted-note"></div>
|
||||
<div id="copyNotice" class="notice"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Updates · stable channel</h2>
|
||||
<div class="row"><span class="k">Installed</span><span class="v" id="uCurrent">—</span></div>
|
||||
<div class="row"><span class="k">Latest stable</span><span class="v" id="uLatest">—</span></div>
|
||||
<div class="row"><span class="k">Status</span><span class="v" id="uStatus">—</span></div>
|
||||
<div class="btns">
|
||||
<button id="btnCheck">Check for updates</button>
|
||||
<button id="btnApply" class="primary" disabled>Apply update</button>
|
||||
</div>
|
||||
<div id="updateNotice" class="notice"></div>
|
||||
<p class="muted-note">Updates download the signed <code>.deb</code> from the upstream <code>jmcorgan/fips</code> releases, verify its SHA-256 against the published checksums, install it, and restart the daemon.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Seed Anchors</h2>
|
||||
<div id="anchorList"><p class="muted-note">Loading…</p></div>
|
||||
<div style="margin-top:14px">
|
||||
<div class="grid">
|
||||
<div class="full"><label>npub</label><input id="aNpub" placeholder="npub1…" autocomplete="off" spellcheck="false" /></div>
|
||||
<div><label>Address (host:port)</label><input id="aAddr" placeholder="192.168.1.116:8668" autocomplete="off" spellcheck="false" /></div>
|
||||
<div><label>Transport</label><select id="aTransport"><option value="udp">udp</option><option value="tcp">tcp</option></select></div>
|
||||
<div class="full"><label>Label (optional)</label><input id="aLabel" placeholder="Home anchor" autocomplete="off" /></div>
|
||||
</div>
|
||||
<div class="btns">
|
||||
<button id="btnAddAnchor" class="primary">Add anchor</button>
|
||||
<button id="btnApplyAnchors">Re-apply all</button>
|
||||
</div>
|
||||
<div id="anchorNotice" class="notice"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const RPC_ENDPOINT = '/rpc/v1';
|
||||
|
||||
function cookieValue(name) {
|
||||
return document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith(`${name}=`))
|
||||
?.split('=').slice(1).join('=') || '';
|
||||
}
|
||||
|
||||
async function rpc(method, params = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const csrf = cookieValue('csrf_token');
|
||||
if (csrf) headers['X-CSRF-Token'] = decodeURIComponent(csrf);
|
||||
const res = await fetch(RPC_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok || body.error) {
|
||||
throw new Error(body.error?.message || `RPC ${method} failed (${res.status})`);
|
||||
}
|
||||
return body.result;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
}
|
||||
function setText(id, v, fallback = '—') {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = (v === null || v === undefined || v === '') ? fallback : v;
|
||||
}
|
||||
function pill(state, text) {
|
||||
return `<span class="pill ${state}">${escapeHtml(text)}</span>`;
|
||||
}
|
||||
function setHtml(id, html) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.innerHTML = html;
|
||||
}
|
||||
function notice(id, kind, msg) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
if (!msg) { el.className = 'notice'; el.textContent = ''; return; }
|
||||
el.className = `notice show ${kind}`;
|
||||
el.innerHTML = msg;
|
||||
}
|
||||
function busy(btn, on, label) {
|
||||
if (!btn) return;
|
||||
if (on) {
|
||||
btn.dataset.label = btn.dataset.label || btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<span class="spin"></span> ${escapeHtml(label || btn.dataset.label)}`;
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.label || btn.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatus(s) {
|
||||
setText('sInstalled', s.installed ? 'Yes' : 'No');
|
||||
setText('sVersion', s.version);
|
||||
const active = s.service_active;
|
||||
setHtml('sService', active
|
||||
? pill('ok', s.service_state || 'active')
|
||||
: pill('bad', s.service_state || 'inactive'));
|
||||
setHtml('sKey', s.key_present ? pill('ok', 'present') : pill('warn', 'missing'));
|
||||
const peers = s.authenticated_peer_count || 0;
|
||||
setHtml('sPeers', peers > 0 ? pill('ok', String(peers)) : pill('warn', '0 — isolated'));
|
||||
setHtml('sAnchor', s.anchor_connected ? pill('ok', 'connected') : pill('bad', 'unreachable'));
|
||||
setText('sNpub', s.npub);
|
||||
if (s.npub) document.getElementById('ownNpub').textContent = s.npub;
|
||||
|
||||
const ap = document.getElementById('anchorPill');
|
||||
if (s.anchor_connected) { ap.className = 'pill ok'; ap.textContent = 'Anchor connected'; }
|
||||
else if (peers > 0) { ap.className = 'pill warn'; ap.textContent = 'Peers, no anchor'; }
|
||||
else if (s.service_active) { ap.className = 'pill bad'; ap.textContent = 'Isolated'; }
|
||||
else { ap.className = 'pill muted'; ap.textContent = 'Daemon down'; }
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const s = await rpc('fips.status');
|
||||
renderStatus(s);
|
||||
setText('uCurrent', s.version, 'not installed');
|
||||
} catch (e) {
|
||||
notice('actionNotice', 'error', escapeHtml(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
function renderAnchors(list) {
|
||||
if (!list || list.length === 0) {
|
||||
setHtml('anchorList', '<p class="muted-note">No seed anchors configured. Add one below so this node can reach the mesh.</p>');
|
||||
return;
|
||||
}
|
||||
const html = list.map(a => `
|
||||
<div class="anchor-item">
|
||||
<div class="top">
|
||||
<strong>${escapeHtml(a.label || a.address)}</strong>
|
||||
<button class="danger" data-remove="${escapeHtml(a.npub)}">Remove</button>
|
||||
</div>
|
||||
<div class="addr">${escapeHtml(a.address)} · ${escapeHtml(a.transport || 'udp')}</div>
|
||||
<div class="npub">${escapeHtml(a.npub)}</div>
|
||||
</div>`).join('');
|
||||
setHtml('anchorList', html);
|
||||
document.querySelectorAll('[data-remove]').forEach(btn => {
|
||||
btn.addEventListener('click', () => removeAnchor(btn.dataset.remove, btn));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAnchors() {
|
||||
try {
|
||||
const r = await rpc('fips.list-seed-anchors');
|
||||
renderAnchors(r.seed_anchors);
|
||||
} catch (e) {
|
||||
setHtml('anchorList', `<p class="muted-note">${escapeHtml(e.message)}</p>`);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAnchor(npub, btn) {
|
||||
busy(btn, true, 'Removing');
|
||||
notice('anchorNotice', '', '');
|
||||
try {
|
||||
const r = await rpc('fips.remove-seed-anchor', { npub });
|
||||
renderAnchors(r.seed_anchors);
|
||||
notice('anchorNotice', 'good', 'Anchor removed.');
|
||||
} catch (e) {
|
||||
notice('anchorNotice', 'error', escapeHtml(e.message));
|
||||
busy(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- wire up buttons ---
|
||||
document.getElementById('btnRefresh').addEventListener('click', loadStatus);
|
||||
|
||||
document.getElementById('btnReconnect').addEventListener('click', async (e) => {
|
||||
const btn = e.currentTarget;
|
||||
busy(btn, true, 'Reconnecting…');
|
||||
notice('actionNotice', 'info', 'Restarting the daemon and waiting for an anchor — this takes about 20 seconds…');
|
||||
try {
|
||||
const r = await rpc('fips.reconnect');
|
||||
renderStatus(r.after);
|
||||
const kind = r.after.anchor_connected ? 'good' : 'error';
|
||||
notice('actionNotice', kind, `${escapeHtml(r.hint || r.likely_cause)}`);
|
||||
} catch (err) {
|
||||
notice('actionNotice', 'error', escapeHtml(err.message));
|
||||
} finally {
|
||||
busy(btn, false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btnRestart').addEventListener('click', async (e) => {
|
||||
const btn = e.currentTarget;
|
||||
busy(btn, true, 'Restarting…');
|
||||
notice('actionNotice', '', '');
|
||||
try {
|
||||
const r = await rpc('fips.restart');
|
||||
notice('actionNotice', 'good', `Restarted ${escapeHtml(r.unit || 'fips service')}.`);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
notice('actionNotice', 'error', escapeHtml(err.message));
|
||||
} finally {
|
||||
busy(btn, false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btnInstall').addEventListener('click', async (e) => {
|
||||
const btn = e.currentTarget;
|
||||
busy(btn, true, 'Installing…');
|
||||
notice('actionNotice', '', '');
|
||||
try {
|
||||
const s = await rpc('fips.install');
|
||||
renderStatus(s);
|
||||
notice('actionNotice', 'good', 'Config and key re-materialised; service activated.');
|
||||
} catch (err) {
|
||||
notice('actionNotice', 'error', escapeHtml(err.message));
|
||||
} finally {
|
||||
busy(btn, false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btnCheck').addEventListener('click', async (e) => {
|
||||
const btn = e.currentTarget;
|
||||
busy(btn, true, 'Checking…');
|
||||
notice('updateNotice', '', '');
|
||||
const applyBtn = document.getElementById('btnApply');
|
||||
try {
|
||||
const c = await rpc('fips.check-update');
|
||||
setText('uCurrent', c.current, 'not installed');
|
||||
setText('uLatest', c.latest_version);
|
||||
setHtml('uStatus', c.update_available ? pill('warn', 'update available') : pill('ok', 'up to date'));
|
||||
applyBtn.disabled = !c.update_available;
|
||||
notice('updateNotice', c.update_available ? 'info' : 'good', escapeHtml(c.notes || ''));
|
||||
} catch (err) {
|
||||
notice('updateNotice', 'error', escapeHtml(err.message));
|
||||
} finally {
|
||||
busy(btn, false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btnApply').addEventListener('click', async (e) => {
|
||||
const btn = e.currentTarget;
|
||||
busy(btn, true, 'Updating…');
|
||||
notice('updateNotice', 'info', 'Downloading, verifying, and installing the new FIPS daemon, then restarting it…');
|
||||
try {
|
||||
await rpc('fips.apply-update');
|
||||
notice('updateNotice', 'good', 'Update installed and daemon restarted.');
|
||||
btn.disabled = true;
|
||||
await loadStatus();
|
||||
await rpc('fips.check-update').then(c => {
|
||||
setText('uLatest', c.latest_version);
|
||||
setHtml('uStatus', c.update_available ? pill('warn', 'update available') : pill('ok', 'up to date'));
|
||||
}).catch(() => {});
|
||||
} catch (err) {
|
||||
notice('updateNotice', 'error', escapeHtml(err.message));
|
||||
busy(btn, false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btnAddAnchor').addEventListener('click', async (e) => {
|
||||
const btn = e.currentTarget;
|
||||
const npub = document.getElementById('aNpub').value.trim();
|
||||
const address = document.getElementById('aAddr').value.trim();
|
||||
const transport = document.getElementById('aTransport').value;
|
||||
const label = document.getElementById('aLabel').value.trim();
|
||||
if (!npub.startsWith('npub1')) { notice('anchorNotice', 'error', 'npub must start with npub1…'); return; }
|
||||
if (!address.includes(':')) { notice('anchorNotice', 'error', 'Address must be host:port (e.g. 192.168.1.116:8668).'); return; }
|
||||
busy(btn, true, 'Adding…');
|
||||
notice('anchorNotice', '', '');
|
||||
try {
|
||||
const r = await rpc('fips.add-seed-anchor', { npub, address, transport, label });
|
||||
renderAnchors(r.seed_anchors);
|
||||
const applied = (r.apply || []).find(x => x.npub === npub);
|
||||
const ok = applied ? applied.ok : true;
|
||||
notice('anchorNotice', ok ? 'good' : 'info',
|
||||
ok ? 'Anchor added and pushed to the running daemon.' : `Anchor saved. Daemon push: ${escapeHtml(applied?.message || 'pending')}`);
|
||||
['aNpub','aAddr','aLabel'].forEach(id => document.getElementById(id).value = '');
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
notice('anchorNotice', 'error', escapeHtml(err.message));
|
||||
} finally {
|
||||
busy(btn, false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btnApplyAnchors').addEventListener('click', async (e) => {
|
||||
const btn = e.currentTarget;
|
||||
busy(btn, true, 'Applying…');
|
||||
notice('anchorNotice', '', '');
|
||||
try {
|
||||
const r = await rpc('fips.apply-seed-anchors');
|
||||
const okCount = (r.results || []).filter(x => x.ok).length;
|
||||
notice('anchorNotice', 'good', `Re-applied ${okCount}/${r.applied || 0} anchors to the daemon.`);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
notice('anchorNotice', 'error', escapeHtml(err.message));
|
||||
} finally {
|
||||
busy(btn, false);
|
||||
}
|
||||
});
|
||||
|
||||
// This node's own anchor address: the host the operator reached the
|
||||
// dashboard at is, by definition, reachable for them — so it's the
|
||||
// right dial hint. FIPS listens on UDP 8668.
|
||||
const FIPS_PORT = 8668;
|
||||
function isPrivateLan(host) {
|
||||
return /^10\./.test(host)
|
||||
|| /^192\.168\./.test(host)
|
||||
|| /^172\.(1[6-9]|2\d|3[01])\./.test(host)
|
||||
|| host === 'localhost' || host === '127.0.0.1';
|
||||
}
|
||||
function populateOwnAnchor() {
|
||||
const host = window.location.hostname;
|
||||
const addr = `${host}:${FIPS_PORT}`;
|
||||
document.getElementById('ownAddr').textContent = addr;
|
||||
const reach = document.getElementById('ownReach');
|
||||
if (/^100\./.test(host)) {
|
||||
reach.innerHTML = 'This is a Tailscale address — reachable by any node on your tailnet, including over the internet.';
|
||||
} else if (isPrivateLan(host)) {
|
||||
reach.innerHTML = '⚠ This is a private LAN address — it only works for nodes on the same local network. For a node across the internet, share this node’s Tailscale (100.x) or public IP with UDP 8668 reachable, or have both nodes use a common public anchor instead.';
|
||||
} else {
|
||||
reach.innerHTML = 'This looks like a public address — reachable over the internet if UDP 8668 is open/forwarded to this node.';
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-copy]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const text = document.getElementById(btn.dataset.copy)?.textContent || '';
|
||||
if (!text || text === '—') return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
notice('copyNotice', 'good', 'Copied.');
|
||||
setTimeout(() => notice('copyNotice', '', ''), 1500);
|
||||
} catch {
|
||||
notice('copyNotice', 'error', `Copy failed — select manually: ${escapeHtml(text)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// initial load
|
||||
populateOwnAnchor();
|
||||
loadStatus();
|
||||
loadAnchors();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
server {
|
||||
listen 8336;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Proxy archipelago RPC same-origin so the browser never makes a
|
||||
# cross-origin request (no CORS needed). The FIPS app is served on
|
||||
# this node's :8336; cookies are scoped by host (not port), so the
|
||||
# browser already carries the `session` (HttpOnly) and `csrf_token`
|
||||
# cookies set by the main UI on :80. We forward both, plus the
|
||||
# X-CSRF-Token header the app derives from the readable csrf_token
|
||||
# cookie, to the backend RPC on 127.0.0.1:5678.
|
||||
#
|
||||
# Unlike bitcoin-ui this config is fully static (baked into the
|
||||
# image) — there is no upstream secret to substitute; the browser's
|
||||
# own archipelago session is the credential.
|
||||
location /rpc/v1 {
|
||||
proxy_pass http://127.0.0.1:5678/rpc/v1;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 60s;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 987 KiB |
|
Before Width: | Height: | Size: 570 KiB After Width: | Height: | Size: 510 KiB |
@@ -1,6 +1,153 @@
|
||||
# Migration Status Report
|
||||
|
||||
Last updated: 2026-06-11
|
||||
Last updated: 2026-06-14
|
||||
|
||||
## RESUME CHECKPOINT (2026-06-14, after SSH drop)
|
||||
|
||||
State right now, so any disconnect resumes cleanly:
|
||||
|
||||
- **`main` = `a483fe4b`** = the other agent's 4 fixes (`0ed892a4`: wallet receive / bitcoin
|
||||
install self-heal / ElectrumX tile / extended test gate) + **my F1 fix committed on top**
|
||||
(`launch_url_port` in `docker_packages.rs` + 3 regression tests). Tree is clean (only two
|
||||
untracked `docs/*.md` tracking files remain). Not pushed.
|
||||
- The old isolated `archy-f1` worktree was **removed** — built the combined tree in-place.
|
||||
- ✅ **DONE — combined backend release build** (`cd core && TMPDIR=/home/archipelago/.buildtmp
|
||||
cargo build --release -p archipelago`, 7m46s, exit 0). `/tmp` is a full tmpfs so `TMPDIR`
|
||||
MUST point at `/home/archipelago/.buildtmp`.
|
||||
- ✅ **DONE — sideloaded + restarted on `.116`.** Backed up old binary to
|
||||
`/usr/local/bin/archipelago.pre-f1.bak`, `install`ed new binary (root:root 755),
|
||||
`sudo systemctl restart archipelago` (new MainPID 2885863).
|
||||
- ✅ **F1 VALIDATED LIVE on `.116` (2026-06-14).** See "FINDING F1" below — before/after proves
|
||||
the fix. Harness focused audit `jellyfin,filebrowser` → **all checks passed, exit 0**.
|
||||
- **IMPORTANT — restart is SAFE on this node:** containers run rootless under
|
||||
`user-1000.slice/user@1000.service/app.slice`, a DIFFERENT cgroup from
|
||||
`/system.slice/archipelago.service`. They survived both the 01:47 and this restart
|
||||
(bitcoin/lnd/btcpay/immich/indeedhub all intact, count stayed 36). The
|
||||
`feedback_no_systemctl_deploy_until_quadlet` cgroup-cascade warning does NOT apply to `.116`'s
|
||||
current config. (The reconciler does recreate a few app containers like jellyfin/fedimint on
|
||||
adoption — normal level-triggered behavior, not casualties.)
|
||||
- **RELEASE IN PROGRESS — v1.7.91-alpha (user approved 2026-06-14).** Bundles the other agent's
|
||||
4 fixes (`0ed892a4`) + F1 (`a483fe4b`) + changelog (`ab858271`). Steps:
|
||||
1. ✅ Freed `/tmp` (removed stale published frontend tarballs 1.7.83→1.7.89; ~1.1G free) —
|
||||
`create-release.sh` writes the 184MB frontend tarball to `/tmp` (hardcoded, NOT TMPDIR).
|
||||
2. ✅ `cargo fmt -p archipelago --check` clean; curated layman changelog added + committed.
|
||||
3. 🔄 `TMPDIR=/home/archipelago/.buildtmp scripts/create-release.sh 1.7.91-alpha`
|
||||
(runs `tests/release/run.sh` gate → bumps Cargo.toml/package.json → builds backend+frontend
|
||||
→ manifest → commit "chore: release v1.7.91-alpha" → tag `v1.7.91-alpha`). MUST set TMPDIR
|
||||
or cargo's ring C-build fails on the full `/tmp` tmpfs.
|
||||
- **AFTER create-release.sh:** `scripts/publish-release-assets.sh 1.7.91-alpha gitea-vps2`
|
||||
→ `git push origin main && git push gitea-local main` → `git push --tags` (origin+gitea-local).
|
||||
Ship target per memory: vps2 (146.59.87.168) is PRIMARY OTA manifest; tx1138 RETIRED.
|
||||
- Verify packaged tarball actually contains the new version string before trusting the build
|
||||
(npm run build can silently produce stale dist — see `feedback_frontend_build_verify`).
|
||||
|
||||
## Validation node (ACTIVE)
|
||||
|
||||
As of 2026-06-14 the app-migration lifecycle validation moves from `.198` (remote, OVH) to
|
||||
**`.116` — the local dev node (`archi-thinkpad`, `192.168.1.116`)** because it is the machine
|
||||
this session runs on, so the harness drives it over loopback instead of SSH (much faster, no
|
||||
network latency). A separate agent owns OS-level fixes + its own test harness; this track owns
|
||||
the **app-packaging migration** lifecycle validation only.
|
||||
|
||||
How to drive the harness against `.116` (local):
|
||||
|
||||
```bash
|
||||
ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=http ARCHY_PASSWORD='ThisIsWeb54321@' \
|
||||
ARCHY_APPS='meshtastic,jellyfin,filebrowser,uptime-kuma' \
|
||||
tests/lifecycle/remote-lifecycle.sh # focused, audit-only (non-destructive)
|
||||
```
|
||||
|
||||
- `.116` serves nginx on **:80 only** (443 is tailscale's) → use `ARCHY_SCHEME=http`, `ARCHY_HOST=127.0.0.1`.
|
||||
- Local node is healthy: `update_state.json.current_version == 1.7.90-alpha`, `update_in_progress=false`
|
||||
(the OTA self-heal that was a follow-up gap in PROGRESS_MEMORY is now confirmed resolved on .116).
|
||||
- Login password for `.116`: `ThisIsWeb54321@` (verified against `auth.login`). Note: auth.login
|
||||
has a login rate-limiter — avoid rapid repeated attempts.
|
||||
- `.198` results below remain the prior baseline; new results are tagged `[.116]`.
|
||||
|
||||
### [.116] audit log (newest first)
|
||||
|
||||
- **2026-06-14 — focused audit `meshtastic,jellyfin,filebrowser,uptime-kuma` (audit-only, non-destructive):**
|
||||
harness exit 1, FAILED checks: 1.
|
||||
- `filebrowser` — running, pass (also passed a standalone single-app smoke run).
|
||||
- `uptime-kuma` — running, pass.
|
||||
- `meshtastic` — `state=absent`. Not installed on `.116` (was installed/validated on `.198`).
|
||||
Not a regression; just node state. To exercise meshtastic here, install it first (it needs
|
||||
`/dev/ttyUSB0`, which `.116` may not have) or drop it from the focused set on this node.
|
||||
- `jellyfin` — **running but FAILED: "launch metadata missing: jellyfin has no lan_address".**
|
||||
**ROOT-CAUSED 2026-06-14 — real, current bug in the working tree (a regression).** See
|
||||
"FINDING F1" below.
|
||||
|
||||
### [.116] FINDING F1 — manifest launch URLs with a path are silently dropped (OPEN, fix pending)
|
||||
|
||||
**Symptom:** `jellyfin` is `running` and genuinely serving (`curl 127.0.0.1:8096/` → 302), but
|
||||
`container-list` reports `lan_address: null`, so the UI/harness sees no launch URL.
|
||||
|
||||
**Root cause:** `core/archipelago/src/container/docker_packages.rs::reachable_lan_address()` parses
|
||||
the port out of the candidate URL with `url.rsplit(':').next()`. When the candidate comes from the
|
||||
manifest `interfaces.main` (via `PodmanClient::lan_address_for` →
|
||||
`core/container/src/podman_client.rs::manifest_primary_interface_url`), the URL **includes the
|
||||
manifest `path`** — e.g. jellyfin → `http://localhost:8096/`. Then `rsplit(':').next()` yields
|
||||
`"8096/"`, which **fails to `parse::<u16>()`**, so the function hits its `else { return None }`
|
||||
branch and drops a perfectly reachable launch URL. (Diagnostic tell: the dropped-at-parse path
|
||||
emits **no** log, whereas a genuine unreachable port logs "suppressing unreachable launch URL".
|
||||
jellyfin has no such log; uptime-kuma — whose candidate `…:3002` has no path — does.)
|
||||
|
||||
**Why it's a regression:** the old `extract_lan_address(ports)` produced `http://localhost:PORT`
|
||||
(no path), which parsed fine. The newer manifest-interface feature appends the declared `path`,
|
||||
so any app routed through `lan_address_for` now yields `…:PORT/` and trips the parser.
|
||||
|
||||
**Blast radius (apps in `requires_reachable_launch` whose `interfaces.main.path` = `/`):**
|
||||
`botfights`, `btcpay-server`, `fedimint`, `jellyfin`, `gitea`, `nextcloud`, `portainer`.
|
||||
(`filebrowser`/`nextcloud`/`nginx-proxy-manager`/`vaultwarden` are in `uses_allocated_launch_port`
|
||||
so they hit `extract_lan_address` first and dodge it; `grafana`/`mempool`/`uptime-kuma`/`searxng`
|
||||
have no manifest `interfaces.main` path.) On `.198` this likely went unnoticed because those apps
|
||||
weren't all running during the launch-metadata assertion, or predated the interfaces.main addition.
|
||||
|
||||
**Fix (IMPLEMENTED in working tree, uncommitted):**
|
||||
`docker_packages.rs::reachable_lan_address` now parses the port via a new `launch_url_port()`
|
||||
helper that reads digits after the final colon (`take_while(is_ascii_digit)`), mirroring the
|
||||
RPC-layer `port_from_url`, so `http://localhost:8096/` → `Some(8096)`. Added unit tests
|
||||
(`launch_url_port_tests`) covering the trailing-path regression, the bare-authority case, and a
|
||||
no-port reject. The existing `lan_address_prefers_manifest_main_interface` test only exercised
|
||||
`lan_address_for` (which always returned `…:8175/`) and never the `reachable_lan_address` wrapper,
|
||||
which is why the bug slipped through.
|
||||
|
||||
**Unit validation: GREEN (2026-06-14).** `cargo test -p archipelago --bin archipelago launch_url_port`
|
||||
→ 3 passed / 0 failed (trailing-path, bare-authority, no-port-reject); crate compiles clean.
|
||||
|
||||
**Coordination note (shared tree):** the repo is on branch `fix/wallet-receive-portdrift-secrets`
|
||||
at commit `bb808df8` (= the deployed 1.7.90-alpha). A parallel agent has uncommitted changes here
|
||||
(lnd `wallet.rs`, `bitcoin_relay.rs`, `prod_orchestrator.rs`, electrumx manifest, neode-ui, new
|
||||
bats). To validate F1 in isolation (and NOT deploy their in-flight work onto the live node, nor
|
||||
disturb their tree), the live-validation build is done in a detached git worktree at
|
||||
`/home/archipelago/archy-f1` = clean `bb808df8` + only the F1 `docker_packages.rs` change. Build:
|
||||
`cd /home/archipelago/archy-f1/core && TMPDIR=/home/archipelago/.buildtmp cargo build --release -p archipelago`
|
||||
(`.116`'s `/tmp` is a 7.7G tmpfs that runs 100% full → the ring crate's C compile fails with
|
||||
"No space left on device"; redirect `TMPDIR` to `/` which has ~399G). After validation the
|
||||
worktree is removed (`git worktree remove`). NOTE: sideloading replaces the OTA-managed
|
||||
`/usr/local/bin/archipelago` with a local 1.7.90-alpha+F1 build until the next OTA — back up the
|
||||
current binary first (`/usr/local/bin/archipelago.pre-f1.bak`).
|
||||
|
||||
**Live validation status — ✅ GREEN on `.116` (2026-06-14).** Built combined tree (`a483fe4b`),
|
||||
sideloaded, restarted `archipelago.service`. Before/after on the live node (old buggy binary → new):
|
||||
|
||||
| app | OLD lan_address | NEW lan_address |
|
||||
|---|---|---|
|
||||
| jellyfin | `None` ❌ | `http://localhost:8096/` ✅ |
|
||||
| btcpay-server | `None` ❌ | `http://localhost:23000/` ✅ |
|
||||
| fedimint | `None` ❌ | `http://localhost:8175/` ✅ |
|
||||
| gitea | `None` ❌ | `http://localhost:3001/` ✅ |
|
||||
| portainer | `None` ❌ | `http://localhost:9000/` ✅ |
|
||||
| botfights | `None` ❌ | `http://localhost:9100/` ✅ |
|
||||
| nextcloud | `:8085` ✓ | `:8085` (unchanged — allocated-port path) |
|
||||
| filebrowser | `:8083` ✓ | `:8083` (unchanged) |
|
||||
|
||||
Harness focused audit `jellyfin,filebrowser` → **all checks passed, exit 0**. Unit tests green.
|
||||
No container casualties (all 36 survived; see RESUME CHECKPOINT for the cgroup detail).
|
||||
|
||||
NOTE: Do NOT run the prod binary directly to "check a version" —
|
||||
`/usr/local/bin/archipelago <anyflag>` boots a whole second node instance (learned the hard way
|
||||
2026-06-14; it exited without leaving a stray, but don't repeat).
|
||||
|
||||
## Goal
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Progress Memory
|
||||
|
||||
Last updated: 2026-06-13
|
||||
|
||||
## Current State
|
||||
|
||||
- `v1.7.90-alpha` release is complete, tagged, pushed, uploaded, and verified on vps2.
|
||||
- Release commit: `bb808df8` (chore: release v1.7.90-alpha).
|
||||
- Feature commit: `c800293f` (fix: bitcoin receive, AIUI pointer input, electrs self-heal, OTA timeout).
|
||||
- Gitea tag: `v1.7.90-alpha` (on origin/gitea-vps2).
|
||||
- Live OTA manifest on the update host (146.59.87.168) now resolves to `1.7.90-alpha`; both
|
||||
artifact download URLs (binary + frontend tarball) return HTTP 200.
|
||||
- v1.7.89-alpha was already fully shipped before this session.
|
||||
|
||||
## What shipped in v1.7.90-alpha
|
||||
|
||||
- Bitcoin receive address generation fixed (correct address type, no more 400).
|
||||
- AIUI/app session: on-screen pointer can click + type into app content (incl. app store
|
||||
search); "open in new tab" opens the phone browser; mobile credential modal centered.
|
||||
- Electrs self-heals from a corrupt index and shows a percent/block-height progress screen.
|
||||
- update.rs: retired tx1138 secondary mirror dropped (one-time migration); longer download
|
||||
timeout for slow connections.
|
||||
|
||||
## Verification
|
||||
|
||||
- Full release harness green (8 stages): git-diff, cargo-fmt, catalog-drift, release-manifest,
|
||||
ui-type-check, ui-unit-tests (80 files / 655 tests), cargo-check, cargo-test-weekly.
|
||||
- Freshly built binary embeds `1.7.90-alpha` (no stale 1.7.89); frontend dist rebuilt fresh
|
||||
(new AppSession bundle); manifest sha256 + size match on-disk artifacts.
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- `gitea-local` (localhost:3000) push FAILS from this node — redirects to /login (auth).
|
||||
The v1.7.88 and v1.7.89 tags were also already missing there, so this is a pre-existing
|
||||
condition on this node, not a v1.7.90 regression. vps2 is the primary OTA mirror and is fine.
|
||||
- OTA self-update verification on THIS node (.116) not yet observed this session — the node
|
||||
should auto-apply from the live 1.7.90-alpha manifest; confirm
|
||||
`update_state.json.current_version == 1.7.90-alpha` after the scheduler runs.
|
||||
|
||||
## Resume Context
|
||||
|
||||
- If a later session resumes, continue from the next active product/release task, not this
|
||||
finished release.
|
||||
- Broader context: docs/WEEKLY_RELEASE_TRACKER.md, docs/RESUME.md, docs/NEXT_TERMINAL_HANDOFF.md
|
||||
@@ -0,0 +1,288 @@
|
||||
# Weekly Release Tracker
|
||||
|
||||
Last updated: 2026-06-14 (session on node .116 / archi-thinkpad)
|
||||
|
||||
---
|
||||
|
||||
# ▶ IN PROGRESS — LND wallet auto-unlock fix (2026-06-14)
|
||||
|
||||
## RESUME PROMPT (paste into a fresh session, on .116 / archi-thinkpad, tree at /home/archipelago/Projects/archy)
|
||||
|
||||
> Resume the LND wallet-password fix. Read memory `project_lnd_wallet_password.md` FIRST (full
|
||||
> root-cause + design + validated facts). Work is on branch `lnd-wallet-password-fix` (pushed to
|
||||
> gitea-vps2, commit 91adc281, NOT merged to main, NOT shipped). Bug: hardcoded
|
||||
> `WALLET_PASSWORD="hellohello"` left LND wallets LOCKED fleet-wide after OTA → Bitcoin-receive
|
||||
> shows "wallet is locked" on every updated node. DONE + cargo-checked: per-node random secret
|
||||
> (secrets/lnd-wallet-password), both init paths unified, candidate-unlock with fail-fast,
|
||||
> login-time candidate-migration (ChangePassword). DETECTION GATE already shipped on main
|
||||
> (commit 8c8e4d7a). DECISION: alpha, NO funds on nodes → destructive wipe+recreate is OK and
|
||||
> wanted UNATTENDED for ALL nodes in the next update. A wallet locked with an unknown password is
|
||||
> already inaccessible, so wiping loses nothing reachable.
|
||||
|
||||
## EXACT NEXT STEPS — LND fix (in order)
|
||||
1. **Finish seed/fresh recovery** (REMAINING piece): in `container/lnd.rs ensure_wallet_initialized`,
|
||||
when wallet.db exists but ALL unlock candidates fail → wipe wallet.db (+ macaroons + graph/chain
|
||||
mainnet state, as root via host_sudo) and re-init fresh (random genseed + per-node secret) so the
|
||||
node self-heals unattended at boot. (Login-time candidate-migration already handles nodes whose
|
||||
pw matches.) Validate the wipe→reinit mechanic on the scratch LND first (see below).
|
||||
2. **Scratch validation** (was in progress, .249 unreachable from .116's subnet → use a throwaway
|
||||
`lnd-scratch` podman container on .116, regtest/neutrino, REST :18099 — already proven for
|
||||
init/unlock/ChangePassword). Test: init(passA) → restart→LOCKED → delete wallet.db while locked →
|
||||
confirm /v1/state→NON_EXISTING (may need container restart) → genseed+initwallet fresh → unlock.
|
||||
NOTE: scratch wallet.db lives at the container's LND data dir (regtest), `podman exec lnd-scratch
|
||||
find / -name wallet.db`. CLEAN UP: `podman rm -f lnd-scratch` when done.
|
||||
3. `cargo check -p archipelago` (on .116 ~15-30s incremental; full test compile ~9min).
|
||||
4. **End-to-end on .228** (reachable 192.168.1.x, SSH pw `archipelago`, UI pw unknown, NO funds —
|
||||
has a locked unknown-pw wallet = perfect auto-recreate test): build binary
|
||||
(`ARCHIPELAGO_TARGET=archipelago@192.168.1.228 scripts/deploy-to-target.sh` or per
|
||||
reference_deploy_to_nodes), deploy, restart, confirm wallet auto-recreates+unlocks, lncli state
|
||||
RPC_ACTIVE, lnd.newaddress returns an address. Run os-audit against .228 → lnd check PASS.
|
||||
5. Merge `lnd-wallet-password-fix` → main, then **cut + publish v1.7.93-alpha** (carries the LND
|
||||
fix). Ship ritual: create-release.sh 1.7.93-alpha → add CHANGELOG (≥3 layman bullets) → run
|
||||
sync-whats-new.py (the new What's-New gate will require it) → publish-release-assets.sh gitea-vps2
|
||||
→ push origin/gitea-vps2 + tags → verify live manifest==1.7.93-alpha. Heads-up: create-release
|
||||
leaves core/Cargo.lock version-bump uncommitted (commit it as a chore, both .91 and .92 hit this).
|
||||
|
||||
## Context: how we got here (this session, all on node .116)
|
||||
- Shipped **v1.7.91-alpha** (bitcoinReceive TS2538 build fix) and **v1.7.92-alpha** (ElectrumX
|
||||
overlay-during-sync fix; L3 reboot os-audit gate; What's-New sync gate + 8-version backfill) —
|
||||
both LIVE on vps2. Restored .116-local nginx `/lnd-connect-info` route (was dropped 2026-06-10).
|
||||
- Triaged user symptoms: ElectrumX "can't connect" = electrs syncing / Bitcoin verifying (not a
|
||||
regression); .228 "5/14 apps after reboot" = normal ~5min staggered startup (all 14 came up).
|
||||
- LND lock bug found + detection gate shipped + forward fix & migration implemented (this section).
|
||||
|
||||
---
|
||||
|
||||
# ✔ DONE PASS — v1.7.91-alpha + v1.7.92-alpha (2026-06-14)
|
||||
|
||||
## Outcome (both releases PUBLISHED + LIVE on vps2)
|
||||
|
||||
- **v1.7.91-alpha** — bitcoinReceive.ts TS2538 build-blocker fixed; cut, published, verified
|
||||
live (`manifest.version==1.7.91-alpha`), tag `v1.7.91-alpha` on vps2. The fleet OTA'd to it
|
||||
(confirmed on .116 + .198).
|
||||
- **v1.7.92-alpha** — cut, published, verified live (`manifest.version==1.7.92-alpha`), tag on
|
||||
vps2, main@d462e444. Carries:
|
||||
- `fix(ui)` ElectrumX **overlay-during-sync** bug — the "App not reachable / retry" overlay
|
||||
no longer paints over the ElectrumX sync screen (AppSessionFrame.vue gated on `!electrsSync`).
|
||||
- `test(resilience)` **L3 per-boot health gate** — `batch_host_reboot` now runs os-audit.sh
|
||||
after reboot (RPC/OTA/all-apps/FM-guards), not just container-set equality. os-audit validated
|
||||
11/0/0 green on .116.
|
||||
- `feat(release)` **What's New sync gate** — `scripts/sync-whats-new.py` + `whats-new-sync`
|
||||
stage in tests/release/run.sh. Backfilled the 8 missing modal blocks (v1.7.85→.92); the gate
|
||||
fails any release whose CHANGELOG version isn't in the Settings modal.
|
||||
- **.116 node fix (not shipped — local config)**: restored the `/lnd-connect-info` nginx proxy
|
||||
route that a 2026-06-10 "before-116-routing" change had dropped (fell through to SPA). Backup at
|
||||
`/etc/nginx/conf.d/rpc.tx1138.com.conf.bak-lndconnect-*`. Shipped template already has the route.
|
||||
- **User symptoms triaged (none were .91/.92 regressions)**: receive-generate "unchanged" = .91's
|
||||
receive change was a behavior-preserving build guard; ElectrumX "can't connect" on .198 = Bitcoin
|
||||
node mid-"Verifying blocks…" (-28) so electrs was "waiting for Bitcoin node"; on .116 electrs was
|
||||
~59% mid-sync. The overlay UX bug is fixed regardless.
|
||||
|
||||
## Known follow-ups (not blockers)
|
||||
- **gitea-local mirror push fails** (`localhost:3000` → redirect to `/login`, token auth). vps2 is
|
||||
the OTA source and is fine; gitea-local secondary mirror is stale. Diagnose the local Gitea token.
|
||||
- `sync-whats-new.py` only **inserts missing** versions; it does not rewrite a block when CHANGELOG
|
||||
bullets for an already-present version change (had to delete+resync the .92 block by hand to pick
|
||||
up its 3rd bullet). Fine for the forward case; enhance to idempotently re-render if needed.
|
||||
|
||||
## What happened this session
|
||||
|
||||
- `scripts/create-release.sh 1.7.91-alpha` was running; its release gate PASSED all 7 checks,
|
||||
backend built clean (7m22s), then it **FAILED at step [4/8] frontend build** with:
|
||||
`src/utils/bitcoinReceive.ts(23,24): error TS2538: Type 'undefined' cannot be used as an index type.`
|
||||
Cause: `noUncheckedIndexedAccess` — `codeMatch[1]` is `string | undefined` and was used directly
|
||||
to index `RECEIVE_CODE_MESSAGES`. **FIXED** → `const code = message.match(/\[([A-Z_]+)\]/)?.[1]`
|
||||
then `if (code && RECEIVE_CODE_MESSAGES[code])`. `npx vue-tsc --noEmit` is now clean (exit 0).
|
||||
The failed run aborted BEFORE bumping the manifest (still 1.7.90) or tagging (no v1.7.91 tag),
|
||||
but it HAD already partial-bumped Cargo.toml/package.json/locks to 1.7.91 — those partial bumps
|
||||
are reverted (create-release.sh re-owns the bump); only the genuine TS fix + harness are committed.
|
||||
- Built a new OS-wide health harness `tests/lifecycle/os-audit.sh` (non-destructive, one scorecard):
|
||||
Section A backend/RPC health, Section B all-apps lifecycle audit (delegates to remote-lifecycle.sh),
|
||||
Section C FM-guards (port-drift + secret-completeness bats, orphan-container sweep). Section A
|
||||
validated all-PASS on .116. Fixed a jq bug in the FM12 OTA-wedge check: `//` treats a legit
|
||||
`false` as empty and fell through to "unknown" — now uses `has()`. Section B is slow (~3 min) and
|
||||
opaque while running because output is captured (`out=$(...)`) not streamed — minor wart, TODO.
|
||||
|
||||
## EXACT NEXT STEPS — v1.7.91 (in order)
|
||||
|
||||
1. Confirm clean tree + on main (`git status`; create-release.sh requires `git diff --quiet HEAD`).
|
||||
The TS fix + os-audit.sh are committed & pushed; version-bump artifacts reverted to 1.7.90.
|
||||
2. Re-run the release: `scripts/create-release.sh 1.7.91-alpha`. Backend is cached (only a .ts
|
||||
changed) so it's fast; the frontend build now passes. It bumps versions, builds, writes
|
||||
releases/manifest.json (→1.7.91-alpha), commits, and tags v1.7.91-alpha.
|
||||
- Memory guards: grep the staged frontend tarball for "1.7.91-alpha" before shipping (silent
|
||||
vue-tsc failures); tarball must be flat (`tar -C web/dist/neode-ui .`).
|
||||
3. Publish: `scripts/publish-release-assets.sh 1.7.91-alpha gitea-vps2`, then
|
||||
`git push origin main && git push origin --tags` (origin pushes to BOTH gitea-local + vps2).
|
||||
4. Verify manifest LIVE (this is "published"):
|
||||
`curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json | jq .version`
|
||||
must show `1.7.91-alpha`. **Then notify the user — they asked to be told when 1.7.91 publishes.**
|
||||
5. os-audit harness: run a full green pass on .116
|
||||
(`ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=http ARCHY_PASSWORD='ThisIsWeb54321@' tests/lifecycle/os-audit.sh`),
|
||||
confirm Section A FM12 now reads `update_in_progress=false` (PASS not WARN), review B + C findings,
|
||||
then wire os-audit.sh into the reboot-survival (L3) loop as the per-boot gate.
|
||||
|
||||
---
|
||||
|
||||
# ─ HISTORY — v1.7.89-alpha pass (2026-06-12), superseded ─
|
||||
|
||||
Last updated: 2026-06-12 ~17:45 EDT (session on node .116)
|
||||
|
||||
## RESUME PROMPT (paste into a fresh session)
|
||||
|
||||
> Continue the v1.7.89-alpha release pass from /home/archipelago/Projects/archy on node .116.
|
||||
> Read docs/WEEKLY_RELEASE_TRACKER.md fully first — it has root causes, fixes already made,
|
||||
> and exact next steps. Do NOT redo: AIUI revert (done, validated), updater fixes in
|
||||
> core/archipelago/src/update.rs (done, uncommitted), .116 OTA unwedge (done). Resume at
|
||||
> "EXACT NEXT STEPS" below.
|
||||
|
||||
## EXACT NEXT STEPS (in order)
|
||||
|
||||
1. Backend focused tests were running in background:
|
||||
`cd core && timeout 1500 cargo test -p archipelago -- update:: lnd container::image_versions scanner`
|
||||
(log: /tmp/claude-.../tasks/bds4jk19e.output — if lost, just rerun the command; first
|
||||
attempt died at 400s timeout during test compile, 1500s is the right budget).
|
||||
Need: all green.
|
||||
2. RESOLVED before session end: vitest recheck passed clean — EXIT=0, 79 files / 645 tests,
|
||||
even while cargo test was compiling. The earlier harness ui-unit-tests FAIL was load/flake
|
||||
(machine saturated by the parallel cargo test compile), not a real failure. On resume just
|
||||
rerun `tests/release/run.sh --quick` WITHOUT a parallel cargo build to confirm green;
|
||||
if it ever fails again, the failing test name is in the stage output (drop `--silent`).
|
||||
3. Run full harness: `tests/release/run.sh` (static+frontend+backend). Then commit ALL
|
||||
working-tree changes (one commit, e.g. "fix: harden OTA updates, AIUI desktop gap, LND
|
||||
no-proxy" — CHANGELOG v1.7.89 section is already curated).
|
||||
4. Cut release: `scripts/create-release.sh 1.7.89-alpha` (needs clean tree, on main,
|
||||
validates CHANGELOG section exists — it does). Then
|
||||
`tests/release/run.sh --manifest` should pass, and grep the staged frontend tarball
|
||||
for 1.7.89-alpha (memory: silent build failures).
|
||||
5. Publish: `scripts/publish-release-assets.sh 1.7.89-alpha gitea-vps2`, then
|
||||
`git push origin main && git push origin --tags` and push gitea-local + tags too.
|
||||
Verify manifest live on http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json
|
||||
6. Verify OTA on THIS node (.116): schedule is auto_apply; either wait for the scheduler
|
||||
or trigger via UI. Confirm /var/lib/archipelago/update_state.json current_version
|
||||
becomes 1.7.89-alpha, `update_in_progress` returns to false, web-ui + binary versions
|
||||
MATCH (this node currently has web-ui 1.7.84 / binary 1.7.85 mismatch — the OTA heals it),
|
||||
and journalctl shows "Post-OTA verification succeeded" (the new probe falls back to
|
||||
http://127.0.0.1/ which is what .116 serves).
|
||||
7. Update this tracker + docs/PROGRESS_MEMORY.md, mark tasks done.
|
||||
Purpose: live tracker for this pass — test everything shipped this week (v1.7.83→v1.7.89),
|
||||
build the release test harness, fix OTA updates on .116, make updates bulletproof, cut v1.7.89-alpha.
|
||||
If the session is cut off, resume from here.
|
||||
|
||||
## Task status
|
||||
|
||||
| # | Task | Status |
|
||||
|---|------|--------|
|
||||
| 1 | AIUI revert (mobile back/close gone, desktop gap fixed) | DONE — validated |
|
||||
| 2 | Dev server on :8100 with embedded AIUI | DONE — see below |
|
||||
| 3 | Inventory this week's release-log items | DONE — see checklist |
|
||||
| 4 | Test harness covering this week + seed of system-wide harness | IN PROGRESS |
|
||||
| 5 | Fix OTA updates on .116 + bulletproof updates | IN PROGRESS — diagnosis below |
|
||||
| 6 | Cut v1.7.89-alpha release | PENDING (gates: 4, 5) |
|
||||
|
||||
## State of the working tree
|
||||
|
||||
- HEAD = 495b9078 (v1.7.89 changelog + AIUI mobile restore committed).
|
||||
- Uncommitted, intended for v1.7.89-alpha:
|
||||
- `neode-ui/src/views/Dashboard.vue` — chat route back to plain `h-full` (desktop bottom-gap fix). Validated.
|
||||
- `core/.../rpc/lnd/*` + `container/lnd.rs` — LND REST no-proxy + wallet readiness/unlock fixes.
|
||||
- Version bumps to 1.7.89-alpha (Cargo.toml, package.json, locks), CHANGELOG entry.
|
||||
- `neode-ui/vite.config.ts` — added `/aiui` dev proxy (keep; dev-only convenience).
|
||||
|
||||
## AIUI validation (task 1) — DONE
|
||||
|
||||
- HEAD already removed the mobile back button and restored `hideClose=true` (495b9078).
|
||||
- Working-tree Dashboard.vue removes `dashboard-scroll-panel mobile-scroll-pad` from the chat
|
||||
route (that padding caused the desktop bottom gap); mesh keeps its styling.
|
||||
- Chat CSS verified byte-identical to last-good 34c4e87d (May 20).
|
||||
- Playwright check (desktop 1440x900, mobile 390x844): chat fills full viewport, no bottom gap,
|
||||
no mobile back/close. `npm run type-check` + focused route tests + full vitest (645/645) pass.
|
||||
|
||||
## Dev server on :8100 (task 2) — DONE
|
||||
|
||||
- Running: `BACKEND_URL=http://127.0.0.1:5678 VITE_AIUI_URL=/aiui/ npx vite --host 0.0.0.0 --port 8100`
|
||||
from `neode-ui/` (real local backend on 5678).
|
||||
- AIUI now embeds in /dashboard/chat via new vite proxy `/aiui` → `http://127.0.0.1:80`
|
||||
(the node's deployed AIUI), same-origin like production.
|
||||
- Secondary throwaway instance for automated checks: :8101 against mock backend
|
||||
(`node mock-backend.js` on 5959, password `password123`).
|
||||
|
||||
## This week's shipped items (v1.7.83 → v1.7.89) — test checklist
|
||||
|
||||
### Frontend (vitest/type-check/build cover most; full suite 645/645 green 2026-06-12)
|
||||
- [x] AIUI fast launch, no availability probe (v1.7.88) — covered by visual check + Chat.vue tests
|
||||
- [x] AIUI mobile layout restore (v1.7.89) — playwright visual check
|
||||
- [x] App-session launch metadata from manifests / typed interfaces (v1.7.83) — appSessionConfig tests
|
||||
- [x] OnlyOffice + Saleor removal (v1.7.83) — catalog tests
|
||||
- [ ] Bitcoin receive UI flow end-to-end (v1.7.87/88) — needs live LND node check
|
||||
- [ ] Fleet tab keeps node list/alerts during refresh, names not hashes (v1.7.85/86) — store tests?
|
||||
- [ ] Credential interstitial full-screen overlay (v1.7.87) — visual
|
||||
- [ ] Mobile federation/system-update buttons full width (v1.7.86) — visual
|
||||
|
||||
### Backend (cargo)
|
||||
- [ ] LND REST no-proxy client + GET newaddress p2wkh (v1.7.88/89) — unit tests + live check
|
||||
- [ ] LND wallet readiness/unlock after restart (v1.7.89) — unit + live
|
||||
- [ ] Bitcoin trusted-node relay rpcauth/txrelay (v1.7.84) — unit tests exist? check
|
||||
- [ ] Container scanner RAII in-flight guard (v1.7.84) — cargo test
|
||||
- [ ] ElectrumX health-check startup window + cache tuning (v1.7.85/86)
|
||||
- [ ] Portainer pin 2.19.4 / bitcoin-ui image pin (v1.7.84/85) — image-versions tests
|
||||
- [ ] Fleet telemetry name/hostname/URL fields (v1.7.85)
|
||||
- [ ] Federation no self-import (v1.7.85)
|
||||
- [ ] Kiosk safe-area + self-update refreshes kiosk files (v1.7.84)
|
||||
- [ ] Wi-Fi scan error/retry/escaped SSID/open networks (v1.7.84)
|
||||
|
||||
### OTA / updates (task 5)
|
||||
- [ ] .116 stuck: current 1.7.85-alpha, `update_in_progress: true` since 1.7.88 attempt — diagnose+fix
|
||||
- [ ] Updater hardening: stuck-in-progress recovery, resumable/atomic apply, verify post-restart version
|
||||
|
||||
## OTA diagnosis on .116 — ROOT CAUSES FOUND + FIXED (code staged for v1.7.89)
|
||||
|
||||
Four bugs, all reproduced from the journal (Jun 12 03:45–04:33):
|
||||
|
||||
1. Post-OTA probe only tries `https://127.0.0.1/`; .116's nginx binds only :80 (443 is
|
||||
tailscale's) → connection refused × 18 → a GOOD 1.7.85 update was "rolled back".
|
||||
FIX: probe falls back to `http://127.0.0.1/` on connect error (update.rs probe_frontend_once).
|
||||
2. That rollback's binary restore did `host_sudo cp` onto the RUNNING binary → ETXTBSY exit 1
|
||||
→ binary stayed 1.7.85 while web-ui rolled back to 1.7.84 (mismatch confirmed live).
|
||||
FIX: rollback now cp→tmp→atomic mv, same pattern as apply (update.rs rollback_update).
|
||||
3. The rollback chown'd `update-backup/archipelago` root:root IN PLACE → next apply's
|
||||
fs::copy (as service user) hit EACCES → "Failed to backup current binary" × 3 → 1.7.86/88
|
||||
never applied. FIX: apply unlinks stale backup first; rollback chowns only its temp copy.
|
||||
4. Failed apply left `update_in_progress: true` wedged (staging still populated so the
|
||||
stale-flag guard never fires). Unwedged operationally; fixed structurally by 1–3.
|
||||
|
||||
Operational cleanup DONE on .116 (2026-06-12 17:15): removed root-owned
|
||||
`update-backup/archipelago`, stale `update-staging/` (1.7.86), and the stale
|
||||
`update-pending-verify.json`. Next state load clears `update_in_progress`.
|
||||
NOTE: live web-ui is 1.7.84 / binary 1.7.85 (mismatch from bug 2). Not hand-patched —
|
||||
the v1.7.89 OTA will resync both. Good 1.7.85 frontend is quarantined at
|
||||
`/opt/archipelago/web-ui.failed.1781250438247`.
|
||||
Verification plan: after v1.7.89 release, watch .116 auto-apply (schedule auto_apply),
|
||||
confirm `update_state.json.current_version == 1.7.89-alpha` and web-ui version matches.
|
||||
|
||||
## Test harness (task 4) — CREATED at tests/release/run.sh
|
||||
|
||||
- Stages: static (git diff --check, cargo fmt, catalog drift, optional --manifest),
|
||||
frontend (type-check, full vitest), optional --with-build (build + grep dist for version),
|
||||
backend (cargo check + focused cargo test: update:: lnd container::image_versions scanner,
|
||||
all wrapped in `timeout`), optional --live URL smoke (/, /aiui/, /rpc/v1).
|
||||
- Results so far (2026-06-12): type-check PASS, full vitest 645/645 PASS, cargo fmt PASS,
|
||||
cargo check PASS, catalog drift PASS (3 pre-existing MISSING_CATALOG warnings, exit 0,
|
||||
identical on HEAD). Focused backend cargo tests running (first run hit the known slow
|
||||
test-compile on .116 at 400s timeout; rerunning with 1500s).
|
||||
- AIUI embed verified end-to-end via playwright on :8101 (mock backend): iframe loads,
|
||||
`ready` handshake clears the loading overlay, hideClose honored.
|
||||
- Release flow confirmed: commit all → `scripts/create-release.sh 1.7.89-alpha` (validates
|
||||
curated CHANGELOG section, builds, manifests, commits, tags) →
|
||||
`scripts/publish-release-assets.sh 1.7.89-alpha gitea-vps2` → push origin main + tags.
|
||||
Tarball layout/perms safety is already inside create-release-manifest.sh.
|
||||
- CHANGELOG v1.7.89 section rewritten layman-readable (updater fixes added).
|
||||
|
||||
## Release gates for v1.7.89-alpha (task 6)
|
||||
|
||||
1. All harness stages green locally.
|
||||
2. OTA fix for stuck `update_in_progress` included + .116 updates successfully to the new release.
|
||||
3. Frontend build: grep packaged tarball for "1.7.89-alpha" before shipping (memory: silent vue-tsc failures).
|
||||
4. Flat tarball layout (`tar -C web/dist/neode-ui .`).
|
||||
5. Commit, tag `v1.7.89-alpha`, push origin + gitea-local + tags, publish release assets, verify
|
||||
manifest + node OTA picks it up.
|
||||
@@ -2,6 +2,14 @@
|
||||
Description=Archipelago Backend
|
||||
After=network-online.target archipelago-setup-tor.service
|
||||
Wants=network-online.target
|
||||
# The data dir AND podman's graphroot (containers/storage) both live on the
|
||||
# separate /var/lib/archipelago volume. Without this, on a cold boot the service
|
||||
# (and its ExecStartPre) can start BEFORE var-lib-archipelago.mount, write to the
|
||||
# bare mountpoint on rootfs, fail every podman call, exit, and get restarted every
|
||||
# 5s until the volume mounts (~5 min of "[FAILED] Failed to start" on boot — B17).
|
||||
# RequiresMountsFor adds both Requires= and After= on the mount unit so we never
|
||||
# start until the data volume is mounted.
|
||||
RequiresMountsFor=/var/lib/archipelago
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
|
||||
@@ -652,7 +652,14 @@ server {
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once on;
|
||||
sub_filter_types text/css application/javascript application/json;
|
||||
sub_filter_once off;
|
||||
sub_filter 'href="/' 'href="/app/fedimint/';
|
||||
sub_filter 'src="/' 'src="/app/fedimint/';
|
||||
sub_filter "href='/" "href='/app/fedimint/";
|
||||
sub_filter "src='/" "src='/app/fedimint/";
|
||||
sub_filter 'url("/' 'url("/app/fedimint/';
|
||||
sub_filter "url('/" "url('/app/fedimint/";
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
location /app/fedimint-gateway/ {
|
||||
|
||||
@@ -174,7 +174,14 @@ location /app/fedimint/ {
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once on;
|
||||
sub_filter_types text/css application/javascript application/json;
|
||||
sub_filter_once off;
|
||||
sub_filter 'href="/' 'href="/app/fedimint/';
|
||||
sub_filter 'src="/' 'src="/app/fedimint/';
|
||||
sub_filter "href='/" "href='/app/fedimint/";
|
||||
sub_filter "src='/" "src='/app/fedimint/";
|
||||
sub_filter 'url("/' 'url("/app/fedimint/';
|
||||
sub_filter "url('/" "url('/app/fedimint/";
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
location /app/fedimint-gateway/ {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.86-alpha",
|
||||
"version": "1.7.97-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.86-alpha",
|
||||
"version": "1.7.97-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.7.86-alpha",
|
||||
"version": "1.7.97-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
|
Before Width: | Height: | Size: 476 KiB After Width: | Height: | Size: 435 KiB |
|
Before Width: | Height: | Size: 894 KiB After Width: | Height: | Size: 824 KiB |
|
Before Width: | Height: | Size: 1014 KiB After Width: | Height: | Size: 965 KiB |
|
Before Width: | Height: | Size: 1019 KiB After Width: | Height: | Size: 954 KiB |
|
Before Width: | Height: | Size: 1016 KiB After Width: | Height: | Size: 943 KiB |
|
Before Width: | Height: | Size: 1019 KiB After Width: | Height: | Size: 954 KiB |
|
Before Width: | Height: | Size: 976 KiB After Width: | Height: | Size: 869 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 987 KiB |
|
Before Width: | Height: | Size: 901 KiB After Width: | Height: | Size: 854 KiB |
|
Before Width: | Height: | Size: 999 KiB After Width: | Height: | Size: 956 KiB |
|
Before Width: | Height: | Size: 999 KiB After Width: | Height: | Size: 952 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 987 KiB |
|
Before Width: | Height: | Size: 5.5 MiB After Width: | Height: | Size: 778 KiB |
|
Before Width: | Height: | Size: 1014 KiB After Width: | Height: | Size: 965 KiB |
|
Before Width: | Height: | Size: 976 KiB After Width: | Height: | Size: 869 KiB |
|
Before Width: | Height: | Size: 996 KiB After Width: | Height: | Size: 919 KiB |
|
Before Width: | Height: | Size: 774 KiB After Width: | Height: | Size: 726 KiB |
|
Before Width: | Height: | Size: 494 KiB After Width: | Height: | Size: 438 KiB |
@@ -21,7 +21,9 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
|
||||
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
|
||||
headers: new Headers(),
|
||||
// A real File Browser JSON response carries this; listDirectory now guards
|
||||
// on it (B4) to detect the SPA-fallback HTML / 502 cases.
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
redirected: false,
|
||||
type: 'basic' as ResponseType,
|
||||
url: '',
|
||||
@@ -119,7 +121,21 @@ describe('FileBrowserClient', () => {
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
|
||||
|
||||
await expect(fileBrowserClient.listDirectory('/missing')).rejects.toThrow('Failed to list directory: 404')
|
||||
await expect(fileBrowserClient.listDirectory('/missing')).rejects.toThrow('File Browser is not available (HTTP 404)')
|
||||
})
|
||||
|
||||
it('throws a friendly error when File Browser is absent and nginx serves the SPA (B4)', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
// 200 but text/html (SPA index.html fallback) — res.json() would throw the
|
||||
// opaque "Unexpected token '<'"; the guard must surface a friendly message.
|
||||
const htmlResponse = {
|
||||
...jsonResponse('<!doctype html><html></html>'),
|
||||
headers: new Headers({ 'content-type': 'text/html' }),
|
||||
} as Response
|
||||
mockFetch.mockResolvedValueOnce(htmlResponse)
|
||||
|
||||
await expect(fileBrowserClient.listDirectory('/')).rejects.toThrow('File Browser is not available')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { isTextField, typeKeyIntoField } from '../remote-relay'
|
||||
|
||||
/**
|
||||
* Companion-cursor text entry. Synthetic KeyboardEvents do NOT mutate input
|
||||
* values in the browser, so the relay edits `.value` at the caret directly and
|
||||
* fires an `input` event. These tests lock in that behaviour so a regression
|
||||
* (like the old "type goes to document, nothing happens" bug) is caught before
|
||||
* release rather than by a user with a companion controller.
|
||||
*/
|
||||
describe('isTextField', () => {
|
||||
it('accepts text-like inputs and textareas', () => {
|
||||
const text = document.createElement('input')
|
||||
text.type = 'text'
|
||||
const search = document.createElement('input')
|
||||
search.type = 'search'
|
||||
const area = document.createElement('textarea')
|
||||
expect(isTextField(text)).toBe(true)
|
||||
expect(isTextField(search)).toBe(true)
|
||||
expect(isTextField(area)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-text controls and null', () => {
|
||||
const checkbox = document.createElement('input')
|
||||
checkbox.type = 'checkbox'
|
||||
expect(isTextField(checkbox)).toBe(false)
|
||||
expect(isTextField(document.createElement('button'))).toBe(false)
|
||||
expect(isTextField(document.createElement('div'))).toBe(false)
|
||||
expect(isTextField(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('typeKeyIntoField', () => {
|
||||
let input: HTMLInputElement
|
||||
let inputEvents: number
|
||||
|
||||
beforeEach(() => {
|
||||
input = document.createElement('input')
|
||||
input.type = 'search'
|
||||
document.body.appendChild(input)
|
||||
inputEvents = 0
|
||||
input.addEventListener('input', () => { inputEvents++ })
|
||||
})
|
||||
|
||||
it('inserts printable characters at the caret and fires input', () => {
|
||||
typeKeyIntoField(input, 'b')
|
||||
typeKeyIntoField(input, 't')
|
||||
typeKeyIntoField(input, 'c')
|
||||
expect(input.value).toBe('btc')
|
||||
expect(input.selectionStart).toBe(3)
|
||||
expect(inputEvents).toBe(3)
|
||||
})
|
||||
|
||||
it('inserts a character in the middle of existing text', () => {
|
||||
input.value = 'bc'
|
||||
input.selectionStart = input.selectionEnd = 1
|
||||
typeKeyIntoField(input, 't')
|
||||
expect(input.value).toBe('btc')
|
||||
expect(input.selectionStart).toBe(2)
|
||||
})
|
||||
|
||||
it('backspace deletes the char before the caret', () => {
|
||||
input.value = 'btc'
|
||||
input.selectionStart = input.selectionEnd = 3
|
||||
typeKeyIntoField(input, 'Backspace')
|
||||
expect(input.value).toBe('bt')
|
||||
expect(input.selectionStart).toBe(2)
|
||||
})
|
||||
|
||||
it('backspace removes the active selection', () => {
|
||||
input.value = 'bitcoin'
|
||||
input.selectionStart = 0
|
||||
input.selectionEnd = 3
|
||||
typeKeyIntoField(input, 'Backspace')
|
||||
expect(input.value).toBe('coin')
|
||||
expect(input.selectionStart).toBe(0)
|
||||
})
|
||||
|
||||
it('arrow keys move the caret without changing the value', () => {
|
||||
input.value = 'abc'
|
||||
input.selectionStart = input.selectionEnd = 3
|
||||
typeKeyIntoField(input, 'ArrowLeft')
|
||||
expect(input.selectionStart).toBe(2)
|
||||
expect(input.value).toBe('abc')
|
||||
})
|
||||
|
||||
it('Enter on a single-line input is left for the app to handle', () => {
|
||||
input.value = 'query'
|
||||
input.selectionStart = input.selectionEnd = 5
|
||||
const consumed = typeKeyIntoField(input, 'Enter')
|
||||
expect(consumed).toBe(false)
|
||||
expect(input.value).toBe('query')
|
||||
})
|
||||
|
||||
it('Enter inserts a newline in a textarea', () => {
|
||||
const area = document.createElement('textarea')
|
||||
area.value = 'a'
|
||||
area.selectionStart = area.selectionEnd = 1
|
||||
expect(typeKeyIntoField(area, 'Enter')).toBe(true)
|
||||
expect(area.value).toBe('a\n')
|
||||
})
|
||||
|
||||
it('non-text keys are not consumed as editing', () => {
|
||||
input.value = 'x'
|
||||
input.selectionStart = input.selectionEnd = 1
|
||||
expect(typeKeyIntoField(input, 'Escape')).toBe(false)
|
||||
expect(typeKeyIntoField(input, 'Tab')).toBe(false)
|
||||
expect(input.value).toBe('x')
|
||||
})
|
||||
})
|
||||
@@ -102,7 +102,15 @@ class FileBrowserClient {
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to list directory: ${res.status}`)
|
||||
if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`)
|
||||
// When File Browser isn't installed, nginx falls through to the SPA and
|
||||
// returns index.html (200, text/html); when it's down it returns 502.
|
||||
// Either way res.json() would throw the opaque "Unexpected token '<'"
|
||||
// error, so detect a non-JSON body and surface a friendly message instead.
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error('File Browser is not available — install or start the File Browser app to use your folders')
|
||||
}
|
||||
const data: FileBrowserListResponse = await res.json()
|
||||
return (data.items || []).map((item) => ({
|
||||
...item,
|
||||
|
||||
@@ -98,6 +98,103 @@ function mapKey(xdotoolKey: string): string {
|
||||
return KEY_MAP[xdotoolKey] ?? xdotoolKey
|
||||
}
|
||||
|
||||
/** <input> types that accept free-text entry (so we should type into them). */
|
||||
const TEXT_INPUT_TYPES = new Set([
|
||||
'text', 'search', 'url', 'tel', 'password', 'email', 'number', '',
|
||||
])
|
||||
|
||||
export function isTextField(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement {
|
||||
if (!el) return false
|
||||
if (el.tagName === 'TEXTAREA') return true
|
||||
if (el.tagName === 'INPUT') {
|
||||
const type = ((el as HTMLInputElement).type || 'text').toLowerCase()
|
||||
return TEXT_INPUT_TYPES.has(type)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* elementFromPoint that descends through SAME-ORIGIN iframes, so the cursor
|
||||
* can target elements *inside* embedded apps (gitea, uptime-kuma, AIUI — any
|
||||
* app served same-origin via /app/… or /aiui/). Cross-origin iframes (apps on
|
||||
* direct ports) are opaque to the parent by browser security policy, so the
|
||||
* deepest reachable element there is the <iframe> itself.
|
||||
*/
|
||||
function deepElementFromPoint(x: number, y: number): Element | null {
|
||||
let cx = x
|
||||
let cy = y
|
||||
let el = document.elementFromPoint(cx, cy)
|
||||
let guard = 0
|
||||
while (el && el.tagName === 'IFRAME' && guard++ < 5) {
|
||||
let doc: Document | null = null
|
||||
try { doc = (el as HTMLIFrameElement).contentDocument } catch { break }
|
||||
if (!doc) break
|
||||
const rect = el.getBoundingClientRect()
|
||||
cx -= rect.left
|
||||
cy -= rect.top
|
||||
const inner = doc.elementFromPoint(cx, cy)
|
||||
if (!inner || inner === el) break
|
||||
el = inner
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
/** The actually-focused element, descending through same-origin iframes. */
|
||||
function deepActiveElement(): Element | null {
|
||||
let el: Element | null = document.activeElement
|
||||
let guard = 0
|
||||
while (el && el.tagName === 'IFRAME' && guard++ < 5) {
|
||||
let doc: Document | null = null
|
||||
try { doc = (el as HTMLIFrameElement).contentDocument } catch { break }
|
||||
if (!doc || !doc.activeElement || doc.activeElement === doc.body) break
|
||||
el = doc.activeElement
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a key to a focused text field. Synthetic KeyboardEvents do NOT mutate
|
||||
* input values (browser security), so we edit `.value` at the caret directly
|
||||
* and fire an `input` event so Vue v-model / reactive search pick it up.
|
||||
* Returns true if the key was consumed as text editing.
|
||||
*/
|
||||
export function typeKeyIntoField(el: HTMLInputElement | HTMLTextAreaElement, key: string): boolean {
|
||||
const value = el.value
|
||||
const start = el.selectionStart ?? value.length
|
||||
const end = el.selectionEnd ?? value.length
|
||||
const setCaret = (pos: number) => { try { el.selectionStart = el.selectionEnd = pos } catch { /* e.g. number inputs */ } }
|
||||
const replaceSelection = (text: string) => {
|
||||
el.value = value.slice(0, start) + text + value.slice(end)
|
||||
setCaret(start + text.length)
|
||||
}
|
||||
|
||||
if (key === 'Backspace') {
|
||||
if (start !== end) { el.value = value.slice(0, start) + value.slice(end); setCaret(start) }
|
||||
else if (start > 0) { el.value = value.slice(0, start - 1) + value.slice(end); setCaret(start - 1) }
|
||||
else return true
|
||||
} else if (key === 'Delete') {
|
||||
if (start !== end) { el.value = value.slice(0, start) + value.slice(end); setCaret(start) }
|
||||
else { el.value = value.slice(0, start) + value.slice(start + 1); setCaret(start) }
|
||||
} else if (key === 'ArrowLeft') {
|
||||
setCaret(Math.max(0, start - 1))
|
||||
} else if (key === 'ArrowRight') {
|
||||
setCaret(Math.min(value.length, end + 1))
|
||||
} else if (key === 'Home') {
|
||||
setCaret(0)
|
||||
} else if (key === 'End') {
|
||||
setCaret(value.length)
|
||||
} else if (key === 'Enter') {
|
||||
if (el.tagName === 'TEXTAREA') replaceSelection('\n')
|
||||
else return false // let the app's keydown handler act (e.g. search submit)
|
||||
} else if (key.length === 1) {
|
||||
replaceSelection(key) // printable character
|
||||
} else {
|
||||
return false // Tab / Escape / F-keys / etc. — not text editing
|
||||
}
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
return true
|
||||
}
|
||||
|
||||
function handleMessage(data: string) {
|
||||
let msg: { t: string; k?: string; x?: number; y?: number; b?: number; p?: number }
|
||||
try {
|
||||
@@ -125,9 +222,17 @@ function handleMessage(data: string) {
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage({ type: 'arcade-input', key, player, action: 'down' }, '*')
|
||||
}
|
||||
// Keep existing keydown/keyup for backward compat with non-arcade UI navigation
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
|
||||
document.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
|
||||
// Deliver the key to the actually-focused element (descending into
|
||||
// same-origin iframes) so it reaches embedded-app inputs and search
|
||||
// boxes, not just the top-level document.
|
||||
const focused = deepActiveElement()
|
||||
const keyTarget: EventTarget = focused ?? document
|
||||
keyTarget.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
// Synthetic key events never insert text, so edit the field directly.
|
||||
if (isTextField(focused)) {
|
||||
typeKeyIntoField(focused, key)
|
||||
}
|
||||
keyTarget.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
|
||||
break
|
||||
}
|
||||
case 'm': {
|
||||
@@ -135,16 +240,29 @@ function handleMessage(data: string) {
|
||||
break
|
||||
}
|
||||
case 'c': {
|
||||
const target = document.elementFromPoint(cursorX, cursorY)
|
||||
const target = deepElementFromPoint(cursorX, cursorY)
|
||||
if (target) {
|
||||
if (cursorEl) {
|
||||
cursorEl.style.background = 'rgba(247, 147, 26, 1)'
|
||||
setTimeout(() => { if (cursorEl) cursorEl.style.background = 'rgba(247, 147, 26, 0.7)' }, 150)
|
||||
}
|
||||
target.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true, cancelable: true,
|
||||
const eventInit: MouseEventInit = {
|
||||
bubbles: true, cancelable: true, view: window,
|
||||
clientX: cursorX, clientY: cursorY,
|
||||
}))
|
||||
}
|
||||
target.dispatchEvent(new MouseEvent('mousedown', eventInit))
|
||||
target.dispatchEvent(new MouseEvent('mouseup', eventInit))
|
||||
target.dispatchEvent(new MouseEvent('click', eventInit))
|
||||
// A synthetic click does NOT move keyboard focus the way a real click
|
||||
// does, so the app-store search box (and any input) would stay
|
||||
// unfocused and untypable. Explicitly focus the nearest focusable
|
||||
// element — for same-origin iframe targets this focuses inside the app.
|
||||
const focusable = (target.closest?.(
|
||||
'input, textarea, select, button, a[href], [contenteditable], [tabindex]',
|
||||
) ?? target) as HTMLElement
|
||||
if (typeof focusable.focus === 'function') {
|
||||
focusable.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
<Transition name="app-launcher">
|
||||
<div
|
||||
v-if="store.isOpen"
|
||||
class="fixed inset-0 z-[2400] flex items-center justify-center p-0 md:p-10"
|
||||
class="fixed inset-0 z-[2400] flex items-stretch justify-stretch p-0"
|
||||
@click.self="store.close()"
|
||||
>
|
||||
<!-- Backdrop - blur like spotlight -->
|
||||
<div class="app-launcher-backdrop absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
<div class="app-launcher-backdrop absolute inset-0 bg-black/75 backdrop-blur-md"></div>
|
||||
|
||||
<!-- Panel - inset with margins, glass style like spotlight -->
|
||||
<!-- Panel - full-screen overlay -->
|
||||
<div
|
||||
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-none md:rounded-2xl shadow-2xl"
|
||||
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-none shadow-2xl"
|
||||
:class="panelClasses"
|
||||
style="border-radius: 0;"
|
||||
>
|
||||
<!-- Header bar - sticky on mobile -->
|
||||
<div class="sticky top-0 z-10 flex items-center gap-3 border-b border-white/10 px-4 py-3 bg-black/60 backdrop-blur-md md:bg-transparent md:backdrop-blur-none">
|
||||
@@ -69,7 +70,7 @@
|
||||
<!-- Loading indicator -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="iframeLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<svg class="animate-spin h-8 w-8 text-white/70" xmlns="http://www.w3.org/2000/svg" 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>
|
||||
@@ -398,7 +399,7 @@ function injectScrollbarHideIfSameOrigin() {
|
||||
const panelClasses = [
|
||||
'glass-card',
|
||||
'w-full h-full',
|
||||
'md:max-w-[calc(100vw-5rem)] md:max-h-[calc(100vh-5rem)]',
|
||||
'max-w-none max-h-none',
|
||||
]
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
/** Shape of GET /electrs-status (see core electrs_status.rs ElectrsSyncStatus). */
|
||||
export interface ElectrsSyncStatus {
|
||||
indexed_height: number
|
||||
bitcoin_height: number
|
||||
network_height: number
|
||||
progress_pct: number
|
||||
status: string // "starting" | "waiting" | "syncing" | "indexing" | "synced" | "error"
|
||||
stale: boolean
|
||||
error: string | null
|
||||
index_size: string | null
|
||||
tor_onion: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls GET /electrs-status while active. Used to show an ElectrumX sync screen
|
||||
* *before* the real Electrum UI — the Electrum server only accepts client
|
||||
* connections (and the UI only works) once the on-chain index is built, which
|
||||
* is a long initial process.
|
||||
*
|
||||
* Fails OPEN: if the status can't be fetched we report not-syncing, so a status
|
||||
* outage never blocks the normal iframe path. We only gate the UI when we
|
||||
* positively know the index is still being built (status !== "synced").
|
||||
*/
|
||||
export function useElectrsSync() {
|
||||
const status = ref<ElectrsSyncStatus | null>(null)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch('/electrs-status', { cache: 'no-store' })
|
||||
if (res.ok) status.value = (await res.json()) as ElectrsSyncStatus
|
||||
} catch {
|
||||
/* keep last known value; fail open */
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (timer) return
|
||||
void poll()
|
||||
timer = setInterval(() => void poll(), 8000)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** True only when we know ElectrumX is still building its index. */
|
||||
const syncing = computed(() => !!status.value && status.value.status !== 'synced')
|
||||
|
||||
onUnmounted(stop)
|
||||
return { status, syncing, start, stop }
|
||||
}
|
||||
@@ -84,12 +84,18 @@ const router = createRouter({
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
// The kiosk display no longer has its own launcher screen. It runs the
|
||||
// normal app (onboarding → login → dashboard) like any other client.
|
||||
// This route only persists kiosk mode + safe-area insets, then redirects
|
||||
// to the root app. The launcher still points Chromium here (not directly
|
||||
// at `/`) so the 'kiosk' flag gets set — App.vue uses it to skip the
|
||||
// remote relay, which would otherwise double xdotool input on the kiosk
|
||||
// display. Public so the auth guard doesn't bounce us before beforeEnter.
|
||||
path: '/kiosk',
|
||||
name: 'kiosk',
|
||||
component: () => import('../views/Kiosk.vue'),
|
||||
meta: { public: true },
|
||||
component: () => import('../views/RootRedirect.vue'),
|
||||
beforeEnter: (to) => {
|
||||
// Persist kiosk mode before redirect so App.vue can skip the remote relay
|
||||
// (relay duplicates xdotool input on the kiosk display)
|
||||
localStorage.setItem('kiosk', 'true')
|
||||
const safeArea = to.query.safe_area
|
||||
const safeAreaPx = Array.isArray(safeArea) ? safeArea[0] : safeArea
|
||||
@@ -106,6 +112,8 @@ const router = createRouter({
|
||||
if (safeAreaYPx && /^\d{1,3}$/.test(safeAreaYPx)) {
|
||||
localStorage.setItem('archipelago_kiosk_safe_area_y_px', safeAreaYPx)
|
||||
}
|
||||
// Grid screen removed — hand off to the normal app flow.
|
||||
return { path: '/' }
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock the rpc-client module
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
vpnStatus: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { useHomeStatusStore } from '../homeStatus'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
function pkg(state: string): Record<string, PackageDataEntry> {
|
||||
return { 'bitcoin-knots': { state } as unknown as PackageDataEntry }
|
||||
}
|
||||
|
||||
describe('homeStatus — B16 bitcoin sync status retain (no vanish, no stale-as-live)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('records a successful poll as available + not stale', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 1 })
|
||||
await store.refreshBitcoin({})
|
||||
expect(store.stats.bitcoinAvailable).toBe(true)
|
||||
expect(store.stats.bitcoinSyncPercent).toBe(100)
|
||||
expect(store.bitcoinStale).toBe(false)
|
||||
expect(store.bitcoinLoadState).toBe('ready')
|
||||
})
|
||||
|
||||
it('keeps the tile visible (available) but marks stale when getinfo fails while the container is Running', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
// First a good poll so we have real sync numbers.
|
||||
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 0.5 })
|
||||
await store.refreshBitcoin(pkg(PackageState.Running))
|
||||
expect(store.stats.bitcoinAvailable).toBe(true)
|
||||
|
||||
// Now a transient RPC failure (e.g. RPC busy during heavy IBD) — container still Running.
|
||||
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
|
||||
await store.refreshBitcoin(pkg(PackageState.Running))
|
||||
expect(store.stats.bitcoinAvailable).toBe(true) // does NOT vanish
|
||||
expect(store.bitcoinStale).toBe(true) // shown as "Updating…", not live
|
||||
expect(store.stats.bitcoinSyncPercent).toBe(50) // last-known retained
|
||||
})
|
||||
|
||||
it('flips to NOT available (and not stale) when getinfo fails and the container is Stopped — no stale-as-live', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 1 })
|
||||
await store.refreshBitcoin(pkg(PackageState.Running))
|
||||
expect(store.stats.bitcoinAvailable).toBe(true)
|
||||
|
||||
mockedRpc.call.mockRejectedValueOnce(new Error('refused'))
|
||||
await store.refreshBitcoin(pkg(PackageState.Stopped))
|
||||
expect(store.stats.bitcoinAvailable).toBe(false) // genuinely down → reflect it
|
||||
expect(store.bitcoinStale).toBe(false) // not "Updating…": it's authoritatively stopped
|
||||
})
|
||||
|
||||
it('retains the last-known available value (marked stale) when package data is momentarily absent', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 1 })
|
||||
await store.refreshBitcoin(pkg(PackageState.Running))
|
||||
expect(store.stats.bitcoinAvailable).toBe(true)
|
||||
|
||||
// getinfo fails AND the packages map has no authoritative bitcoin entry (route change / scan).
|
||||
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
|
||||
await store.refreshBitcoin({})
|
||||
expect(store.stats.bitcoinAvailable).toBe(true) // retained, does NOT flash "Not running"
|
||||
expect(store.bitcoinStale).toBe(true)
|
||||
expect(store.bitcoinLoadState).toBe('ready')
|
||||
})
|
||||
|
||||
it('stays unknown (null) without fabricating availability when the first ever poll fails with no package data', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
|
||||
await store.refreshBitcoin({})
|
||||
expect(store.stats.bitcoinAvailable).toBeNull() // nothing known yet — don't invent a tile
|
||||
expect(store.bitcoinLoadState).toBe('error')
|
||||
})
|
||||
|
||||
it('marks bitcoin available + stale when the first poll times out but the container is Running (syncing node)', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
// No prior success; getinfo times out during heavy initial sync, but container is up.
|
||||
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
|
||||
await store.refreshBitcoin(pkg(PackageState.Running))
|
||||
expect(store.stats.bitcoinAvailable).toBe(true) // tile appears instead of staying hidden
|
||||
expect(store.bitcoinStale).toBe(true) // labeled "Updating…" since we have no live numbers yet
|
||||
})
|
||||
})
|
||||
@@ -43,6 +43,10 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
|
||||
const stats = reactive<SystemStatsSnapshot>(emptyStats())
|
||||
const systemLoadState = ref<LoadState>('idle')
|
||||
const bitcoinLoadState = ref<LoadState>('idle')
|
||||
// True when we're showing a retained (last-known) bitcoin value because the
|
||||
// latest poll failed transiently — the UI renders an "Updating…" badge so the
|
||||
// figure is never presented as live, and the tile never vanishes mid-sync.
|
||||
const bitcoinStale = ref(false)
|
||||
const vpnLoadState = ref<LoadState>('idle')
|
||||
const fipsLoadState = ref<LoadState>('idle')
|
||||
const lastSystemRefreshAt = ref<number | null>(null)
|
||||
@@ -109,26 +113,34 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
|
||||
stats.bitcoinSyncPercent = (btc.sync_progress ?? 0) * 100
|
||||
stats.bitcoinBlockHeight = btc.block_height ?? 0
|
||||
stats.bitcoinAvailable = true
|
||||
bitcoinStale.value = false
|
||||
bitcoinLoadState.value = 'ready'
|
||||
lastBitcoinRefreshAt.value = Date.now()
|
||||
} catch {
|
||||
const btcPkg = packages['bitcoin-knots'] || packages['bitcoin-core'] || packages.bitcoin
|
||||
if (btcPkg?.state === PackageState.Running) {
|
||||
// Container is up but the RPC call failed (busy during heavy IBD, etc.).
|
||||
// Keep the tile visible with the last-known figures, marked as updating.
|
||||
stats.bitcoinAvailable = true
|
||||
bitcoinStale.value = true
|
||||
bitcoinLoadState.value = 'ready'
|
||||
lastBitcoinRefreshAt.value = Date.now()
|
||||
return
|
||||
}
|
||||
|
||||
if (btcPkg && (btcPkg.state === PackageState.Stopped || btcPkg.state === PackageState.Exited)) {
|
||||
// Authoritatively down — reflect it (do NOT keep showing stale data as live).
|
||||
stats.bitcoinAvailable = false
|
||||
bitcoinStale.value = false
|
||||
bitcoinLoadState.value = 'ready'
|
||||
lastBitcoinRefreshAt.value = Date.now()
|
||||
return
|
||||
}
|
||||
|
||||
// No authoritative package data yet. Keep the previous known value
|
||||
// rather than flashing "Not running" during route changes/scans.
|
||||
// rather than flashing "Not running" during route changes/scans; if we
|
||||
// had a value, surface it as "updating" instead of presenting it as live.
|
||||
if (stats.bitcoinAvailable !== null) bitcoinStale.value = true
|
||||
bitcoinLoadState.value = stats.bitcoinAvailable === null ? 'error' : 'ready'
|
||||
}
|
||||
}
|
||||
@@ -186,6 +198,7 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
|
||||
stats,
|
||||
systemLoadState,
|
||||
bitcoinLoadState,
|
||||
bitcoinStale,
|
||||
vpnLoadState,
|
||||
fipsLoadState,
|
||||
systemStatsLoaded,
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
.glass-card:focus-visible,
|
||||
.sidebar-nav-item:focus-visible,
|
||||
.path-option-card:focus-visible,
|
||||
.kiosk-app-tile:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
@@ -556,8 +555,8 @@ input[type="radio"]:active + * {
|
||||
context) stay above the tab bar instead of sliding underneath it. */
|
||||
@media (max-width: 767px) {
|
||||
.chat-iframe-mobile {
|
||||
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px))) !important;
|
||||
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px))) !important;
|
||||
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
|
||||
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,44 @@ describe('explainReceiveAddressFailure', () => {
|
||||
it('explains lnd transport failures', () => {
|
||||
expect(explainReceiveAddressFailure(new Error('LND REST connection failed'))).toContain('not responding cleanly')
|
||||
})
|
||||
|
||||
it('keeps bitcoin address generation failures visible', () => {
|
||||
expect(explainReceiveAddressFailure(new Error('Bitcoin address generation failed: LND is not ready'))).toContain('Bitcoin address generation failed')
|
||||
})
|
||||
|
||||
describe('structured reason codes (backend [CODE] token)', () => {
|
||||
it('maps an unreachable REST endpoint to a starting/recovering message — NOT locked (.228 regression)', () => {
|
||||
const msg = explainReceiveAddressFailure(
|
||||
new Error('Bitcoin address unavailable [LND_REST_UNREACHABLE]: service not reachable'),
|
||||
)
|
||||
expect(msg).toContain('starting up or recovering')
|
||||
expect(msg.toLowerCase()).not.toContain('locked')
|
||||
})
|
||||
|
||||
it('maps a genuinely locked wallet to the locked message', () => {
|
||||
expect(
|
||||
explainReceiveAddressFailure(new Error('Bitcoin address unavailable [LND_WALLET_LOCKED]: locked')),
|
||||
).toContain('locked')
|
||||
})
|
||||
|
||||
it('maps an uninitialized wallet to the setup message', () => {
|
||||
expect(
|
||||
explainReceiveAddressFailure(new Error('Bitcoin address unavailable [LND_WALLET_UNINITIALIZED]: x')),
|
||||
).toContain('has not been set up')
|
||||
})
|
||||
|
||||
it('maps a syncing wallet to the syncing message', () => {
|
||||
expect(
|
||||
explainReceiveAddressFailure(new Error('Bitcoin address unavailable [LND_SYNCING]: x')),
|
||||
).toContain('syncing')
|
||||
})
|
||||
|
||||
it('prefers the code over substrings even when the detail text is misleading', () => {
|
||||
// Detail mentions neither "locked" nor "unlock"; code must still win.
|
||||
const msg = explainReceiveAddressFailure(
|
||||
new Error('Bitcoin address unavailable [LND_REST_UNREACHABLE]: wallet service down'),
|
||||
)
|
||||
expect(msg).toContain('starting up or recovering')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
// Machine-readable reason codes the backend embeds as a `[CODE]` token in
|
||||
// receive-address errors (see core .../api/rpc/lnd/wallet.rs). Mapping the code
|
||||
// directly is precise — unlike the substring heuristics below, it cannot
|
||||
// mislabel an unreachable-REST failure as "wallet is locked" (the .228 bug).
|
||||
const RECEIVE_CODE_MESSAGES: Record<string, string> = {
|
||||
LND_REST_UNREACHABLE:
|
||||
'Bitcoin address is not ready yet because the Lightning wallet service is still starting up or recovering. Please try again in a moment.',
|
||||
LND_WALLET_LOCKED:
|
||||
'Bitcoin address is not ready because the Lightning wallet is locked. Unlock or initialize LND first.',
|
||||
LND_WALLET_UNINITIALIZED:
|
||||
'Bitcoin address is not ready because the Lightning wallet has not been set up yet. Finish wallet setup, then try again.',
|
||||
LND_SYNCING:
|
||||
'Bitcoin address is not ready while the wallet is still syncing with the Bitcoin network. Try again once sync has progressed.',
|
||||
LND_ERROR:
|
||||
'Bitcoin address is not ready yet. Check that the Lightning app is healthy, then try again.',
|
||||
}
|
||||
|
||||
export function explainReceiveAddressFailure(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error || '')
|
||||
|
||||
// Prefer the structured reason code when present.
|
||||
const code = message.match(/\[([A-Z_]+)\]/)?.[1]
|
||||
if (code && RECEIVE_CODE_MESSAGES[code]) {
|
||||
return RECEIVE_CODE_MESSAGES[code]
|
||||
}
|
||||
|
||||
const lower = message.toLowerCase()
|
||||
|
||||
if (lower.includes('wallet') && (lower.includes('locked') || lower.includes('unlock'))) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Open a URL in the device's real browser.
|
||||
*
|
||||
* In a normal mobile/desktop browser this is just `window.open(_blank)`. Inside
|
||||
* the Android companion app the page runs in a WebView where `window.open` is
|
||||
* unreliable (noopener/noreferrer can suppress onCreateWindow), so the native
|
||||
* shell injects a `window.ArchipelagoNative.openExternal(url)` bridge that hands
|
||||
* the URL to an ACTION_VIEW intent. We prefer the bridge when present and fall
|
||||
* back to `window.open` otherwise — so the working mobile-browser path is
|
||||
* untouched.
|
||||
*/
|
||||
interface ArchipelagoNativeBridge {
|
||||
openExternal?: (url: string) => void
|
||||
}
|
||||
|
||||
export function openExternalUrl(url: string): void {
|
||||
if (!url) return
|
||||
const native = (window as unknown as { ArchipelagoNative?: ArchipelagoNativeBridge })
|
||||
.ArchipelagoNative
|
||||
if (native && typeof native.openExternal === 'function') {
|
||||
native.openExternal(url)
|
||||
return
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
@@ -34,6 +34,7 @@
|
||||
:refresh-key="refreshKey"
|
||||
:blocked-reason="blockedReason"
|
||||
:blocked-title="blockedTitle"
|
||||
:electrs-sync="electrsSync"
|
||||
@iframe-load="onLoad"
|
||||
@iframe-error="onError"
|
||||
@refresh="refresh"
|
||||
@@ -106,6 +107,8 @@ import {
|
||||
import { launchBlockedReason } from './apps/appsConfig'
|
||||
import { useAppIdentity } from './appSession/useAppIdentity'
|
||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||
import { openExternalUrl } from '@/utils/openExternal'
|
||||
import { useElectrsSync } from '@/composables/useElectrsSync'
|
||||
|
||||
const props = defineProps<{
|
||||
appIdProp?: string
|
||||
@@ -155,6 +158,23 @@ const blockedReason = computed(() => launchBlockedReason(appId.value, packageEnt
|
||||
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||
|
||||
// ElectrumX shows a sync screen before its real UI (the Electrum server only
|
||||
// serves clients once its index is built). Poll /electrs-status while this is
|
||||
// the Electrum app; pass the status to the frame only while still syncing.
|
||||
const isElectrsApp = computed(() =>
|
||||
['electrumx', 'electrs-ui', 'archy-electrs-ui'].includes(appId.value)
|
||||
)
|
||||
const { status: electrsStatus, syncing: electrsSyncing, start: startElectrsPoll, stop: stopElectrsPoll } =
|
||||
useElectrsSync()
|
||||
const electrsSync = computed(() =>
|
||||
isElectrsApp.value && electrsSyncing.value ? electrsStatus.value : null
|
||||
)
|
||||
watch(
|
||||
isElectrsApp,
|
||||
(on) => { if (on) startElectrsPoll(); else stopElectrsPoll() },
|
||||
{ immediate: true }
|
||||
)
|
||||
const screensaverReason = computed(() => `app-session:${appId.value}`)
|
||||
const screensaverSuppressedApps = new Set([
|
||||
'indeedhub',
|
||||
@@ -294,12 +314,12 @@ function startLoadTimeout() {
|
||||
}
|
||||
|
||||
function openNewTabAndBack() {
|
||||
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||
closeSession()
|
||||
}
|
||||
|
||||
function openNewTab() {
|
||||
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||
}
|
||||
|
||||
function iframeGoBack() {
|
||||
|
||||
@@ -244,10 +244,10 @@
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="credentialModal.show"
|
||||
class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 md:p-6"
|
||||
class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/80 backdrop-blur-md p-4"
|
||||
@click.self="closeCredentialModal"
|
||||
>
|
||||
<div class="sideload-modal credential-modal">
|
||||
<div class="credential-modal-panel">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
|
||||
@@ -259,7 +259,7 @@
|
||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-blue-300 hover:text-blue-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
@@ -802,15 +802,29 @@ async function submitSideload() {
|
||||
}
|
||||
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
|
||||
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
|
||||
.credential-modal {
|
||||
.credential-modal-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem);
|
||||
border-radius: 1.25rem;
|
||||
padding-bottom: 1.25rem;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
/* Centered card that never exceeds the visible viewport (minus safe areas),
|
||||
matching the wallet receive modal / AppIconGrid credential modal. The body
|
||||
scrolls if content overflows rather than the panel stretching edge-to-edge. */
|
||||
max-height: calc(
|
||||
100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) -
|
||||
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem
|
||||
);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(8, 10, 18, 0.98);
|
||||
padding: 1.25rem;
|
||||
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.credential-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -818,11 +832,4 @@ async function submitSideload() {
|
||||
.credential-modal-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.sideload-modal {
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="chat-fullscreen">
|
||||
<!-- Close button (desktop: top-right pill) -->
|
||||
<!-- Close button + connection indicator (desktop: top-right pill) -->
|
||||
<div class="chat-mode-pill hidden md:flex">
|
||||
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
|
||||
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -8,11 +8,16 @@
|
||||
</svg>
|
||||
<span class="text-xs font-medium">{{ t('chat.close') }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="aiuiConnected"
|
||||
class="w-2 h-2 rounded-full bg-green-400 ml-2 shadow-[0_0_6px_rgba(74,222,128,0.5)]"
|
||||
:title="t('chat.aiuiConnected')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator while checking availability -->
|
||||
<!-- Loading indicator while iframe loads -->
|
||||
<Transition name="fade">
|
||||
<div v-if="aiuiAvailable === null" class="chat-loading" role="status" aria-live="polite">
|
||||
<div v-if="aiuiUrl && !aiuiConnected" class="chat-loading" role="status" aria-live="polite">
|
||||
<div class="glass-card p-8 flex flex-col items-center gap-4">
|
||||
<div class="chat-loading-spinner" aria-hidden="true" />
|
||||
<p class="text-sm text-white/60">{{ t('chat.loadingAssistant') }}</p>
|
||||
@@ -27,24 +32,23 @@
|
||||
:src="aiuiUrl"
|
||||
:title="t('chat.aiAssistant')"
|
||||
class="chat-iframe chat-iframe-mobile"
|
||||
|
||||
allow="microphone"
|
||||
style="background: transparent"
|
||||
/>
|
||||
|
||||
<!-- Fallback when AIUI is not deployed -->
|
||||
<div v-else-if="aiuiAvailable === false" class="chat-placeholder">
|
||||
<div class="chat-placeholder-inner glass-card p-8">
|
||||
<!-- Fallback when no AIUI URL configured -->
|
||||
<div v-else class="chat-placeholder">
|
||||
<div class="chat-placeholder-inner">
|
||||
<div class="chat-placeholder-icon">
|
||||
<svg class="w-8 h-8 text-white/40" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('chat.aiAssistant') }}</h2>
|
||||
<p class="text-white/80 mb-4 leading-relaxed">
|
||||
<p class="text-white/60 mb-4 leading-relaxed">
|
||||
{{ t('chat.notConfigured') }}
|
||||
</p>
|
||||
<p class="text-sm text-white/50">
|
||||
<p class="text-xs text-white/30">
|
||||
{{ t('chat.deployCta') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -63,37 +67,16 @@ const { t } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
|
||||
const aiuiConnected = ref(false)
|
||||
let broker: ContextBroker | null = null
|
||||
|
||||
const aiuiAvailable = ref<boolean | null>(null) // null = checking, true/false = result
|
||||
|
||||
const aiuiUrl = computed(() => {
|
||||
const envUrl = import.meta.env.VITE_AIUI_URL
|
||||
if (envUrl) return `${envUrl}?embedded=true&hideClose=true`
|
||||
// In production, only return the URL if we've confirmed AIUI files exist
|
||||
if (import.meta.env.PROD && aiuiAvailable.value === true) return `/aiui/?embedded=true&hideClose=true&v=${Date.now()}`
|
||||
if (import.meta.env.PROD) return '/aiui/?embedded=true&hideClose=true'
|
||||
return ''
|
||||
})
|
||||
|
||||
/** Check if AIUI is actually deployed by fetching its index.html */
|
||||
async function checkAiuiAvailable() {
|
||||
if (import.meta.env.VITE_AIUI_URL) {
|
||||
aiuiAvailable.value = true
|
||||
return
|
||||
}
|
||||
if (!import.meta.env.PROD) {
|
||||
aiuiAvailable.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/aiui/', { method: 'HEAD' })
|
||||
// If we get HTML back (200), AIUI is deployed. If 404/403, it's not.
|
||||
aiuiAvailable.value = res.ok
|
||||
} catch {
|
||||
aiuiAvailable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeChat() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
@@ -111,13 +94,12 @@ function onAiuiMessage(event: MessageEvent) {
|
||||
} catch { return }
|
||||
// Listen for ready messages from AIUI iframe
|
||||
if (event.data?.type === 'ready') {
|
||||
// AIUI connected - could use for future features
|
||||
aiuiConnected.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', onAiuiMessage)
|
||||
await checkAiuiAvailable()
|
||||
if (aiuiUrl.value) {
|
||||
broker = new ContextBroker(aiuiFrame, aiuiUrl.value)
|
||||
broker.start()
|
||||
|
||||
@@ -86,8 +86,8 @@
|
||||
<div
|
||||
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
|
||||
:class="[
|
||||
'h-full dashboard-scroll-panel mobile-scroll-pad',
|
||||
route.path === '/dashboard/mesh' ? 'mesh-dashboard-panel' : '',
|
||||
'h-full',
|
||||
route.path === '/dashboard/mesh' ? 'dashboard-scroll-panel mobile-scroll-pad mesh-dashboard-panel' : '',
|
||||
mobileTabPaddingTop ? 'overflow-y-auto' : ''
|
||||
]"
|
||||
:style="{ paddingTop: mobileTabPaddingTop ? (mobileTabPaddingTop + 16) + 'px' : undefined }"
|
||||
|
||||
@@ -482,7 +482,7 @@ const cloudFolderDisplay = computed(() => cloudFolderCount.value !== null ? Stri
|
||||
|
||||
onMounted(async () => {
|
||||
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
|
||||
loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 30000); checkUpdateStatus(); loadWeb5Status()
|
||||
loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 10000); checkUpdateStatus(); loadWeb5Status()
|
||||
})
|
||||
|
||||
// Wallet modals
|
||||
@@ -506,6 +506,7 @@ const systemStatsLoaded = computed(() => homeStatus.systemStatsLoaded)
|
||||
const systemStats = computed(() => ({
|
||||
...homeStatus.stats,
|
||||
bitcoinAvailable: homeStatus.stats.bitcoinAvailable === true,
|
||||
bitcoinStale: homeStatus.bitcoinStale,
|
||||
}))
|
||||
const systemUptimeDisplay = computed(() => { if (homeStatus.stats.uptimeSecs === 0) return t('home.systemMonitoring'); const days = Math.floor(homeStatus.stats.uptimeSecs / 86400); const hours = Math.floor((homeStatus.stats.uptimeSecs % 86400) / 3600); if (days > 0) return `Uptime: ${days}d ${hours}h`; const mins = Math.floor((homeStatus.stats.uptimeSecs % 3600) / 60); return `Uptime: ${hours}h ${mins}m` })
|
||||
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
<template>
|
||||
<div class="kiosk-root" tabindex="0" ref="kioskRoot">
|
||||
<!-- Kiosk launcher grid -->
|
||||
<div class="kiosk-launcher">
|
||||
<!-- Header -->
|
||||
<div class="kiosk-header">
|
||||
<div class="flex items-center gap-4">
|
||||
<img :src="FALLBACK_ICON" alt="Archipelago" class="w-10 h-10" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white font-archipelago">Archipelago</h1>
|
||||
<p class="text-sm text-white/50">{{ currentTime }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="kiosk-status-pill" :class="isConnected ? 'status-success' : 'status-error'">
|
||||
<div class="w-2 h-2 rounded-full" :class="isConnected ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
{{ isConnected ? t('kiosk.online') : t('kiosk.offline') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App grid -->
|
||||
<div class="kiosk-grid">
|
||||
<button
|
||||
v-for="app in launchableApps"
|
||||
:key="app.id"
|
||||
class="kiosk-app-tile"
|
||||
@click="openApp(app)"
|
||||
:data-controller-focusable="true"
|
||||
>
|
||||
<div class="kiosk-app-icon-wrap">
|
||||
<img
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="kiosk-app-icon"
|
||||
@error="($event.target as HTMLImageElement).src = FALLBACK_ICON"
|
||||
/>
|
||||
<div
|
||||
class="kiosk-app-status"
|
||||
:class="app.running ? 'bg-green-400' : 'bg-white/30'"
|
||||
/>
|
||||
</div>
|
||||
<span class="kiosk-app-label">{{ app.title }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="kiosk-footer">
|
||||
<span class="text-white/30 text-sm">{{ t('kiosk.navHint') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useAppStore()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
const kioskRoot = ref<HTMLElement | null>(null)
|
||||
|
||||
interface KioskApp {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
url: string
|
||||
running: boolean
|
||||
}
|
||||
|
||||
// Public asset path — construct with BASE_URL to avoid Vite resolving it as a module import
|
||||
const FALLBACK_ICON = `${import.meta.env.BASE_URL}assets/img/favico.png`
|
||||
|
||||
const currentTime = ref('')
|
||||
|
||||
const isConnected = computed(() => store.isConnected)
|
||||
|
||||
// Build list of launchable apps from the store's package data
|
||||
const launchableApps = computed<KioskApp[]>(() => {
|
||||
const pkgs = store.data?.['package-data'] || {}
|
||||
const apps: KioskApp[] = []
|
||||
|
||||
// App URL mappings. Bitcoin UI uses its direct host-network port; loading it
|
||||
// through /app/bitcoin-ui/ can render a blank shell because its assets are
|
||||
// rooted at /.
|
||||
const urlMap: Record<string, string> = {
|
||||
'bitcoin-knots': 'http://' + window.location.hostname + ':8334',
|
||||
'lnd': '/app/lnd/',
|
||||
'mempool': '/app/mempool/',
|
||||
'btcpay-server': '/app/btcpay/',
|
||||
'homeassistant': '/app/homeassistant/',
|
||||
'grafana': '/app/grafana/',
|
||||
'jellyfin': '/app/jellyfin/',
|
||||
'nextcloud': '/app/nextcloud/',
|
||||
'immich': '/app/immich/',
|
||||
'photoprism': '/app/photoprism/',
|
||||
'vaultwarden': '/app/vaultwarden/',
|
||||
'filebrowser': '/app/filebrowser/',
|
||||
'searxng': '/app/searxng/',
|
||||
'ollama': '/app/ollama/',
|
||||
'portainer': '/app/portainer/',
|
||||
'uptime-kuma': '/app/uptime-kuma/',
|
||||
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
|
||||
'tailscale': '/app/tailscale/',
|
||||
'fedimint': '/app/fedimint/',
|
||||
'fedimint-gateway': '/app/fedimint-gateway/',
|
||||
'indeedhub': 'http://localhost:7778',
|
||||
'botfights': 'http://localhost:9100',
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
'call-the-operator': 'https://cta.tx1138.com',
|
||||
'arch-presentation': 'https://present.l484.com',
|
||||
'syntropy-institute': 'https://syntropy.institute',
|
||||
't-zero': 'https://teeminuszero.net',
|
||||
}
|
||||
|
||||
for (const [id, pkg] of Object.entries(pkgs)) {
|
||||
const url = urlMap[id]
|
||||
if (!url) continue
|
||||
|
||||
const isRunning = pkg.state === 'running' ||
|
||||
pkg.installed?.status === 'running'
|
||||
|
||||
apps.push({
|
||||
id,
|
||||
title: pkg.manifest?.title || id,
|
||||
icon: pkg['static-files']?.icon || FALLBACK_ICON,
|
||||
url,
|
||||
running: isRunning,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort: running apps first, then alphabetical
|
||||
return apps.sort((a, b) => {
|
||||
if (a.running !== b.running) return a.running ? -1 : 1
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
})
|
||||
|
||||
function openApp(app: KioskApp) {
|
||||
// Delegate to the app launcher — handles iframe overlay vs new-tab
|
||||
appLauncher.open({ url: app.url, title: app.title })
|
||||
}
|
||||
|
||||
// Clock updater
|
||||
let clockInterval: ReturnType<typeof setInterval> | undefined
|
||||
function updateClock() {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateClock()
|
||||
clockInterval = setInterval(updateClock, 30000)
|
||||
kioskRoot.value?.focus()
|
||||
|
||||
// Connect WebSocket if not already
|
||||
if (!store.isConnected) {
|
||||
store.connectWebSocket().catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clockInterval) clearInterval(clockInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kiosk-root {
|
||||
position: fixed;
|
||||
left: var(--kiosk-safe-area-x, 0px);
|
||||
top: var(--kiosk-safe-area-y, 0px);
|
||||
width: calc(100vw - (var(--kiosk-safe-area-x, 0px) * 2));
|
||||
height: calc(100vh - (var(--kiosk-safe-area-y, 0px) * 2));
|
||||
background: #000;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.kiosk-launcher {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: clamp(1rem, 3vh, 2rem) clamp(1.5rem, 4vw, 3rem);
|
||||
background: linear-gradient(180deg, #0a0a12 0%, #000 100%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.kiosk-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.kiosk-status-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kiosk-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 1.5rem;
|
||||
align-content: start;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.kiosk-app-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transition: all 0.25s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.kiosk-app-tile:hover,
|
||||
.kiosk-app-tile:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(251, 146, 60, 0.4);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 30px rgba(251, 146, 60, 0.15);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.kiosk-app-icon-wrap {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.kiosk-app-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.kiosk-app-status {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #000;
|
||||
}
|
||||
|
||||
.kiosk-app-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kiosk-footer {
|
||||
padding-top: 1.5rem;
|
||||
text-align: center;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -30,7 +30,15 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<h1 class="text-2xl font-bold text-white">{{ peerDisplayName }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-2xl font-bold text-white">{{ peerDisplayName }}</h1>
|
||||
<span
|
||||
v-if="transportPill"
|
||||
:class="transportPill.cls"
|
||||
:title="transportPill.title"
|
||||
class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
>{{ transportPill.label }}</span>
|
||||
</div>
|
||||
<p v-if="currentPeer?.did" class="text-sm text-white/50 font-mono truncate max-w-md" :title="currentPeer.did">{{ currentPeer.did }}</p>
|
||||
<p v-else class="text-sm text-white/50">Peer files</p>
|
||||
</div>
|
||||
@@ -43,7 +51,7 @@
|
||||
<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>
|
||||
<p class="text-white/50 text-sm">Connecting via Tor... This may take a few seconds.</p>
|
||||
<p class="text-white/50 text-sm">Connecting to peer… This may take a few seconds.</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
@@ -288,6 +296,23 @@ const catalogItems = ref<CatalogItem[]>([])
|
||||
const downloading = ref<string | null>(null)
|
||||
const playing = ref<string | null>(null)
|
||||
const purchaseError = ref<string | null>(null)
|
||||
// Transport actually used to reach this peer (returned by content.browse-peer)
|
||||
// so we can show a FIPS/Tor pill instead of always assuming Tor (B21).
|
||||
const transport = ref<string | null>(null)
|
||||
const transportPill = computed(() => {
|
||||
switch (transport.value) {
|
||||
case 'fips':
|
||||
return { label: 'FIPS', cls: 'bg-green-500/20 text-green-300', title: 'Connected over the fast encrypted mesh (FIPS)' }
|
||||
case 'mesh':
|
||||
return { label: 'Mesh', cls: 'bg-green-500/20 text-green-300', title: 'Connected over the mesh' }
|
||||
case 'lan':
|
||||
return { label: 'LAN', cls: 'bg-blue-500/20 text-blue-300', title: 'Connected over the local network' }
|
||||
case 'tor':
|
||||
return { label: 'Tor', cls: 'bg-amber-500/20 text-amber-300', title: 'Connected over Tor (slower)' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
const previewUrls = reactive<Record<string, string>>({})
|
||||
const audioPlayer = useAudioPlayer()
|
||||
|
||||
@@ -329,12 +354,13 @@ async function loadCatalog() {
|
||||
loading.value = true
|
||||
catalogError.value = ''
|
||||
try {
|
||||
const result = await rpcClient.call<{ items?: CatalogItem[] }>({
|
||||
const result = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
|
||||
method: 'content.browse-peer',
|
||||
params: { onion },
|
||||
timeout: 30000,
|
||||
})
|
||||
catalogItems.value = result?.items ?? []
|
||||
transport.value = result?.transport ?? null
|
||||
} catch (e: unknown) {
|
||||
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
|
||||
if (!hadItems) catalogItems.value = []
|
||||
@@ -511,6 +537,22 @@ async function playMedia(item: CatalogItem) {
|
||||
|
||||
const paid = isPaidItem(item.access)
|
||||
|
||||
// Free content: stream via the Range-capable proxy (B3) so the player can
|
||||
// seek and start instantly, instead of downloading the whole file as a
|
||||
// base64 blob into a non-seekable Blob URL (which broke video/large audio).
|
||||
if (!paid) {
|
||||
const streamUrl = `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(item.id)}`
|
||||
if (item.mime_type.startsWith('audio/')) {
|
||||
audioPlayer.play(streamUrl, item.filename.split('/').pop() || item.filename)
|
||||
} else if (item.mime_type.startsWith('video/')) {
|
||||
videoPlayerItem.value = item
|
||||
videoPlayerUrl.value = streamUrl
|
||||
videoPlayerPaid.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Paid content: use the preview/download flow below.
|
||||
// If we already have a preview blob URL, use it
|
||||
const existingUrl = previewUrls[item.id]
|
||||
if (existingUrl) {
|
||||
|
||||
@@ -9,8 +9,43 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- ElectrumX sync screen — shown before the real UI while the on-chain
|
||||
index is still being built (the Electrum server can't serve clients
|
||||
until then). Mirrors the Fedimint Guardian "wait page" design. -->
|
||||
<Transition name="content-fade">
|
||||
<!-- Sync overlay only while ElectrumX is actively indexing. If the status
|
||||
goes stale (ElectrumX disconnected/unresponsive) we stop blocking and
|
||||
let the app's own UI load instead of a loader stuck on top (B7). -->
|
||||
<div v-if="electrsSync && !electrsSync.stale" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8 w-full max-w-md">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-orange-300 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V7M4 7l8 5 8-5M4 7l8-4 8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ appTitle }} is syncing</h3>
|
||||
<p class="text-white/50 text-sm mb-5">
|
||||
ElectrumX is building its index from the blockchain. The UI opens
|
||||
automatically once it's ready — you can keep using the rest of Archipelago.
|
||||
</p>
|
||||
<div class="w-full h-2 rounded-full bg-white/10 overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full bg-orange-400/80 transition-all duration-700"
|
||||
:style="{ width: `${Math.min(100, Math.max(2, electrsSync.progress_pct)).toFixed(1)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm font-medium mb-1">{{ electrsSync.progress_pct.toFixed(1) }}%</p>
|
||||
<p class="text-white/40 text-xs">
|
||||
Block {{ electrsSync.indexed_height.toLocaleString() }} of {{ electrsSync.network_height.toLocaleString() }}
|
||||
<template v-if="electrsSync.index_size"> · {{ electrsSync.index_size }} indexed</template>
|
||||
</p>
|
||||
<p v-if="electrsSync.stale" class="text-yellow-400/70 text-xs mt-2">Reconnecting to ElectrumX…</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
v-if="appUrl && !iframeBlocked"
|
||||
v-if="appUrl && !iframeBlocked && (!electrsSync || electrsSync.stale)"
|
||||
class="absolute inset-0 app-session-frame-scroll-host"
|
||||
tabindex="-1"
|
||||
@pointerdown="focusIframe"
|
||||
@@ -28,9 +63,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Iframe blocked fallback -->
|
||||
<!-- Iframe blocked fallback. Suppressed while the ElectrumX sync screen
|
||||
(the "pre UI") is showing: a still-syncing Electrum server isn't
|
||||
reachable yet, so the "App not reachable / retry" overlay would just
|
||||
paint over the sync progress and read as a hard error. -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="iframeBlocked" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -79,6 +117,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
|
||||
|
||||
const props = defineProps<{
|
||||
appUrl: string
|
||||
@@ -91,6 +130,9 @@ const props = defineProps<{
|
||||
refreshKey: number
|
||||
blockedReason?: string
|
||||
blockedTitle?: string
|
||||
// Non-null only for ElectrumX while its index is still building — shows the
|
||||
// sync screen and gates the iframe until status flips to "synced".
|
||||
electrsSync?: ElectrsSyncStatus | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -7,6 +7,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
"botfights": 9100,
|
||||
"btcpay-server": 23000,
|
||||
"did-wallet": 8083,
|
||||
"electrumx": 50002,
|
||||
"fedimint": 8175,
|
||||
"filebrowser": 8083,
|
||||
"gitea": 3001,
|
||||
|
||||
@@ -85,8 +85,8 @@
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 md:p-6" @click.self="closeCredentialModal">
|
||||
<div class="sideload-modal credential-modal">
|
||||
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/80 backdrop-blur-md p-4" @click.self="closeCredentialModal">
|
||||
<div class="credential-modal-panel">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
|
||||
@@ -98,7 +98,7 @@
|
||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-blue-300 hover:text-blue-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
@@ -328,24 +328,32 @@ function scrollToPage(index: number) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.credential-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.credential-modal {
|
||||
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem);
|
||||
border-radius: 1.25rem;
|
||||
padding-bottom: 1.25rem;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||
.credential-modal-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
/* Centered card that never exceeds the visible viewport (minus safe areas),
|
||||
matching the wallet receive modal. The body scrolls if content overflows
|
||||
rather than the panel stretching edge-to-edge. */
|
||||
max-height: calc(
|
||||
100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) -
|
||||
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem
|
||||
);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(8, 10, 18, 0.98);
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.credential-modal-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.sideload-modal {
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -189,7 +189,14 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
|
||||
if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) {
|
||||
return pkg.state === PackageState.Running || pkg.state === PackageState.Starting
|
||||
}
|
||||
return !!hasUI && pkg.state === 'running' && pkg.health !== 'starting' && pkg.health !== 'unhealthy'
|
||||
// A static launch URL (e.g. a host-networked companion UI like
|
||||
// archy-electrs-ui) serves independently of the backend's own sync state, so
|
||||
// the tile stays launchable while the backend is still 'starting' (ElectrumX
|
||||
// indexes for 10m+ on first run). A genuinely 'unhealthy' backend still
|
||||
// blocks. Apps that rely on a runtime interface-address keep the strict gate.
|
||||
const blockedByHealth =
|
||||
pkg.health === 'unhealthy' || (pkg.health === 'starting' && !hasKnownLaunchUrl)
|
||||
return !!hasUI && pkg.state === 'running' && !blockedByHealth
|
||||
}
|
||||
|
||||
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
|
||||
|
||||
@@ -60,8 +60,8 @@
|
||||
<div v-if="stats.bitcoinAvailable" class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs text-orange-400/80">Bitcoin</p>
|
||||
<p class="text-sm font-medium" :class="stats.bitcoinSyncPercent >= 99.9 ? 'text-green-400' : 'text-orange-400'">
|
||||
{{ stats.bitcoinSyncPercent >= 99.9 ? 'Synced' : stats.bitcoinSyncPercent.toFixed(1) + '%' }}
|
||||
<p class="text-sm font-medium" :class="stats.bitcoinStale ? 'text-white/50' : (stats.bitcoinSyncPercent >= 99.9 ? 'text-green-400' : 'text-orange-400')">
|
||||
{{ stats.bitcoinStale ? 'Updating…' : (stats.bitcoinSyncPercent >= 99.9 ? 'Synced' : stats.bitcoinSyncPercent.toFixed(1) + '%') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
@@ -96,6 +96,7 @@ defineProps<{
|
||||
bitcoinSyncPercent: number
|
||||
bitcoinBlockHeight: number
|
||||
bitcoinAvailable: boolean
|
||||
bitcoinStale?: boolean
|
||||
}
|
||||
uptimeDisplay: string
|
||||
}>()
|
||||
|
||||
@@ -69,11 +69,18 @@
|
||||
</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" />
|
||||
<input v-model="draft.address" type="text" placeholder="185.18.221.160:8443" 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">Transport</span>
|
||||
<select v-model="draft.transport" 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">
|
||||
<option value="tcp">tcp (public anchor / over internet)</option>
|
||||
<option value="udp">udp (same LAN)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 sm:col-span-2">
|
||||
<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" />
|
||||
<input v-model="draft.label" type="text" placeholder="Public 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>
|
||||
@@ -106,9 +113,10 @@ const applying = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const statusIsError = ref(false)
|
||||
|
||||
const draft = reactive<Pick<SeedAnchor, 'npub' | 'address' | 'label'>>({
|
||||
const draft = reactive<Pick<SeedAnchor, 'npub' | 'address' | 'transport' | 'label'>>({
|
||||
npub: '',
|
||||
address: '',
|
||||
transport: 'tcp',
|
||||
label: '',
|
||||
})
|
||||
|
||||
@@ -136,7 +144,7 @@ async function addAnchor() {
|
||||
params: {
|
||||
npub: draft.npub.trim(),
|
||||
address: draft.address.trim(),
|
||||
transport: 'udp',
|
||||
transport: draft.transport,
|
||||
label: draft.label.trim(),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -188,6 +188,181 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.97-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.97-alpha</span>
|
||||
<span class="text-xs text-white/40">June 16, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The Bitcoin sync status on the home screen no longer disappears for a moment when it refreshes. If the node was briefly busy, the panel used to vanish and pop back; it now stays put and simply shows "Updating…" until the next reading arrives, while a genuinely stopped node still correctly shows as not running.</p>
|
||||
<p>Bitcoin sync progress on the home screen now updates more promptly, so the percentage and block height keep pace with the node instead of lagging behind.</p>
|
||||
<p>The Lightning wallet "connect your wallet" screen loads its details and QR code again across all nodes, instead of failing to fetch them.</p>
|
||||
<p>Your list of trusted nodes is now clean: the same node no longer appears several times under different names, and removed nodes stay removed. In chat, a node that previously showed up as two separate contacts now appears just once.</p>
|
||||
<p>Browsing another node's cloud is smoother: music and video files from a peer now preview and play properly (including seeking partway through), and the connection now shows a small badge telling you whether it's using the fast encrypted mesh or the slower Tor network.</p>
|
||||
<p>Opening "My Folders" in the cloud now shows a clear, friendly message when the file app isn't running, instead of a confusing error.</p>
|
||||
<p>The Electrum server app opens on its own once it's ready, instead of sometimes leaving a loading spinner stuck on top of the screen.</p>
|
||||
<p>The Fedimint app now displays with its proper styling and icons, instead of appearing unstyled with a missing image.</p>
|
||||
<p>The Mempool app now connects to your Bitcoin node whether the node is Bitcoin Core or Bitcoin Knots, instead of only working with one of them.</p>
|
||||
<p>Nodes start up cleanly after a reboot. On some boots the node's main service was trying to start before its data drive had finished mounting, so it failed and retried about twenty times over roughly five minutes — showing a wall of "Failed to start" messages — before finally coming up. It now waits for the data drive to be ready first, so it starts on the first try.</p>
|
||||
<p>The background images throughout the interface now load faster — they've been made significantly smaller with no loss of quality.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.96-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.96-alpha</span>
|
||||
<span class="text-xs text-white/40">June 15, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The screen attached to your node now shows the normal Archipelago interface and your dashboard after you sign in, instead of a separate, stripped-down grid of app icons that could appear in its place. That extra screen has been removed so the attached display matches what you see everywhere else.</p>
|
||||
<p>On a brand-new node, the attached screen now walks through the same welcome and setup steps you'd see on a phone or laptop, and shows the normal sign-in screen once the node is set up — so the on-device display always matches the rest of the interface.</p>
|
||||
<p>When adding a FIPS network anchor, you can now choose whether it connects over TCP (for a public anchor reached across the internet) or UDP (for one on your local network), instead of it always assuming the local-network option.</p>
|
||||
<p>Behind the scenes, a new automated two-node test now exercises real node-to-node features — browsing another node's shared files and handling a removed node — against live nodes before each release, so node-to-node problems are caught earlier.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.95-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.95-alpha</span>
|
||||
<span class="text-xs text-white/40">June 15, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Browsing another node's shared files now works over the fast encrypted mesh. Opening a peer's cloud could fail with a generic "Operation failed" message because the request for their file list wasn't permitted over the mesh and came back as "not found" — and it never retried over Tor. The mesh now serves the file list directly, and if a peer can't answer over the mesh the node automatically falls back to Tor instead of giving up.</p>
|
||||
<p>Nodes you remove from your federation now stay removed. Previously a deleted node could quietly come back the next time you synced with another node that still listed it. Removed nodes are now remembered as removed and won't reappear on their own — only if you add them back yourself.</p>
|
||||
<p>The app credentials pop-up now appears as a normal centred box with a dimmed background over the whole screen, instead of stretching to fill the entire screen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.94-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.94-alpha</span>
|
||||
<span class="text-xs text-white/40">June 15, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Your node now joins the private encrypted mesh network on its own. A wrong built-in setting meant nodes were quietly never reaching the shared mesh meeting point, so everything between nodes fell back to the slower Tor network. Every node now connects to the mesh automatically on startup, so node-to-node features like file sharing use the faster encrypted mesh first and only fall back to Tor when a peer is genuinely offline. (Confirmed live: a node with its mesh setting wiped re-connected to the mesh by itself within a second of starting.)</p>
|
||||
<p>You can now bring the mesh networking software up to the latest stable version straight from the node, with one action — it fetches the new version, checks it's genuine before installing, and restarts the mesh on its own. (Confirmed live end to end: a node on an older build was upgraded to the current stable release and rejoined the mesh automatically.)</p>
|
||||
<p>The Lightning wallet screen connects again on nodes where it was showing a "failed to fetch" error instead of your balance and channels. The wallet app and the node now talk to each other correctly, and the connection quietly repairs itself if its details drift after a restart.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.93-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.93-alpha</span>
|
||||
<span class="text-xs text-white/40">June 14, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Receiving Bitcoin and Lightning works again on nodes where the Lightning wallet was stuck locked. After some updates the wallet could come back locked with a password the node no longer had, so "generate a receive address" kept failing with a "wallet is locked" message that nothing could clear. The node now detects this and repairs itself automatically.</p>
|
||||
<p>Each node now secures its Lightning wallet with its own unique, randomly generated password instead of a shared built-in one, and remembers it safely so the wallet unlocks on its own after every restart or update — no more getting stuck locked.</p>
|
||||
<p>If a wallet is found locked with an unrecoverable password, the node rebuilds it cleanly so Bitcoin and Lightning start working again. (On these early-access nodes the wallet holds no funds, so nothing is lost — a wallet locked with an unknown password was already inaccessible.)</p>
|
||||
<p>The self-repair was validated end to end on live nodes: a stuck, locked wallet was detected, rebuilt, and came back unlocked on its own, and stayed unlocked across restarts.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.92-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.92-alpha</span>
|
||||
<span class="text-xs text-white/40">June 14, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The Electrum server app no longer flashes a "can't connect, try again" error over its loading screen while it's still catching up. If ElectrumX is building its index or waiting on the Bitcoin node, you now just see the sync progress, and the app opens on its own once it's ready.</p>
|
||||
<p>Behind the scenes, the reboot-survival test now confirms the whole system is genuinely healthy after a restart — every app reachable, updates not stuck, core services answering — instead of only checking that containers came back, so update-related problems are caught before shipping.</p>
|
||||
<p>Settings → What's New now lists the notes for every recent release again. The screen had quietly fallen several versions behind, so the last eight releases of changes weren't showing up there — they're all back now, and a release check keeps it from drifting again.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.91-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.91-alpha</span>
|
||||
<span class="text-xs text-white/40">June 14, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Apps you've installed now reliably show their "Open" button again. Some apps — including Jellyfin, BTCPay Server, Fedimint, Gitea and Portainer — were running fine but their launch link sometimes went missing, so there was no way to open them from the home screen. They now open correctly.</p>
|
||||
<p>Receiving Bitcoin is more dependable: if the wallet's internal connection details drift after a restart, it now repairs them on its own, and any error it does hit is reported clearly instead of as a generic failure or a misleading "wallet locked" message.</p>
|
||||
<p>Installing Bitcoin now sets itself up correctly without manual help — a security credential that could previously be missing and stop Bitcoin from starting is created automatically before it launches.</p>
|
||||
<p>The Electrum server app is back on the home screen and can be launched again.</p>
|
||||
<p>Behind the scenes, the release now runs an expanded automated test suite before shipping, so these kinds of issues are caught earlier.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.90-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.90-alpha</span>
|
||||
<span class="text-xs text-white/40">June 13, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Generating a Bitcoin receive address works again — the wallet now requests the correct address type, fixing the "400 Bad Request" error when creating an address.</p>
|
||||
<p>In the companion app, the on-screen pointer can now click into apps and type — including the app store search box — instead of clicks and keystrokes not reaching app content.</p>
|
||||
<p>"Open in a new tab" from the companion app now opens the app in your phone's browser, instead of doing nothing. The normal mobile browser keeps working as before.</p>
|
||||
<p>The login/credentials pop-up on phones is once again a centered, properly sized window rather than stretching the full height of the screen.</p>
|
||||
<p>The Electrum server now recovers on its own if its index ever gets corrupted, and shows a clear progress screen (with percent complete and block height) while it builds its index, instead of a blank or broken page.</p>
|
||||
<p>Software updates are more reliable on slow internet connections — downloads are given much more time to finish before giving up.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.89-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.89-alpha</span>
|
||||
<span class="text-xs text-white/40">June 12, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The AI assistant looks the way it always did again: no extra back button or close button on phones, and the desktop view fills the whole screen without a gap at the bottom.</p>
|
||||
<p>System updates are much more reliable: updates that previously got stuck partway or failed to install now complete cleanly, and a failed update can no longer block all future updates.</p>
|
||||
<p>After an update, the system now checks itself correctly on every node type, so working updates are no longer mistakenly undone.</p>
|
||||
<p>Generating a Bitcoin receive address works again on nodes where a network proxy previously got in the way.</p>
|
||||
<p>The Lightning wallet now recovers and unlocks itself properly after restarts.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.88-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.88-alpha</span>
|
||||
<span class="text-xs text-white/40">June 12, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression.</p>
|
||||
<p>Bitcoin receive now uses LND's GET-based newaddress flow with the native SegWit address type, fixing the 501 Method Not Allowed response from the previous POST attempt.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.87-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.87-alpha</span>
|
||||
<span class="text-xs text-white/40">June 12, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Bitcoin receive now calls LND's on-chain address endpoint with the correct REST method, and backend failures keep the specific address-generation error instead of collapsing into the generic operation-failed message.</p>
|
||||
<p>App launch credential interstitials now render as true full-screen overlays, and the launcher loading indicator uses the neutral brand palette instead of a blue spinner.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.86-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.86-alpha</span>
|
||||
<span class="text-xs text-white/40">June 12, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fleet now preserves the last known node list, alerts, and selection locally while telemetry refreshes in the background, so the dashboard no longer blanks on tab switches or update scans.</p>
|
||||
<p>Connected nodes and identities now reuse their last loaded data instead of reloading the visible list every time the user revisits the tab.</p>
|
||||
<p>The Fleet matrix and detail views now show actual node names and host information instead of raw node id prefixes.</p>
|
||||
<p>The network map only redraws when its graph data actually changes, which stops the D3 scene from visually resetting on every refresh tick.</p>
|
||||
<p>Mobile federation and system-update actions now stack full width, and the ElectrumX app health check allows a long startup window so slow sync nodes do not restart mid-index.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.85-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.85-alpha</span>
|
||||
<span class="text-xs text-white/40">June 12, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.</p>
|
||||
<p>Portainer is pinned to 2.19.4 instead of latest, avoiding schema-drift restarts from surprise image updates.</p>
|
||||
<p>LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.</p>
|
||||
<p>Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.</p>
|
||||
<p>Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.84-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||