21 KiB
Architecture
Analysis Date: 2026-07-29
System Overview
┌────────────────────────────────────────────────────────────────┐
│ Frontend Layer (Vue 3) │
│ `neode-ui/src` (TypeScript + SPA) │
│ Routes → Views → Components → Composables → RPC Client │
└────────────────┬─────────────────────────────────────────────┘
│ WebSocket + HTTP(S)
│ JSON-RPC 2.0 protocol
▼
┌────────────────────────────────────────────────────────────────┐
│ HTTP Server Layer (Hyper) │
│ `core/archipelago/src/server.rs` │
│ TCP Listener → Hyper → Router → ApiHandler/RpcHandler │
└────────────────┬─────────────────────────────────────────────┘
│
┌──────────┴──────────┬──────────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌────────────┐
│ WebSocket │ RPC │ │ Content │
│ Handler │ Handler │ │ Proxy │
│ (state sync) │ (methods)│ │ (app URIs) │
└─────────┘ └──────────┘ └────────────┘
│ │
└──────────┬───────┘
│
▼
┌─────────────────────────────────────┐
│ Service Layer (Async Tasks) │
│ `core/archipelago/src/api/rpc/*` │
│ │
│ • auth, identity, secrets │
│ • container orchestration │
│ • bitcoin, lightning, wallet │
│ • mesh, federation, FIPS │
│ • content, backup, settings │
└─────────────┬───────────────────────┘
│
┌───────────┼───────────┬──────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌────────┐ ┌──────────┐
│Container│ │ State │ │BlobStore
│Orch. │ │Manager │ │ │ │Identity │
│(Podman) │ │(Broadcast
│ │ │ channels)│ │ ContentClient Manager │
└─────────┘ └──────────┘ └────────┘ └──────────┘
│ │ │ │
└───────────┼───────────┼─────────┘
│
▼
┌─────────────────────────────────────┐
│ Persistent Storage Layer │
│ │
│ • Data directory files (YAML/JSON) │
│ • SQLite (session store) │
│ • Blob store (content-addressed) │
│ • Podman container state │
│ • Secret vaults (encrypted) │
└─────────────────────────────────────┘
Component Responsibilities
| Component | Responsibility | File |
|---|---|---|
| Server | HTTP listener, connection multiplexing, TLS/encryption | core/archipelago/src/server.rs |
| ApiHandler | HTTP request routing, authentication, response formatting | core/archipelago/src/api/handler/mod.rs |
| RpcHandler | JSON-RPC 2.0 dispatch, method registration, rate limiting | core/archipelago/src/api/rpc/mod.rs |
| ContainerOrchestrator | Podman lifecycle, manifest reconciliation, adoption | core/archipelago/src/container/prod_orchestrator.rs |
| StateManager | Central state broadcast channel, revision tracking | core/archipelago/src/state.rs |
| AuthManager | User credentials, session validation, password hashing | core/archipelago/src/auth.rs |
| Identity Manager | Node Ed25519 keys, seed derivation, Tor address | core/archipelago/src/identity_manager.rs |
| BootReconciler | Periodic manifest sync loop, adoption, remediation | core/archipelago/src/container/boot_reconciler.rs |
| Frontend Router | Vue Router, page navigation, deep linking | neode-ui/src/router/index.ts |
| Frontend Stores | Pinia state (apps, settings, user, mesh) | neode-ui/src/stores/ |
| Frontend Components | UI elements, modals, cards, layout primitives | neode-ui/src/components/ |
Pattern Overview
Overall: Multi-tier async architecture with centralized request dispatch and broadcast state synchronization.
Key Characteristics:
- Async-first (Tokio) - All I/O operations are non-blocking; task spawning for background work
- RPC-driven API - Frontend communicates via JSON-RPC 2.0 (not REST); single
/api/v0WebSocket + HTTP endpoint - State as broadcast - Global state changes flow through Tokio broadcast channels to all connected WebSocket clients
- Manifest-driven containers - App lifecycle controlled by declarative YAML manifests (Archipelago-specific extensions)
- Plugin architecture - Apps are isolated Podman containers with declarative interfaces (web UI, ports, secrets)
Layers
HTTP/Transport Layer:
- Purpose: Accept inbound connections, handle TLS termination, demultiplex HTTP/WebSocket
- Location:
core/archipelago/src/server.rs - Contains: Hyper listener, TCP accept loop, connection state tracking
- Depends on: Tokio, Hyper, TLS/mTLS libraries (rustls/openssl)
- Used by: All external clients (web UI, companion app, API consumers)
Request Routing & Auth Layer:
- Purpose: Dispatch HTTP requests to handlers, validate sessions, enforce CSRF, rate-limit login
- Location:
core/archipelago/src/api/(handler + rpc submodules) - Contains: Route matching, middleware chain, cookie extraction, error formatting
- Depends on: Server, StateManager, SessionStore
- Used by: All request paths; gates API access
RPC Dispatch Layer:
- Purpose: Deserialize JSON-RPC 2.0 requests, call appropriate service method, serialize responses
- Location:
core/archipelago/src/api/rpc/mod.rs+ subdirectories (auth.rs, container.rs, bitcoin.rs, etc.) - Contains: Method table, parameter validation, response formatting, rate limit checks
- Depends on: All service modules
- Used by: Frontend (WebSocket + HTTP POST to /api/v0), internal tools
Service Layer:
- Purpose: Implement business logic — container lifecycle, identity, auth, content sync, mesh discovery
- Location:
core/archipelago/src/api/rpc/*(one RPC module per domain), pluscore/archipelago/src/(background tasks) - Contains: ~40 RPC method modules + 50+ core service modules (bootstrap.rs, health_monitor.rs, crash_recovery.rs, etc.)
- Depends on: StateManager, ContainerOrchestrator, config/secrets, external services (Bitcoin, Lightning, FIPS)
- Used by: RPC layer; other services for cross-cutting concerns (mesh, federation, webhooks)
State Management Layer:
- Purpose: Hold canonical application state, broadcast changes to all connected clients, persist snapshots
- Location:
core/archipelago/src/state.rs(StateManager + data_model.rs) - Contains: RwLock, broadcast channel, revision counter
- Depends on: DataModel (serde-serializable struct tree)
- Used by: All services that mutate state (container ops, auth, settings)
Container Orchestration Layer:
- Purpose: Podman lifecycle management, image verification, secret injection, crash recovery, adoption
- Location:
core/archipelago/src/container/prod_orchestrator.rs(1M+ lines; split across boot_reconciler.rs, quadlet.rs, docker_packages.rs, etc.) - Contains: Manifest parsing, image pull/verify, container create/start/stop, volume mounts, networking
- Depends on: Podman CLI + socket, config parser, image registries, local filesystem
- Used by: RPC container.* methods, BootReconciler loop, crash recovery
Frontend Layer (Vue 3):
- Purpose: Render UI, dispatch RPC calls, maintain local UI state, handle user input
- Location:
neode-ui/src/ - Contains: Views (pages), Components (reusable UI), Composables (logic hooks), Stores (Pinia), Router
- Depends on: Vue 3, Vue Router, Pinia, RPC client library (custom), D3/Leaflet (charts/maps)
- Used by: Browser clients (desktop, mobile, companion app via WebView)
Data Flow
Primary Request Path (User Action → Backend → State Sync)
-
Frontend user interaction (click button, type input) → Vue component event handler
- Location:
neode-ui/src/views/*.vueorneode-ui/src/components/*.vue
- Location:
-
Composable dispatches RPC (e.g.,
useContainerInstall()callsrpc.container.install())- Location:
neode-ui/src/composables/(custom or imported fromapi/rpc-client.ts)
- Location:
-
RPC client serializes → HTTP/WebSocket POST to /api/v0
- Location:
neode-ui/src/api/rpc-client.ts - Payload:
{ jsonrpc: "2.0", method: "container.install", params: {...}, id: ... }
- Location:
-
HTTP Server receives, routes to ApiHandler
- Location:
core/archipelago/src/server.rs(listener) →core/archipelago/src/api/handler/mod.rs(dispatch)
- Location:
-
ApiHandler checks auth, extracts body, calls RpcHandler
- Location:
core/archipelago/src/api/handler/mod.rs:handle_request()
- Location:
-
RpcHandler dispatches by method name to specific RPC module
- Location:
core/archipelago/src/api/rpc/mod.rs:call()→ routing tocore/archipelago/src/api/rpc/container.rs:install()
- Location:
-
Service method executes (e.g.,
container.rs:install()calls orchestrator, updates state)- Location:
core/archipelago/src/api/rpc/container.rs(calls methods on ContainerOrchestrator)
- Location:
-
StateManager.update_data() broadcasts the new state to all WebSocket subscribers
- Location:
core/archipelago/src/state.rs:update_data()→ broadcast channel - All connected WebSocket clients receive
{ rev: N, data: {...} }update
- Location:
-
Frontend receives state update, updates Pinia stores, re-renders UI
- Location:
neode-ui/src/stores/(Pinia stores mutate) → Vue reactivity chain → DOM update
- Location:
State Management:
- All reads from
StateManagergo throughget_snapshot()which acquires read-lock - All writes go through
update_data()which acquires write-lock + increments revision - Broadcast channel has ~100-message buffer; slow subscribers may lose old updates (by design — UI only needs latest)
- WebSocket clients re-sync on reconnect via
get_snapshot()call (full state transfer)
Secondary Flow: Scheduled Reconciliation (Convergence Loop)
-
BootReconciler spawned at startup in
main.rs- Location:
core/archipelago/src/main.rs(line ~338-348)
- Location:
-
Reconciler runs every
RECONCILER_DEFAULT_INTERVAL(~30s typical)- Location:
core/archipelago/src/container/boot_reconciler.rs:run_forever()
- Location:
-
Compares desired manifests (disk + registry catalog) vs actual Podman state
- Looks for: containers missing, containers orphaned, image updates, secret changes
-
Applies remediation (create, delete, restart containers)
- Calls: orchestrator.reconcile_*() methods
-
Logs changes, broadcasts state update if anything changed
- Frontend receives update, shows user the reconciled app state
This ensures apps survive crashes, OTA updates, or manual Podman edits — the desired state always converges.
Key Abstractions
ContainerOrchestrator trait:
- Purpose: Abstract container lifecycle behind a trait so Prod (Podman-based) and Dev (in-memory) modes can coexist
- Examples:
core/archipelago/src/container/prod_orchestrator.rs,core/archipelago/src/container/dev_orchestrator.rs - Pattern: Trait-based strategy; RpcHandler holds
Arc<dyn ContainerOrchestrator>, switches at runtime - Methods: create, start, stop, delete, adopt, list, reconcile, install, upgrade
Manifest (YAML-based declarative app):
- Purpose: Fully describe an app's container, dependencies, secrets, ports, UI in one file
- Examples:
/opt/archipelago/apps/*/manifest.yml(on-disk) or registry-delivered catalogs - Pattern: Custom extensions over OCI/Docker Compose (e.g.,
interfaces.main.ui,generated_secrets) - Parsed into:
container::manifest::Manifeststruct, consumed by orchestrator
RPC Method Modules:
- Purpose: Group related JSON-RPC methods by domain (auth, container, bitcoin, mesh, etc.)
- Examples:
core/archipelago/src/api/rpc/auth.rs,core/archipelago/src/api/rpc/bitcoin.rs - Pattern: Each module exports
pub async fn method_name(handler, params) -> Result<Response> - Registration: Hardcoded dispatch in
RpcHandler::call()(no reflection; methods are explicit)
BlobStore (Content-Addressed):
- Purpose: Store attachments/files by SHA-256 hash; issue time-limited capability tokens for access
- Examples: Used by mesh.send-content, federation attachments, backup archives
- Pattern: Capability-based access control (CBAC); tokens scoped to issuer pubkey + hash
- Located:
core/archipelago/src/blobs.rs+core/archipelago/src/content_server.rs
StateManager + DataModel:
- Purpose: Single source of truth for UI state; broadcast updates to all clients
- Pattern: Read-write lock over a serde-serializable struct tree; broadcast channel for efficiency
- Persistence: Most state is ephemeral (app listings, UI settings); durable state persists to disk separately
- Clients: Frontend (WebSocket subscriber), internal services (read via get_snapshot), monitoring/debug
Session Store:
- Purpose: Track authenticated HTTP sessions (cookie → user identity mapping)
- Examples: SQLite-backed or in-memory store
- Pattern: Session token issued at login, validated on each request, expires after TTL
- Used by: ApiHandler auth check, rate limiter (per IP + per user)
Entry Points
Backend Daemon (Binary):
- Location:
core/archipelago/src/main.rs - Triggers:
systemd start archipelago.serviceor manual./archipelagoon development node - Responsibilities: Parse config, init tracing, load/reconcile containers, start HTTP server, spawn background tasks
- Key setup: Load identity → setup auth → spawn orchestrator → load manifests → start reconciler → start server
Frontend SPA:
- Location:
neode-ui/src/main.ts - Triggers: Browser loads
/index.html(served by HTTP server from/opt/archipelago/web-ui/) - Responsibilities: Boot Vue app, setup Router, setup Pinia stores, establish WebSocket to backend
- Key setup: Mount app → router ready → fetch initial state → subscribe to updates
RPC Endpoints (HTTP + WebSocket):
- Location:
core/archipelago/src/api/(handler routes requests here) - Endpoint:
/api/v0(JSON-RPC 2.0 POST or WebSocket upgrade) - Methods: ~200+ RPCs across domains (auth, container, bitcoin, mesh, federation, etc.)
- Example:
POST /api/v0with body{"jsonrpc": "2.0", "method": "auth.login", "params": {...}, "id": 1}
Background Tasks (Spawned at startup):
- BootReconciler: Periodic manifest reconciliation loop
- Health Monitor: Periodic app health checks + restart
- Update Scheduler: Periodic app update checks
- Mesh Service: P2P mesh listener + sender (federation, LoRa)
- Webhook Relay: Listens for inbound webhooks, broadcasts to subscribers
- WebSocket Listener: Upgraded HTTP connections → broadcast state subscriber
- See:
core/archipelago/src/main.rs(lines ~400-450 show the spawned tasks)
Architectural Constraints
- Single event loop — All I/O-bound work runs on a single Tokio multi-threaded runtime; no worker threads by default (some container ops are blocking, run in tokio::task::spawn_blocking)
- Global state via broadcast — StateManager broadcasts to all WebSocket clients; no request-response for state changes (async by design)
- Container state mutability — Podman state can drift from manifest (manual edits, crashes); reconciler runs periodically to converge
- No in-process data consistency — Multiple services can mutate StateManager concurrently; last write wins (fine for UI; critical ops use locks)
- Shared blob store — All services that need to share content use the same BlobStore instance (single cap_key, single root directory)
- Rate limiting per IP + method — Prevents brute-force login, but shared IPs see shared limits (edge case: family users, proxies)
- Session cookie same-site — WebSocket + HTTP POST must be same-origin; CORS headers controlled by ApiHandler
Anti-Patterns
Circular RPC Dispatches
What happens: An RPC method calls back into another RPC method, forming a cycle (e.g., auth.login → container.list → auth.check_permission → auth.login)
Why it's wrong: Deadlocks on RwLocks, infinite loops on state broadcasts, unclear error messages, hard to debug
Do this instead: Pass check result as a side-effect from the outer method; compute permissions once at the start. Use composable patterns in frontend instead (e.g., useCanInstall() checks perms once per component mount).
Synchronous blocking in RPC handlers
What happens: RPC method calls .unwrap() on Podman command result, blocking the entire event loop
Why it's wrong: One slow container op (e.g., large image pull) blocks all concurrent users
Do this instead: Use tokio::task::spawn_blocking() for I/O that may take >100ms. See core/archipelago/src/container/docker_packages.rs for examples.
Hardcoding paths in app RPC modules
What happens: bitcoin.rs hardcodes /opt/archipelago/data/bitcoin.conf instead of using config.data_dir
Why it's wrong: Dev mode, tests, and alternate installs all fail with "not found"
Do this instead: Read from Config struct, which is passed to every RPC method. See core/archipelago/src/api/rpc/bitcoin.rs:status() for correct pattern.
Frontend state outside Pinia stores
What happens: Components use component-local ref<> for app list, duplicate the StateManager's data
Why it's wrong: Stale data after OTA updates, inconsistent with other users on the same node, race conditions on install/uninstall
Do this instead: Always derive from Pinia stores (e.g., useAppStore().apps). Stores subscribe to WebSocket updates. See neode-ui/src/stores/appStore.ts.
Not handling WebSocket reconnection
What happens: Frontend goes offline for 10s (network glitch), WebSocket closes, frontend doesn't re-sync state
Why it's wrong: UI shows stale data (app still "installing" when actually done), user clicks again, double-action happens
Do this instead: WebSocket reconnect handler should re-fetch full state (node.status, etc.), re-subscribe. See neode-ui/src/api/rpc-client.ts for the reconnect loop.
Error Handling
Strategy: Defensive layering — errors are caught at each tier, logged, and converted to user-facing messages.
Patterns:
- HTTP layer: 4xx/5xx with JSON error (no 500s for logic errors; only for crashes)
- RPC layer: Serialize error as
{ error: { code: N, message: "...", data: {...} } }per JSON-RPC spec - Service layer: Use
anyhow::Result<T>+?operator for early exit; convert toRpcErrorat handler boundary - Frontend: Catch RPC errors, show toast/modal, log to console (never crash the app)
Critical paths:
- Auth failure: 401 Unauthorized + "Invalid password" (no "user not found" to leak usernames)
- Container ops: If reconciler sees drift, logs it but continues (never crashes the daemon)
- Image pull failure: Fallback to last-cached version if network timeout (user is never blocked on external registries)
- Podman socket unavailable: Return 503 Service Unavailable (user sees "Archipelago is starting")
Architecture analysis: 2026-07-29