Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aa1ca013f | ||
|
|
5af9a22b98 | ||
|
|
786498a57a | ||
|
|
790ad154f3 | ||
|
|
0c8991b519 | ||
|
|
e2c2f942c2 | ||
|
|
937ba7e115 | ||
|
|
e056c2477b |
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## 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.)
|
||||
|
||||
Generated
+1
-1
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.93-alpha"
|
||||
version = "1.7.95-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.94-alpha"
|
||||
version = "1.7.96-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -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.",
|
||||
_ => "",
|
||||
|
||||
@@ -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>,
|
||||
@@ -114,6 +128,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 +144,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,
|
||||
@@ -287,6 +365,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() {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.94-alpha",
|
||||
"version": "1.7.96-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.94-alpha",
|
||||
"version": "1.7.96-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.94-alpha",
|
||||
"version": "1.7.96-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -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: '/' }
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -244,7 +244,7 @@
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="credentialModal.show"
|
||||
class="credential-modal-overlay fixed inset-0 z-[2700] flex items-stretch justify-stretch bg-black/80 backdrop-blur-md p-0"
|
||||
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">
|
||||
@@ -806,17 +806,22 @@ async function submitSideload() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 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;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
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: none;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.credential-modal-body {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -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>
|
||||
@@ -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,31 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- 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">
|
||||
|
||||
+16
-15
@@ -1,27 +1,28 @@
|
||||
{
|
||||
"version": "1.7.94-alpha",
|
||||
"version": "1.7.96-alpha",
|
||||
"release_date": "2026-06-15",
|
||||
"changelog": [
|
||||
"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 \u2014 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."
|
||||
"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 \u2014 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 \u2014 browsing another node's shared files and handling a removed node \u2014 against live nodes before each release, so node-to-node problems are caught earlier."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.94-alpha",
|
||||
"new_version": "1.7.94-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.94-alpha/archipelago",
|
||||
"sha256": "d0dd5903c52065e67f0314d9c3dcc1da3d843f656fed36ad1e2aa8776119206f",
|
||||
"size_bytes": 44430984
|
||||
"current_version": "1.7.96-alpha",
|
||||
"new_version": "1.7.96-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.96-alpha/archipelago",
|
||||
"sha256": "147672b4ebecd6042d4606a4fa2fdd60a5de8b9f518a7c0263ee2b6f49aed113",
|
||||
"size_bytes": 44358704
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.94-alpha.tar.gz",
|
||||
"current_version": "1.7.94-alpha",
|
||||
"new_version": "1.7.94-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.94-alpha/archipelago-frontend-1.7.94-alpha.tar.gz",
|
||||
"sha256": "9958bfa6ae85c52dd7c2fd39ea0f6cc4a34785308b30a17684d1bcb8e713ba2a",
|
||||
"size_bytes": 184073585
|
||||
"name": "archipelago-frontend-1.7.96-alpha.tar.gz",
|
||||
"current_version": "1.7.96-alpha",
|
||||
"new_version": "1.7.96-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.96-alpha/archipelago-frontend-1.7.96-alpha.tar.gz",
|
||||
"sha256": "c91c73abd078dd85f2515b553d60348ab8fe169f7ec00e9d4a6b4f131a96c2fa",
|
||||
"size_bytes": 184071997
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+16
-15
@@ -1,27 +1,28 @@
|
||||
{
|
||||
"version": "1.7.94-alpha",
|
||||
"version": "1.7.96-alpha",
|
||||
"release_date": "2026-06-15",
|
||||
"changelog": [
|
||||
"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 \u2014 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."
|
||||
"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 \u2014 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 \u2014 browsing another node's shared files and handling a removed node \u2014 against live nodes before each release, so node-to-node problems are caught earlier."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.94-alpha",
|
||||
"new_version": "1.7.94-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.94-alpha/archipelago",
|
||||
"sha256": "d0dd5903c52065e67f0314d9c3dcc1da3d843f656fed36ad1e2aa8776119206f",
|
||||
"size_bytes": 44430984
|
||||
"current_version": "1.7.96-alpha",
|
||||
"new_version": "1.7.96-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.96-alpha/archipelago",
|
||||
"sha256": "147672b4ebecd6042d4606a4fa2fdd60a5de8b9f518a7c0263ee2b6f49aed113",
|
||||
"size_bytes": 44358704
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.94-alpha.tar.gz",
|
||||
"current_version": "1.7.94-alpha",
|
||||
"new_version": "1.7.94-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.94-alpha/archipelago-frontend-1.7.94-alpha.tar.gz",
|
||||
"sha256": "9958bfa6ae85c52dd7c2fd39ea0f6cc4a34785308b30a17684d1bcb8e713ba2a",
|
||||
"size_bytes": 184073585
|
||||
"name": "archipelago-frontend-1.7.96-alpha.tar.gz",
|
||||
"current_version": "1.7.96-alpha",
|
||||
"new_version": "1.7.96-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.96-alpha/archipelago-frontend-1.7.96-alpha.tar.gz",
|
||||
"sha256": "c91c73abd078dd85f2515b553d60348ab8fe169f7ec00e9d4a6b4f131a96c2fa",
|
||||
"size_bytes": 184071997
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
# Multi-node RPC harness library.
|
||||
#
|
||||
# Unlike tests/lifecycle/lib/rpc.bash (which targets a single ARCHY_HOST),
|
||||
# this drives N independent archipelago nodes in one run so we can exercise
|
||||
# real node-to-node paths: federation sync over Tor, FIPS anchoring, etc.
|
||||
#
|
||||
# A "node handle" is a short label (e.g. A, B, alice). For each handle you
|
||||
# register a base URL + UI password; the lib logs in and keeps that node's
|
||||
# session/CSRF cookies in its own state file so calls never cross wires.
|
||||
#
|
||||
# Usage:
|
||||
# source tests/multinode/lib/multinode.bash
|
||||
# node_register A https://192.168.1.228 password123
|
||||
# node_register B http://192.168.1.116 'ThisIsWeb54321@'
|
||||
# node_login A; node_login B
|
||||
# node_rpc A node.tor-address
|
||||
# node_result B federation.list-nodes
|
||||
#
|
||||
# Requires: curl, jq.
|
||||
#
|
||||
# Note: this is a library — it does NOT set shell options (set -u/-e), since
|
||||
# that would leak into the sourcing script. Each function guards its own vars
|
||||
# with ${var:-} defaults. Callers set their own options.
|
||||
|
||||
# Where per-node session state lives (one file per handle).
|
||||
MULTINODE_STATE_DIR="${MULTINODE_STATE_DIR:-/tmp/archy-multinode}"
|
||||
mkdir -p "$MULTINODE_STATE_DIR"
|
||||
|
||||
# handle -> base url / password, kept in associative arrays.
|
||||
declare -gA _MN_URL
|
||||
declare -gA _MN_PW
|
||||
declare -gA _MN_SESSION
|
||||
declare -gA _MN_CSRF
|
||||
|
||||
# node_register HANDLE BASE_URL PASSWORD
|
||||
node_register() {
|
||||
local h="$1" url="$2" pw="$3"
|
||||
_MN_URL[$h]="${url%/}"
|
||||
_MN_PW[$h]="$pw"
|
||||
}
|
||||
|
||||
_mn_session_file() { echo "$MULTINODE_STATE_DIR/session-$1"; }
|
||||
|
||||
# node_login HANDLE — authenticate and capture session + csrf cookies.
|
||||
node_login() {
|
||||
local h="$1"
|
||||
local url="${_MN_URL[$h]:-}" pw="${_MN_PW[$h]:-}"
|
||||
if [[ -z "$url" || -z "$pw" ]]; then
|
||||
echo "node_login: handle '$h' not registered" >&2
|
||||
return 1
|
||||
fi
|
||||
local headers; headers=$(mktemp)
|
||||
local body
|
||||
body=$(curl -sk -D "$headers" -X POST "${url}/rpc/v1" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-raw "{\"jsonrpc\":\"2.0\",\"method\":\"auth.login\",\"params\":{\"password\":\"${pw}\"},\"id\":1}")
|
||||
local err; err=$(echo "$body" | jq -r '.error.message // empty' 2>/dev/null)
|
||||
if [[ -n "$err" ]]; then
|
||||
echo "node_login[$h] failed: $err" >&2
|
||||
rm -f "$headers"
|
||||
return 1
|
||||
fi
|
||||
local session csrf
|
||||
session=$(grep -i '^set-cookie: session=' "$headers" | head -1 | sed -E 's/.*session=([^;]+).*/\1/' | tr -d '\r')
|
||||
csrf=$(grep -i '^set-cookie: csrf_token=' "$headers" | head -1 | sed -E 's/.*csrf_token=([^;]+).*/\1/' | tr -d '\r')
|
||||
rm -f "$headers"
|
||||
if [[ -z "$session" || -z "$csrf" ]]; then
|
||||
echo "node_login[$h]: missing session/csrf cookie" >&2
|
||||
return 1
|
||||
fi
|
||||
_MN_SESSION[$h]="$session"
|
||||
_MN_CSRF[$h]="$csrf"
|
||||
printf '%s\n%s\n' "$session" "$csrf" > "$(_mn_session_file "$h")"
|
||||
}
|
||||
|
||||
# node_rpc HANDLE METHOD [PARAMS_JSON] — raw JSON-RPC response on stdout.
|
||||
node_rpc() {
|
||||
local h="$1" method="$2" params="${3:-}"
|
||||
local url="${_MN_URL[$h]:-}"
|
||||
local session="${_MN_SESSION[$h]:-}" csrf="${_MN_CSRF[$h]:-}"
|
||||
if [[ -z "$session" || -z "$csrf" ]] && [[ -f "$(_mn_session_file "$h")" ]]; then
|
||||
mapfile -t lines < "$(_mn_session_file "$h")"
|
||||
session="${lines[0]:-}"; csrf="${lines[1]:-}"
|
||||
_MN_SESSION[$h]="$session"; _MN_CSRF[$h]="$csrf"
|
||||
fi
|
||||
local payload
|
||||
if [[ -z "$params" ]]; then
|
||||
payload=$(jq -nc --arg m "$method" '{jsonrpc:"2.0",method:$m,id:1}')
|
||||
else
|
||||
payload=$(jq -nc --arg m "$method" --argjson p "$params" '{jsonrpc:"2.0",method:$m,params:$p,id:1}')
|
||||
fi
|
||||
curl -sk -X POST "${url}/rpc/v1" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "Cookie: session=${session}; csrf_token=${csrf}" \
|
||||
-H "X-CSRF-Token: ${csrf}" \
|
||||
--data-raw "$payload"
|
||||
}
|
||||
|
||||
# node_result HANDLE METHOD [PARAMS_JSON] — .result on success; prints error to
|
||||
# stderr and returns non-zero on RPC error.
|
||||
node_result() {
|
||||
local resp; resp=$(node_rpc "$@")
|
||||
local err; err=$(echo "$resp" | jq -r '.error.message // empty' 2>/dev/null)
|
||||
if [[ -n "$err" ]]; then
|
||||
echo "node_result[$1 $2] error: $err" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "$resp" | jq '.result'
|
||||
}
|
||||
|
||||
# node_onion HANDLE — echo this node's own .onion address (empty if none).
|
||||
node_onion() {
|
||||
node_result "$1" node.tor-address 2>/dev/null | jq -r '. // empty | if type=="object" then (.onion // .address // .tor_address // empty) else . end' 2>/dev/null
|
||||
}
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Controlled two-node reproduction of node-to-node federation sync.
|
||||
#
|
||||
# Pairs two real nodes via federation.invite/join, triggers federation.sync-state
|
||||
# in both directions, and reports which transport actually carried the call and
|
||||
# any per-peer error. This is the controlled repro for the reported
|
||||
# "Tor connection cloud->node not working" symptom: raw Tor transport is known
|
||||
# good (see README), so this isolates whether the APP-level sync path works and,
|
||||
# if it fails, surfaces the exact error string.
|
||||
#
|
||||
# Env (override as needed):
|
||||
# A_URL A_PW node A base url + UI password (default .116 http)
|
||||
# B_URL B_PW node B base url + UI password (default .228 https)
|
||||
# FORCE_TOR=1 set both nodes' federation transport preference to Tor first
|
||||
#
|
||||
# Usage: tests/multinode/repro-federation-sync.sh
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$HERE/lib/multinode.bash"
|
||||
|
||||
A_URL="${A_URL:-http://192.168.1.116}"; A_PW="${A_PW:-ThisIsWeb54321@}"
|
||||
B_URL="${B_URL:-https://192.168.1.228}"; B_PW="${B_PW:-password123}"
|
||||
|
||||
bar() { printf '\n=== %s ===\n' "$*"; }
|
||||
|
||||
node_register A "$A_URL" "$A_PW"
|
||||
node_register B "$B_URL" "$B_PW"
|
||||
|
||||
bar "login"
|
||||
node_login A || { echo "A login failed"; exit 1; }
|
||||
node_login B || { echo "B login failed"; exit 1; }
|
||||
echo "A=$A_URL B=$B_URL logged in"
|
||||
|
||||
bar "onions"
|
||||
A_ONION=$(node_onion A); B_ONION=$(node_onion B)
|
||||
echo "A onion: ${A_ONION:-<none>}"
|
||||
echo "B onion: ${B_ONION:-<none>}"
|
||||
|
||||
if [[ "${FORCE_TOR:-0}" == "1" ]]; then
|
||||
bar "force federation transport = tor on both"
|
||||
node_rpc A transport.set-preference '{"service":"federation","pref":"tor"}' | jq -c '.result // .error'
|
||||
node_rpc B transport.set-preference '{"service":"federation","pref":"tor"}' | jq -c '.result // .error'
|
||||
fi
|
||||
|
||||
bar "federation state BEFORE"
|
||||
echo "A knows:"; node_result A federation.list-nodes | jq -r '.[]? | " \(.name // "?") did=\(.did[0:24])… last_seen=\(.last_seen // "never")"' 2>/dev/null || echo " (none/err)"
|
||||
echo "B knows:"; node_result B federation.list-nodes | jq -r '.[]? | " \(.name // "?") did=\(.did[0:24])… last_seen=\(.last_seen // "never")"' 2>/dev/null || echo " (none/err)"
|
||||
|
||||
bar "pair: A invites, B joins"
|
||||
INV_A=$(node_result A federation.invite)
|
||||
CODE_A=$(echo "$INV_A" | jq -r '.code // empty')
|
||||
echo "A invite code: ${CODE_A:0:40}…"
|
||||
if [[ -n "$CODE_A" ]]; then
|
||||
node_result B federation.join "$(jq -nc --arg c "$CODE_A" '{code:$c}')" \
|
||||
&& echo "B joined A" || echo "B join FAILED"
|
||||
fi
|
||||
|
||||
bar "pair: B invites, A joins"
|
||||
INV_B=$(node_result B federation.invite)
|
||||
CODE_B=$(echo "$INV_B" | jq -r '.code // empty')
|
||||
echo "B invite code: ${CODE_B:0:40}…"
|
||||
if [[ -n "$CODE_B" ]]; then
|
||||
node_result A federation.join "$(jq -nc --arg c "$CODE_B" '{code:$c}')" \
|
||||
&& echo "A joined B" || echo "A join FAILED"
|
||||
fi
|
||||
|
||||
bar "trigger sync-state on A (A dials its peers)"
|
||||
node_result A federation.sync-state | jq '.'
|
||||
|
||||
bar "trigger sync-state on B (B dials its peers)"
|
||||
node_result B federation.sync-state | jq '.'
|
||||
|
||||
bar "federation state AFTER (look for fresh last_seen + transport)"
|
||||
echo "A knows:"; node_result A federation.list-nodes | jq -r '.[]? | " \(.name // "?") last_seen=\(.last_seen // "never") transport=\(.last_transport // .transport // "?")"' 2>/dev/null
|
||||
echo "B knows:"; node_result B federation.list-nodes | jq -r '.[]? | " \(.name // "?") last_seen=\(.last_seen // "never") transport=\(.last_transport // .transport // "?")"' 2>/dev/null
|
||||
|
||||
bar "done"
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
# Two-node (optionally three-node) end-to-end smoke suite for the full app.
|
||||
#
|
||||
# Unlike repro-federation-sync.sh (a diagnostic that just prints state), this
|
||||
# is an ASSERTION suite: every check is pass/fail and the script exits non-zero
|
||||
# if any required check fails. It exercises the real node-to-node surface and
|
||||
# specifically guards the bugs fixed in v1.7.94 / v1.7.95:
|
||||
# - FIPS auto-connects to the public anchor (v1.7.94)
|
||||
# - peer content browse works over the mesh, not just Tor (v1.7.95 — the
|
||||
# `/content` catalog used to 404 over FIPS and never fall back to Tor)
|
||||
# - a removed federation node stays removed, incl. transitive re-discovery
|
||||
# (v1.7.95 tombstone) — the transitive case needs node C.
|
||||
#
|
||||
# Nodes (override via env):
|
||||
# A_URL A_PW node A (default .116 http)
|
||||
# B_URL B_PW node B (default .228 https)
|
||||
# C_URL C_PW node C (OPTIONAL — enables the transitive-tombstone test)
|
||||
#
|
||||
# Requires both nodes on v1.7.95-alpha+ for the content-browse and tombstone
|
||||
# checks; older peers SKIP those (reported, not failed).
|
||||
#
|
||||
# Usage:
|
||||
# tests/multinode/smoke.sh
|
||||
# A_URL=http://192.168.1.116 B_URL=https://192.168.1.228 tests/multinode/smoke.sh
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$HERE/lib/multinode.bash"
|
||||
|
||||
A_URL="${A_URL:-http://192.168.1.116}"; A_PW="${A_PW:-ThisIsWeb54321@}"
|
||||
B_URL="${B_URL:-https://192.168.1.228}"; B_PW="${B_PW:-password123}"
|
||||
C_URL="${C_URL:-}"; C_PW="${C_PW:-}"
|
||||
|
||||
# ── tiny assertion framework ──────────────────────────────────────────────
|
||||
PASS=0; FAIL=0; SKIP=0
|
||||
declare -a FAILED_NAMES
|
||||
green() { printf '\033[32m%s\033[0m' "$*"; }
|
||||
red() { printf '\033[31m%s\033[0m' "$*"; }
|
||||
yellow(){ printf '\033[33m%s\033[0m' "$*"; }
|
||||
section() { printf '\n\033[1m── %s ──\033[0m\n' "$*"; }
|
||||
ok() { printf ' %s %s\n' "$(green ✓)" "$1"; PASS=$((PASS+1)); }
|
||||
no() { printf ' %s %s\n' "$(red ✗)" "$1"; FAIL=$((FAIL+1)); FAILED_NAMES+=("$1"); }
|
||||
skip() { printf ' %s %s (%s)\n' "$(yellow —)" "$1" "$2"; SKIP=$((SKIP+1)); }
|
||||
# assert_eq NAME EXPECTED ACTUAL
|
||||
assert_eq() { [[ "$2" == "$3" ]] && ok "$1" || no "$1 (expected '$2', got '$3')"; }
|
||||
# assert_true NAME VALUE — passes when VALUE is "true"
|
||||
assert_true() { [[ "$2" == "true" ]] && ok "$1" || no "$1 (got '$2')"; }
|
||||
|
||||
# did_of HANDLE — this node's own DID via node.did (string or {did:...}).
|
||||
did_of() {
|
||||
node_result "$1" node.did 2>/dev/null \
|
||||
| jq -r 'if type=="string" then . elif type=="object" then (.did // .node_did // empty) else empty end' 2>/dev/null
|
||||
}
|
||||
|
||||
# pair HANDLE_INVITER HANDLE_JOINER — invite + join one direction. Echo "ok"/"fail".
|
||||
pair() {
|
||||
local inv code
|
||||
inv=$(node_result "$1" federation.invite 2>/dev/null)
|
||||
code=$(echo "$inv" | jq -r '.code // empty' 2>/dev/null)
|
||||
[[ -z "$code" ]] && { echo "fail"; return; }
|
||||
if node_result "$2" federation.join "$(jq -nc --arg c "$code" '{code:$c}')" >/dev/null 2>&1; then
|
||||
echo "ok"
|
||||
else echo "fail"; fi
|
||||
}
|
||||
|
||||
node_register A "$A_URL" "$A_PW"
|
||||
node_register B "$B_URL" "$B_PW"
|
||||
HAVE_C=0
|
||||
if [[ -n "$C_URL" && -n "$C_PW" ]]; then node_register C "$C_URL" "$C_PW"; HAVE_C=1; fi
|
||||
|
||||
# ── 1. reachability + auth ────────────────────────────────────────────────
|
||||
section "reachability + login"
|
||||
node_login A && ok "A login ($A_URL)" || { no "A login ($A_URL)"; echo "A unreachable — aborting"; exit 1; }
|
||||
node_login B && ok "B login ($B_URL)" || { no "B login ($B_URL)"; echo "B unreachable — aborting"; exit 1; }
|
||||
if [[ $HAVE_C == 1 ]]; then node_login C && ok "C login ($C_URL)" || { no "C login"; HAVE_C=0; }; fi
|
||||
|
||||
A_ONION=$(node_onion A); B_ONION=$(node_onion B)
|
||||
[[ -n "$A_ONION" ]] && ok "A has onion address" || no "A has onion address"
|
||||
[[ -n "$B_ONION" ]] && ok "B has onion address" || no "B has onion address"
|
||||
|
||||
# ── 2. FIPS mesh: daemon up + anchor connected (v1.7.94) ──────────────────
|
||||
section "FIPS mesh / anchor"
|
||||
for h in A B; do
|
||||
s=$(node_result "$h" fips.status 2>/dev/null)
|
||||
if [[ -z "$s" ]]; then skip "$h fips.status" "no FIPS RPC (old build?)"; continue; fi
|
||||
assert_true "$h FIPS service active" "$(echo "$s" | jq -r '.service_active')"
|
||||
ac=$(echo "$s" | jq -r '.anchor_connected')
|
||||
if [[ "$ac" == "true" ]]; then ok "$h anchor connected"
|
||||
else skip "$h anchor connected" "anchor_connected=$ac — node may need v1.7.94 + a moment to handshake"; fi
|
||||
done
|
||||
|
||||
# ── 3. federation pairing (both directions) ───────────────────────────────
|
||||
section "federation pairing"
|
||||
assert_eq "A invites, B joins" "ok" "$(pair A B)"
|
||||
assert_eq "B invites, A joins" "ok" "$(pair B A)"
|
||||
# both should now list each other
|
||||
node_result A federation.sync-state >/dev/null 2>&1
|
||||
node_result B federation.sync-state >/dev/null 2>&1
|
||||
A_SEES_B=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg o "${B_ONION%.onion}" 'any((.nodes // .)[]?; (.onion // "" | gsub("\\.onion$";"")) == $o)')
|
||||
B_SEES_A=$(node_result B federation.list-nodes 2>/dev/null | jq -r --arg o "${A_ONION%.onion}" 'any((.nodes // .)[]?; (.onion // "" | gsub("\\.onion$";"")) == $o)')
|
||||
assert_true "A's node list contains B" "$A_SEES_B"
|
||||
assert_true "B's node list contains A" "$B_SEES_A"
|
||||
|
||||
# ── 4. peer content browse over the mesh (v1.7.95 fix) ────────────────────
|
||||
section "peer content browse (was: 404 over mesh, no Tor fallback)"
|
||||
if [[ -n "$B_ONION" ]]; then
|
||||
resp=$(node_rpc A content.browse-peer "$(jq -nc --arg o "$B_ONION" '{onion:$o}')")
|
||||
err=$(echo "$resp" | jq -r '.error.message // empty')
|
||||
if [[ -z "$err" ]]; then
|
||||
ok "A browses B's content catalog (HTTP 200)"
|
||||
elif echo "$err" | grep -q '404'; then
|
||||
no "A browses B's content — still 404 over mesh (is B on v1.7.95?): $err"
|
||||
else
|
||||
# Other errors (peer offline, no content shared) are environmental, not the bug.
|
||||
skip "A browses B's content" "non-404 error: $err"
|
||||
fi
|
||||
else
|
||||
skip "A browses B's content" "B has no onion"
|
||||
fi
|
||||
|
||||
# ── 5. removed-node tombstone (v1.7.95) ───────────────────────────────────
|
||||
section "removed-node tombstone"
|
||||
B_DID=$(did_of B)
|
||||
if [[ -z "$B_DID" ]]; then
|
||||
skip "remove B then verify stays removed" "couldn't resolve B's DID"
|
||||
else
|
||||
if node_result A federation.remove-node "$(jq -nc --arg d "$B_DID" '{did:$d}')" >/dev/null 2>&1; then
|
||||
still=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg d "$B_DID" 'any((.nodes // .)[]?; .did == $d)')
|
||||
assert_eq "B removed from A's list" "false" "$still"
|
||||
# Transitive test needs C: A federated with B and C; C federated with B;
|
||||
# A removes B; A syncs with C (who advertises B) → B must NOT reappear.
|
||||
if [[ $HAVE_C == 1 ]]; then
|
||||
pair A C >/dev/null; pair C A >/dev/null; pair C B >/dev/null
|
||||
node_result A federation.sync-state >/dev/null 2>&1
|
||||
reappeared=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg d "$B_DID" 'any((.nodes // .)[]?; .did == $d)')
|
||||
assert_eq "B does NOT reappear via transitive sync with C" "false" "$reappeared"
|
||||
else
|
||||
skip "transitive reappear via 3rd node" "set C_URL/C_PW to enable"
|
||||
fi
|
||||
# re-add restores B (explicit re-add clears the tombstone)
|
||||
pair B A >/dev/null
|
||||
node_result A federation.sync-state >/dev/null 2>&1
|
||||
readded=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg d "$B_DID" 'any((.nodes // .)[]?; .did == $d)')
|
||||
assert_true "explicit re-pair brings B back (tombstone cleared)" "$readded"
|
||||
else
|
||||
skip "remove B" "remove-node RPC failed (B may already be absent)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── summary ───────────────────────────────────────────────────────────────
|
||||
section "summary"
|
||||
printf ' %s passed, %s failed, %s skipped\n' "$(green $PASS)" "$([[ $FAIL -gt 0 ]] && red $FAIL || echo $FAIL)" "$(yellow $SKIP)"
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
printf ' failed:\n'; for n in "${FAILED_NAMES[@]}"; do printf ' - %s\n' "$n"; done
|
||||
exit 1
|
||||
fi
|
||||
echo " all required checks passed"
|
||||
Reference in New Issue
Block a user