From 6656fed9d62d59deedc6fc19e91691f1e36aec5b Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 1 Apr 2026 16:25:27 +0100 Subject: [PATCH] fix: federation peer-joined updates empty onion addresses When a node was already known (via link-node) but had an empty onion address, the peer-joined handler returned early without updating the onion. Now it patches missing onion/pubkey fields on existing nodes. Also adds update_node() to federation storage and updates the architecture comparison doc with system resources, StartOS/umbrelOS tabs, Web5 section, and comparison view. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/api/rpc/federation/handlers.rs | 15 +- core/archipelago/src/federation/mod.rs | 2 +- core/archipelago/src/federation/storage.rs | 21 ++ docs/container-architecture.html | 332 ++++++++++++++++-- 4 files changed, 344 insertions(+), 26 deletions(-) diff --git a/core/archipelago/src/api/rpc/federation/handlers.rs b/core/archipelago/src/api/rpc/federation/handlers.rs index 78dbde31..66befe90 100644 --- a/core/archipelago/src/api/rpc/federation/handlers.rs +++ b/core/archipelago/src/api/rpc/federation/handlers.rs @@ -364,7 +364,20 @@ impl RpcHandler { } let nodes = federation::load_nodes(&self.config.data_dir).await?; - if nodes.iter().any(|n| n.did == did) { + if let Some(existing) = nodes.iter().find(|n| n.did == did) { + // If already known but missing onion/pubkey, update them + if existing.onion.is_empty() || existing.pubkey.is_empty() { + let mut updated = existing.clone(); + if existing.onion.is_empty() && !onion.is_empty() { + updated.onion = onion.to_string(); + } + if existing.pubkey.is_empty() && !pubkey.is_empty() { + updated.pubkey = pubkey.to_string(); + } + updated.last_seen = Some(chrono::Utc::now().to_rfc3339()); + federation::update_node(&self.config.data_dir, &updated).await?; + info!(peer_did = %did, peer_onion = %onion, "Updated existing peer with missing onion/pubkey"); + } return Ok(serde_json::json!({ "accepted": true, "already_known": true })); } diff --git a/core/archipelago/src/federation/mod.rs b/core/archipelago/src/federation/mod.rs index 12d25028..b0b4c159 100644 --- a/core/archipelago/src/federation/mod.rs +++ b/core/archipelago/src/federation/mod.rs @@ -12,7 +12,7 @@ mod types; // Re-export all public items so `crate::federation::*` continues to work. pub use invites::{accept_invite, create_invite}; pub use storage::{ - add_node, load_nodes, remove_node, save_nodes, set_trust_level, + add_node, load_nodes, remove_node, save_nodes, set_trust_level, update_node, }; pub use sync::{build_local_state, deploy_to_peer, sync_with_peer}; pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel}; diff --git a/core/archipelago/src/federation/storage.rs b/core/archipelago/src/federation/storage.rs index afe71026..a2e069d5 100644 --- a/core/archipelago/src/federation/storage.rs +++ b/core/archipelago/src/federation/storage.rs @@ -96,6 +96,27 @@ pub async fn set_trust_level( Ok(nodes) } +/// Update a federated node's metadata (onion, pubkey, name, last_seen). +pub async fn update_node(data_dir: &Path, updated: &FederatedNode) -> Result<()> { + let mut nodes = load_nodes(data_dir).await?; + if let Some(node) = nodes.iter_mut().find(|n| n.did == updated.did) { + if !updated.onion.is_empty() { + node.onion = updated.onion.clone(); + } + if !updated.pubkey.is_empty() { + node.pubkey = updated.pubkey.clone(); + } + if updated.name.is_some() { + node.name = updated.name.clone(); + } + if updated.last_seen.is_some() { + node.last_seen = updated.last_seen.clone(); + } + save_nodes(data_dir, &nodes).await?; + } + Ok(()) +} + pub async fn update_node_state( data_dir: &Path, did: &str, diff --git a/docs/container-architecture.html b/docs/container-architecture.html index d7cf2ee1..3a9df9a1 100644 --- a/docs/container-architecture.html +++ b/docs/container-architecture.html @@ -119,6 +119,9 @@ .proto-card { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; padding: 14px 16px; cursor: pointer; transition: all 0.2s; overflow-wrap: break-word; word-break: break-word; } .proto-card:hover { background: rgba(255,255,255,0.06); border-color: rgba(255,255,255,0.12); } .proto-card.expanded .proto-details { display: block; } + .proto-grid.all-expanded { align-items: stretch; } + .proto-grid.all-expanded .proto-card { display: flex; flex-direction: column; } + .proto-grid.all-expanded .proto-details { flex: 1; } .proto-card-name { font-size: 14px; font-weight: 600; color: #fff; } .proto-card-desc { font-size: 12px; color: #8b8fa3; margin-top: 4px; } .proto-card-layman { font-size: 11px; color: #6b7280; margin-top: 2px; font-style: italic; } @@ -315,6 +318,76 @@ body.light .l-cnt { background: rgba(16,185,129,0.05); border-color: rgba(16,185,129,0.15); } body.light .l-ui { background: rgba(236,72,153,0.05); border-color: rgba(236,72,153,0.15); } body.light .l-kiosk { background: rgba(251,146,60,0.05); border-color: rgba(251,146,60,0.15); } + + /* ═══ MOBILE ═══ */ + @media (max-width: 768px) { + /* Header: compact, hide subtitle, move controls */ + .header { padding: 12px 16px; } + .header h1 { font-size: 18px; } + .header p { display: none; } + .header-controls { position: static; display: flex; margin-top: 8px; } + + /* System selector: scroll horizontally, smaller text */ + .sys-selector { padding: 0 8px; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; } + .sys-selector::-webkit-scrollbar { display: none; } + .sys-btn { padding: 8px 14px; font-size: 13px; white-space: nowrap; flex-shrink: 0; } + .sys-version { display: none; } + + /* Nav: scroll horizontally, smaller */ + .nav { padding: 0; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; } + .nav::-webkit-scrollbar { display: none; } + .nav-btn { padding: 10px 14px; font-size: 12px; flex-shrink: 0; } + + /* Section content: reduce padding */ + .section { padding: 16px; } + + /* Stats: 2 columns on mobile */ + .stats { grid-template-columns: repeat(3, 1fr); gap: 8px; } + .stat { padding: 10px 8px; } + .stat-value { font-size: 18px; } + .stat-label { font-size: 10px; } + + /* Cards: single column */ + .cards { grid-template-columns: 1fr; } + .proto-grid { grid-template-columns: 1fr; } + .sec-grid { grid-template-columns: 1fr; } + + /* Layers: tighter padding */ + .layer { padding: 12px 14px; } + + /* Dep chain: smaller font */ + .dep-chain { font-size: 11px; padding: 12px 14px; overflow-x: auto; } + + /* Path tree: smaller font */ + .path-tree { font-size: 10px; padding: 12px 14px; } + + /* Comparison table: horizontal scroll */ + .cmp-table { display: block; overflow-x: auto; } + + /* Glossary: single column */ + .glossary { column-count: 1; } + + /* Resource grid: single column */ + .section > div[style*="grid-template-columns: 1fr 1fr"] { display: flex !important; flex-direction: column !important; } + + /* Popover: full width on mobile */ + .popover { width: calc(100vw - 32px); left: 16px !important; } + + /* Boot steps: tighter */ + .boot-step { gap: 12px; } + .boot-num { width: 24px; height: 24px; font-size: 11px; } + + /* Toggle buttons */ + .toggle-btn { padding: 6px 10px; font-size: 11px; } + } + + /* Small phones */ + @media (max-width: 400px) { + .stats { grid-template-columns: repeat(2, 1fr); } + .sys-btn { padding: 8px 10px; font-size: 12px; } + .nav-btn { padding: 8px 10px; font-size: 11px; } + .header h1 { font-size: 16px; } + } @@ -355,11 +428,11 @@
34
Containers
-
8
System Layers
+
260+
RPC Methods
9
Protocols
LUKS2
Encryption
-
Rootless
Containers
-
Tor
Privacy Layer
+
Rootless
Podman
+
8 GB+
Recommended RAM
@@ -461,6 +534,85 @@ vaultwarden nextcloud searxng uptime-kuma ollama onlyoffice nginx-pm portainer
+ +

System Resources

+ +
+ +
+
Hardware Requirements
+ + + + + + + + +
Minimum RAM4 GB
Recommended RAM8 GB+ (core stack uses ~8–10 GB)
Minimum Disk32 GB SSD
Recommended Disk1 TB+ NVMe SSD
CPUx86_64 or ARM64, 4+ cores recommended
NetworkEthernet recommended (WiFi supported)
TargetsHP ProDesk, Intel NUC, any standard PC
+
+ +
+
Memory Budget (all containers)
+ + + + + + + + + + +
Bitcoin Knots2 GB (1 GB low-memory mode)
ElectrumX1 GB
LND512 MB
BTCPay + DB1.5 GB (1 GB + 512 MB)
Mempool stack1.3 GB (512+256+512 MB)
Fedimint + GW1 GB (512+512 MB)
Ollama (AI)4 GB (1 GB low-memory)
All other apps128–1024 MB each
Total allocated~20 GB (not all run simultaneously)
+
+ +
+ +
+ +
+
Disk Usage by Component
+ + + + + + + + + +
Bitcoin blockchain (full)~600 GB
Bitcoin (pruned)~550 MB
ElectrumX index~50 GB
LND channels + wallet~1 GB
Databases (all)~2–10 GB
Container images~15 GB
Ollama models1–50 GB (varies)
Media (Jellyfin/Photos)User-determined
+
+ +
+
Network Ports (External)
+ + + + + + + +
80 / 443Nginx → Web UI, app proxies
8333Bitcoin P2P (node discovery)
9735Lightning P2P (payment routing)
50001Electrum protocol (wallet queries)
22SSH (admin access)
Internal only8332 (RPC), 10009 (gRPC), 8080 (REST), 8999, 4080, 3000, 3001, 8082–8096, 9000…
+
+ +
+ +
+
+
Container Security Defaults
+ + + + + + + +
Capabilities--cap-drop=ALL then add only needed: CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE. Some get NET_RAW (LND), NET_BIND_SERVICE (Vaultwarden, nginx-pm, LND-UI).
Privileges--security-opt=no-new-privileges on all containers
Health checksAll containers: --health-interval=120s --health-timeout=5s --health-retries=3
Low-memory modeAuto-detected: Bitcoin 2G→1G, PhotoPrism 1G→512M, OnlyOffice 2G→1G, Ollama 4G→1G
Disk modeAuto: if disk <1TB → Bitcoin prune=550, dbcache=512M. If ≥1TB → full txindex, dbcache=4G
RPC methods260+ registered across 20+ namespaces (auth, seed, package, bitcoin, lnd, identity, tor, nostr, mesh, federation, dwn, system, monitoring…)
+
+
+ @@ -1402,7 +1554,7 @@
-
+
JSON-RPC 2.0
Primary protocol between the web UI and the Rust backend. All commands are RPC calls.
Like texting the backend: you send a message ("please start this app"), it texts back ("done" or "error").
@@ -1417,7 +1569,7 @@
-
+
WebSocket
Real-time bidirectional channel for live updates — container status changes, logs, events.
An open phone line between your browser and the server. Instead of asking "any updates?" every second, the server just tells you when something changes.
@@ -1430,7 +1582,7 @@
-
+
Bitcoin RPC (JSON-RPC 1.0)
How apps talk to the Bitcoin node. Authenticated with username + HMAC-hashed password.
The language apps use to ask Bitcoin questions: "what's the current block?" or "send this transaction."
@@ -1443,7 +1595,7 @@
-
+
gRPC
High-performance RPC protocol used by LND for admin operations. Binary format, strongly typed.
A fast, structured way for apps to control the Lightning node. More efficient than regular HTTP for complex operations.
@@ -1456,7 +1608,7 @@
-
+
Electrum Protocol
Lightweight protocol for wallet address lookups. JSON-RPC over raw TCP sockets.
How Bitcoin wallets check their balance without downloading the entire blockchain. Ask "what transactions touched this address?" and get an instant answer.
@@ -1468,7 +1620,7 @@
-
+
ZMQ (ZeroMQ)
Publish-subscribe messaging from Bitcoin node. Instant notifications for new blocks and transactions.
A broadcasting system. When a new Bitcoin block is found, Bitcoin instantly shouts it out and everyone listening hears immediately.
@@ -1481,7 +1633,7 @@
-
+
Tor (SOCKS5 + Hidden Services)
Privacy layer. Routes Bitcoin P2P through onion routing, exposes services as .onion addresses.
Like sending a letter through 3 random post offices so nobody knows where it came from. Also lets people reach your node without knowing your real IP.
@@ -1494,7 +1646,7 @@
-
+
Nostr (NIP-01)
Decentralized social protocol. WebSocket-based relay communication for events (posts, follows, messages).
A social media protocol where no company controls the network. Your posts live on relays, and you own your identity with a cryptographic key.
@@ -1507,7 +1659,7 @@
-
+
DWN (Decentralized Web Node)
W3C protocol for storing encrypted data and messages in a decentralized way. Identity-linked storage.
A personal data vault. Apps can store data here that only you control. Like a safety deposit box that follows you across the internet.
@@ -2413,7 +2565,7 @@
-
+
JSON-RPC (Host ↔ Service)
All communication between the Rust backend and services uses JSON-RPC over Unix domain sockets.
Apps and the system talk through a structured messaging format over local socket files — fast and secure.
@@ -2426,7 +2578,7 @@
-
+
JSON-RPC (UI ↔ Backend)
The Angular frontend communicates with startd via JSON-RPC over HTTP. 100+ API methods. State sync via Patch-DB WebSocket.
The dashboard sends commands and gets responses in JSON format. Live updates stream automatically through a WebSocket.
@@ -2438,7 +2590,7 @@
-
+
Patch-DB (CBOR Reactive Sync)
Custom reactive database using CBOR encoding. Backend pushes diffs over WebSocket — UI updates automatically without polling.
Instead of the UI constantly asking "what changed?", the backend pushes only what changed, in a compact binary format.
@@ -2450,7 +2602,7 @@
-
+
HTTPS / TLS (Built-in)
TLS termination handled directly by the Rust backend (tokio-rustls). Self-signed root CA per server + ACME for public domains.
The backend IS the web server — no nginx or caddy needed. It handles encryption directly.
@@ -2462,7 +2614,7 @@
-
+
WireGuard (VPN Tunnels)
First-class WireGuard support for remote access. Users add WireGuard configs as "gateways." Managed by tunnelbox daemon.
Built-in VPN for accessing your node from anywhere. Add a WireGuard config and get a secure tunnel.
@@ -2473,7 +2625,7 @@
-
+
DNS (hickory-server)
Built-in DNS server for service discovery and resolution. Also uses mDNS (avahi) for .local domain access on LAN.
The system runs its own DNS so containers can find each other by name. Your phone finds the node via .local address.
@@ -2879,7 +3031,7 @@
-
+
tRPC (UI ↔ Backend)
TypeScript-first RPC framework with end-to-end type safety. Runs over both HTTP and WebSocket on port 80.
A typed communication channel between the dashboard and the backend. If the API changes, TypeScript catches errors automatically.
@@ -2892,7 +3044,7 @@
-
+
Docker Compose (App Lifecycle)
Each app managed via Docker Compose v2. Install/start/stop/update handled by a bash script (app-script) calling docker compose.
Apps are defined as Docker Compose projects. A bash script handles the lifecycle by calling docker compose commands.
@@ -2904,7 +3056,7 @@
-
+
exports.sh (Dependency Resolution)
Shell scripts that export environment variables (IPs, ports, RPC credentials). When app B depends on app A, A's exports.sh is sourced first.
Apps share their connection details through environment variables set by shell scripts.
@@ -2916,7 +3068,7 @@
-
+
Tor (Optional, Containerized)
Toggle per-system. tor_proxy container provides SOCKS5 at 10.21.21.11. Per-app tor_server containers create hidden services.
Tor is optional and runs in its own container. When enabled, each app gets its own .onion address for remote access.
@@ -2928,7 +3080,7 @@
-
+
JWT + Proxy Tokens (Auth)
JWT for API authentication. Separate "proxy tokens" validate iframe requests to app_proxy containers. bcrypt password hashing.
Login tokens that prove who you are. Separate tokens for the dashboard API and for accessing individual apps.
@@ -2940,7 +3092,7 @@
-
+
Git (App Store)
App store is a Git repository cloned locally via isomorphic-git. Pulled every 5 minutes for updates.
The app catalog is just a Git repo. Umbrel checks for new apps and updates by pulling the latest commits every 5 minutes.
@@ -3533,6 +3685,138 @@ function syncRowHeights(cards) { } } +function toggleProto(el) { + const grid = el.closest('.proto-grid') + if (grid) { + const isExpanding = !el.classList.contains('expanded') + const cards = Array.from(grid.querySelectorAll('.proto-card')) + + if (isExpanding) { + // Convert free-form Label: HTML into structured detail-rows + cards.forEach(structureProtoDetails) + // Normalize so all cards have the same rows in same order + normalizeProtoRows(cards) + cards.forEach(c => c.classList.add('expanded')) + grid.classList.add('all-expanded') + requestAnimationFrame(() => requestAnimationFrame(() => syncProtoHeights(cards))) + } else { + cards.forEach(c => c.classList.remove('expanded')) + grid.classList.remove('all-expanded') + grid.querySelectorAll('.proto-card-name, .proto-card-desc, .proto-card-layman, .detail-row').forEach(e => e.style.minHeight = '') + } + } else { + el.classList.toggle('expanded') + } +} + +// Convert Label: value
into structured detail-rows +function structureProtoDetails(card) { + const details = card.querySelector('.proto-details') + if (!details || details.dataset.structured) return + details.dataset.structured = '1' + + const html = details.innerHTML + // Split on
or
or
+ const lines = html.split(//).map(l => l.trim()).filter(Boolean) + const rows = [] + let currentLabel = null + let currentValue = '' + + lines.forEach(line => { + // Check if line starts with Label: + const match = line.match(/^([^<]+?):?<\/b>\s*(.*)/) + if (match) { + if (currentLabel) rows.push({ label: currentLabel, value: currentValue.trim() }) + currentLabel = match[1].replace(/:$/, '').trim() + currentValue = match[2] || '' + } else if (currentLabel) { + // Continuation line (like bullet points) + currentValue += (currentValue ? '
' : '') + line + } else { + // Standalone line without label + currentLabel = line.replace(/<[^>]+>/g, '').substring(0, 20).trim() + currentValue = line + } + }) + if (currentLabel) rows.push({ label: currentLabel, value: currentValue.trim() }) + + if (rows.length > 0) { + details.innerHTML = rows.map(r => + '
' + r.label + '' + (r.value || '\u2014') + '
' + ).join('') + } +} + +// Normalize proto detail rows across cards (same as normalizeDetailRows) +function normalizeProtoRows(cards) { + const allLabels = [] + const seen = new Set() + cards.forEach(card => { + const details = card.querySelector('.proto-details') + if (!details) return + details.querySelectorAll('.detail-row .detail-label').forEach(lbl => { + const t = lbl.textContent.trim() + if (!seen.has(t)) { seen.add(t); allLabels.push(t) } + }) + }) + if (allLabels.length === 0) return + + cards.forEach(card => { + const details = card.querySelector('.proto-details') + if (!details || details.dataset.normalized) return + details.dataset.normalized = '1' + + const existing = {} + details.querySelectorAll('.detail-row').forEach(row => { + const lbl = row.querySelector('.detail-label') + if (lbl) existing[lbl.textContent.trim()] = row + }) + + const frag = document.createDocumentFragment() + allLabels.forEach(label => { + if (existing[label]) { + frag.appendChild(existing[label]) + } else { + const row = document.createElement('div') + row.className = 'detail-row' + row.innerHTML = '' + label + '\u2014' + frag.appendChild(row) + } + }) + details.appendChild(frag) + }) +} + +function syncProtoHeights(cards) { + if (cards.length < 2) return + + function syncByClass(cls) { + const els = cards.map(c => c.querySelector('.' + cls)).filter(Boolean) + if (els.length < 2) return + els.forEach(e => e.style.minHeight = '') + let max = 0 + els.forEach(e => { max = Math.max(max, e.offsetHeight) }) + if (max > 0) els.forEach(e => e.style.minHeight = max + 'px') + } + + syncByClass('proto-card-name') + syncByClass('proto-card-desc') + syncByClass('proto-card-layman') + + // Sync detail rows by index + const maxRows = Math.max(...cards.map(c => c.querySelectorAll('.detail-row').length)) + for (let i = 0; i < maxRows; i++) { + const rows = cards.map(c => { + const all = c.querySelectorAll('.detail-row') + return all[i] || null + }).filter(Boolean) + rows.forEach(r => r.style.minHeight = '') + let maxH = 0 + rows.forEach(r => { maxH = Math.max(maxH, r.offsetHeight) }) + if (maxH > 0) rows.forEach(r => r.style.minHeight = maxH + 'px') + } +} + function find(name) { event.stopPropagation() const card = document.querySelector(`[data-name="${name}"]`)