archy/neode-ui/src/api/rpc-client.ts
Dorian 56e04a9df8 fix: netavark GLIBC mismatch in ISO, container adopt, app updates
ISO build no longer copies netavark from build host (Debian 13/GLIBC 2.41)
which broke container networking on Debian 12 targets. Rootfs already
installs netavark from Debian 12 repos — just configure the backend.

Install RPC now adopts existing containers (from first-boot) instead of
erroring on duplicates. Container scanner extracts real versions from
image tags and detects available updates against pinned versions.

Frontend shows update button with version info when updates are available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 11:47:35 +02:00

792 lines
21 KiB
TypeScript

// RPC Client for connecting to Archipelago backend
export interface RPCOptions {
method: string
params?: Record<string, unknown>
timeout?: number
}
export interface RPCResponse<T> {
result?: T
error?: {
code: number
message: string
data?: unknown
}
}
function getCsrfToken(): string | null {
const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/)
if (match) return match[1]!
// Fallback: check for a meta tag (useful when cookies are blocked or not yet set)
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? null
}
class RPCClient {
private static _sessionExpiredRedirecting = false
private baseUrl: string
constructor(baseUrl: string = '/rpc/v1') {
this.baseUrl = baseUrl
}
async call<T>(options: RPCOptions): Promise<T> {
const { method, params = {}, timeout = 15000 } = options
const maxRetries = 3
for (let attempt = 0; attempt < maxRetries; attempt++) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
const csrfToken = getCsrfToken()
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken
}
const response = await fetch(this.baseUrl, {
method: 'POST',
credentials: 'include', // Important for session cookies
headers,
body: JSON.stringify({ method, params }),
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!response.ok) {
// Session expired — debounced redirect to login
// Use a single shared timeout to prevent redirect storms when
// multiple parallel requests all get 401 at once
if (response.status === 401 && method !== 'auth.login') {
// Clear stale auth immediately — stops App.vue watcher from
// firing more requests and prevents the router from
// optimistically navigating to /dashboard
try { localStorage.removeItem('neode-auth') } catch { /* noop */ }
const isOnboarding = window.location.pathname.startsWith('/onboarding')
console.warn(`[RPC] 401 on ${method} | path=${window.location.pathname} | onboarding=${isOnboarding} | redirecting=${RPCClient._sessionExpiredRedirecting}`)
if (!isOnboarding && !RPCClient._sessionExpiredRedirecting) {
RPCClient._sessionExpiredRedirecting = true
console.warn(`[RPC] Session expired — redirecting to /login in 300ms`)
setTimeout(() => {
window.location.href = '/login'
}, 300)
}
throw new Error('Session expired')
}
// 403: read body to distinguish CSRF (retryable) from RBAC (permanent)
if (response.status === 403) {
let reason = ''
try {
const body: RPCResponse<unknown> = await response.json()
reason = body.error?.message || ''
} catch { /* body parse failed */ }
const isCsrf = !reason || reason.toLowerCase().includes('csrf')
if (isCsrf && attempt < maxRetries - 1) {
// CSRF mismatch — cookie may have been updated by a concurrent
// Set-Cookie response not yet visible to JS. Retry after delay.
await new Promise((r) => setTimeout(r, 500))
continue
}
throw new Error(reason || `HTTP 403: Forbidden`)
}
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
const isRetryable = response.status === 502 || response.status === 503
if (isRetryable && attempt < maxRetries - 1) {
const delay = 600 * (attempt + 1)
await new Promise((r) => setTimeout(r, Math.floor(delay * (0.5 + Math.random() * 0.5))))
continue
}
throw err
}
const data: RPCResponse<T> = await response.json()
if (data.error) {
throw new Error(data.error.message || 'RPC Error')
}
return data.result as T
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof Error) {
if (error.name === 'AbortError') {
const timeoutErr = new Error('Request timeout')
if (attempt < maxRetries - 1) {
const delay = 600 * (attempt + 1)
await new Promise((r) => setTimeout(r, Math.floor(delay * (0.5 + Math.random() * 0.5))))
continue
}
throw timeoutErr
}
const msg = error.message
const isRetryable = /502|503|Bad Gateway|fetch|network/i.test(msg)
if (isRetryable && attempt < maxRetries - 1) {
const delay = 600 * (attempt + 1)
await new Promise((r) => setTimeout(r, Math.floor(delay * (0.5 + Math.random() * 0.5))))
continue
}
throw error
}
throw new Error('Unknown error occurred')
}
}
throw new Error('Request failed after retries')
}
// Convenience methods
async login(password: string): Promise<{ requires_totp?: boolean } | null> {
return this.call({
method: 'auth.login',
params: {
password,
},
})
}
async loginTotp(code: string): Promise<{ success: boolean }> {
return this.call({ method: 'auth.login.totp', params: { code } })
}
async loginBackup(code: string): Promise<{ success: boolean }> {
return this.call({ method: 'auth.login.backup', params: { code } })
}
async totpSetupBegin(password: string): Promise<{
qr_svg: string
secret_base32: string
pending_token: string
}> {
return this.call({ method: 'auth.totp.setup.begin', params: { password } })
}
async totpSetupConfirm(params: {
code: string
password: string
pendingToken: string
}): Promise<{ enabled: boolean; backup_codes: string[] }> {
return this.call({ method: 'auth.totp.setup.confirm', params })
}
async totpDisable(password: string, code: string): Promise<{ disabled: boolean }> {
return this.call({ method: 'auth.totp.disable', params: { password, code } })
}
async totpStatus(): Promise<{ enabled: boolean }> {
return this.call({ method: 'auth.totp.status', params: {} })
}
async changePassword(params: {
currentPassword: string
newPassword: string
alsoChangeSsh?: boolean
}): Promise<{ success: boolean }> {
return this.call({
method: 'auth.changePassword',
params: {
currentPassword: params.currentPassword,
newPassword: params.newPassword,
alsoChangeSsh: params.alsoChangeSsh ?? true,
},
})
}
async logout(): Promise<void> {
return this.call({
method: 'auth.logout',
params: {},
})
}
async completeOnboarding(): Promise<boolean> {
return this.call({
method: 'auth.onboardingComplete',
params: {},
})
}
async isOnboardingComplete(): Promise<boolean> {
return this.call({
method: 'auth.isOnboardingComplete',
params: {},
})
}
async resetOnboarding(): Promise<boolean> {
return this.call({
method: 'auth.resetOnboarding',
params: {},
})
}
// ─── Seed Management ───────────────────────────────────────────────
async generateSeed(): Promise<{ words: string[] }> {
return this.call({ method: 'seed.generate' })
}
async verifySeed(words: string[], indices: number[]): Promise<{
verified: boolean
did: string
nostr_npub: string
}> {
return this.call({ method: 'seed.verify', params: { words, indices } })
}
async restoreSeed(words: string[]): Promise<{
did: string
nostr_npub: string
restored: boolean
}> {
return this.call({ method: 'seed.restore', params: { words } })
}
async saveSeedEncrypted(passphrase: string): Promise<{ saved: boolean }> {
return this.call({ method: 'seed.save-encrypted', params: { passphrase } })
}
async seedStatus(): Promise<{
has_seed: boolean
is_legacy: boolean
identity_count: number
next_index: number
}> {
return this.call({ method: 'seed.status' })
}
// ─── Node Identity ───────────────────────────────────────────────
async getNodeDid(): Promise<{ did: string; pubkey: string }> {
return this.call({
method: 'node.did',
params: {},
})
}
async signChallenge(challenge: string): Promise<{ signature: string }> {
return this.call({
method: 'node.signChallenge',
params: { challenge },
})
}
async createBackup(passphrase: string): Promise<{
version: number
did: string
pubkey: string
kid: string
encrypted: boolean
blob: string
timestamp: string
}> {
return this.call({
method: 'node.createBackup',
params: { passphrase },
})
}
async resolveDid(did?: string): Promise<Record<string, unknown>> {
return this.call({
method: 'identity.resolve-did',
params: did ? { did } : {},
})
}
async createPresentation(params: {
holderId: string
credentialIds: string[]
}): Promise<Record<string, unknown>> {
return this.call({
method: 'identity.create-presentation',
params: { holder_id: params.holderId, credential_ids: params.credentialIds },
})
}
async verifyPresentation(presentation: Record<string, unknown>): Promise<{
valid: boolean
holder_valid: boolean
credentials: Array<{ id: string; valid: boolean; revoked: boolean }>
}> {
return this.call({
method: 'identity.verify-presentation',
params: { presentation },
})
}
async createPsbt(params: {
outputs: Array<{ address: string; amount_sats: number }>
feeRateSatPerVbyte?: number
}): Promise<{
psbt_base64: string
change_output_index: number
total_amount_sats: number
fee_rate_sat_per_vbyte: number
}> {
return this.call({
method: 'lnd.create-psbt',
params: {
outputs: params.outputs,
fee_rate_sat_per_vbyte: params.feeRateSatPerVbyte ?? 10,
},
})
}
async finalizePsbt(signedPsbtBase64: string): Promise<{
raw_final_tx: string
broadcast: boolean
}> {
return this.call({
method: 'lnd.finalize-psbt',
params: { signed_psbt_base64: signedPsbtBase64 },
})
}
async publishNostrIdentity(): Promise<{ event_id: string; success: number; failed: number }> {
return this.call({
method: 'node.nostr-publish',
params: {},
})
}
async getNostrPubkey(): Promise<{ nostr_pubkey: string; nostr_npub?: string }> {
return this.call({
method: 'node.nostr-pubkey',
params: {},
})
}
async listPeers(): Promise<{ peers: Array<{ onion: string; pubkey: string; name?: string }> }> {
return this.call({
method: 'node-list-peers',
params: {},
})
}
async addPeer(params: { onion: string; pubkey: string; name?: string }): Promise<{ peers: unknown[] }> {
return this.call({
method: 'node-add-peer',
params,
})
}
async removePeer(pubkey: string): Promise<{ peers: unknown[] }> {
return this.call({
method: 'node-remove-peer',
params: { pubkey },
})
}
async sendMessageToPeer(onion: string, message: string): Promise<{ ok: boolean; sent_to: string }> {
return this.call({
method: 'node-send-message',
params: { onion, message },
timeout: 90000,
})
}
async checkPeerReachable(onion: string): Promise<{ onion: string; reachable: boolean }> {
return this.call({
method: 'node-check-peer',
params: { onion },
timeout: 35000,
})
}
async getReceivedMessages(): Promise<{ messages: Array<{ from_pubkey: string; message: string; timestamp: string }> }> {
return this.call({
method: 'node-messages-received',
params: {},
})
}
async discoverNodes(): Promise<{ nodes: Array<{ did: string; onion: string; pubkey: string; node_address: string }> }> {
return this.call({
method: 'node-nostr-discover',
params: {},
timeout: 20000,
})
}
async getTorAddress(): Promise<{ tor_address: string | null }> {
return this.call({
method: 'node.tor-address',
params: {},
})
}
async torListServices(): Promise<{ services: Array<{ name: string; local_port: number; onion_address: string | null; enabled: boolean }> }> {
return this.call({ method: 'tor.list-services' })
}
async torRotateService(name: string): Promise<{ rotated: boolean; name: string; old_onion: string | null; new_onion: string | null; transition_hours: number }> {
return this.call({ method: 'tor.rotate-service', params: { name } })
}
async torToggleApp(appId: string, enabled: boolean): Promise<{ app_id: string; enabled: boolean; changed: boolean; onion_address: string | null }> {
return this.call({ method: 'tor.toggle-app', params: { app_id: appId, enabled } })
}
async torCleanupRotated(): Promise<{ cleaned: string[]; count: number }> {
return this.call({ method: 'tor.cleanup-rotated' })
}
async verifyNostrRevoked(): Promise<{
revoked: boolean
nostr_pubkey: string
latest_content?: string
error?: string
}> {
return this.call({
method: 'node-nostr-verify-revoked',
params: {},
timeout: 25000,
})
}
async echo(message: string): Promise<string> {
return this.call({
method: 'server.echo',
params: { message },
})
}
async getSystemTime(): Promise<{ now: string; uptime: number }> {
return this.call({
method: 'server.time',
params: {},
})
}
async getMetrics(): Promise<Record<string, unknown>> {
return this.call({
method: 'server.metrics',
params: {},
})
}
async updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> {
return this.call({
method: 'server.update',
params: { 'marketplace-url': marketplaceUrl },
})
}
async detectUsbDevices(): Promise<{
devices: Array<{
type: string
vendor_id: string
product_id: string
manufacturer: string
product: string
}>
}> {
return this.call({
method: 'system.detect-usb-devices',
params: {},
})
}
async restartServer(): Promise<void> {
return this.call({
method: 'server.restart',
params: {},
})
}
async shutdownServer(): Promise<void> {
return this.call({
method: 'server.shutdown',
params: {},
})
}
async installPackage(id: string, marketplaceUrl: string, version: string): Promise<string> {
return this.call({
method: 'package.install',
params: { id, 'marketplace-url': marketplaceUrl, version },
})
}
async uninstallPackage(id: string): Promise<void> {
return this.call({
method: 'package.uninstall',
params: { id },
timeout: 660000, // Bitcoin Knots needs up to 600s for UTXO flush
})
}
async startPackage(id: string): Promise<void> {
return this.call({
method: 'package.start',
params: { id },
timeout: 60000,
})
}
async stopPackage(id: string): Promise<void> {
return this.call({
method: 'package.stop',
params: { id },
timeout: 120000,
})
}
async restartPackage(id: string): Promise<void> {
return this.call({
method: 'package.restart',
params: { id },
timeout: 120000,
})
}
async updatePackage(id: string): Promise<{ status: string }> {
return this.call({
method: 'package.update',
params: { id },
timeout: 660000, // Bitcoin Knots needs up to 600s for graceful shutdown
})
}
async getMarketplace(url: string): Promise<Record<string, unknown>> {
return this.call({
method: 'marketplace.get',
params: { url },
})
}
// Federation
async federationInvite(): Promise<{ code: string; did: string; onion: string }> {
return this.call({
method: 'federation.invite',
params: {},
})
}
async federationJoin(code: string): Promise<{
joined: boolean
node: { did: string; onion: string; pubkey: string; trust_level: string }
}> {
return this.call({
method: 'federation.join',
params: { code },
})
}
async federationListNodes(): Promise<{
nodes: Array<{
did: string
pubkey: string
onion: string
trust_level: string
added_at: string
name?: string
last_seen?: string
last_state?: {
timestamp: string
apps: Array<{ id: string; status: string; version?: string }>
cpu_usage_percent?: number
mem_used_bytes?: number
mem_total_bytes?: number
disk_used_bytes?: number
disk_total_bytes?: number
uptime_secs?: number
tor_active?: boolean
}
}>
}> {
return this.call({
method: 'federation.list-nodes',
params: {},
})
}
async federationRemoveNode(did: string): Promise<{ removed: boolean; nodes_remaining: number }> {
return this.call({
method: 'federation.remove-node',
params: { did },
})
}
async federationSetTrust(
did: string,
trustLevel: 'trusted' | 'observer' | 'untrusted',
): Promise<{ updated: boolean; did: string; trust_level: string }> {
return this.call({
method: 'federation.set-trust',
params: { did, trust_level: trustLevel },
})
}
async federationSyncState(): Promise<{
synced: number
failed: number
results: Array<{ did: string; status: string; apps?: number; error?: string }>
}> {
return this.call({
method: 'federation.sync-state',
params: {},
timeout: 120000,
})
}
async federationDeployApp(params: {
did: string
appId: string
version?: string
marketplaceUrl?: string
}): Promise<{ deployed: boolean; app_id: string; peer_did: string; peer_onion: string }> {
return this.call({
method: 'federation.deploy-app',
params: {
did: params.did,
app_id: params.appId,
version: params.version ?? 'latest',
marketplace_url: params.marketplaceUrl ?? '',
},
timeout: 180000,
})
}
// VPN
async vpnStatus(): Promise<{
connected: boolean
provider?: string
interface?: string
ip_address?: string
hostname?: string
peers_connected: number
bytes_in: number
bytes_out: number
configured: boolean
configured_provider: string
wg_ip?: string | null
node_npub?: string | null
relay_url?: string | null
relay_onion?: string | null
relay_direct?: string | null
}> {
return this.call({
method: 'vpn.status',
params: {},
})
}
async vpnConfigure(params: {
provider: 'tailscale' | 'wireguard'
auth_key?: string
address?: string
dns?: string
peer?: {
public_key: string
endpoint: string
allowed_ips?: string
persistent_keepalive?: number
}
}): Promise<{ configured: boolean; provider: string; public_key?: string; address?: string }> {
return this.call({
method: 'vpn.configure',
params,
timeout: 60000,
})
}
async vpnDisconnect(): Promise<{ disconnected: boolean }> {
return this.call({
method: 'vpn.disconnect',
params: {},
})
}
// Marketplace
async marketplaceDiscover(): Promise<{
apps: Array<{
manifest: {
app_id: string
name: string
version: string
description: { short: string; long: string } | string
author: { name: string; did: string; nostr_pubkey: string }
container: { image: string; ports: Array<{ container: number; host: number }>; }
category: string
icon_url: string
repo_url: string
license: string
}
trust_score: number
trust_tier: string
relay_count: number
first_seen: string
nostr_pubkey: string
}>
relay_count: number
}> {
return this.call({
method: 'marketplace.discover',
params: {},
timeout: 30000,
})
}
// DNS
async dnsStatus(): Promise<{
provider: string
servers: string[]
doh_enabled: boolean
doh_url: string | null
resolv_conf_servers: string[]
}> {
return this.call({
method: 'network.dns-status',
params: {},
})
}
async configureDns(params: {
provider: 'system' | 'cloudflare' | 'google' | 'quad9' | 'mullvad' | 'custom'
servers?: string[]
}): Promise<{
ok: boolean
provider: string
servers: string[]
doh_enabled: boolean
doh_url: string | null
}> {
return this.call({
method: 'network.configure-dns',
params,
})
}
// Disk management
async diskStatus(): Promise<{
used_bytes: number
total_bytes: number
free_bytes: number
used_percent: number
level: 'ok' | 'warning' | 'critical'
}> {
return this.call({ method: 'system.disk-status' })
}
async diskCleanup(): Promise<{
freed_bytes: number
freed_human: string
actions: string[]
}> {
return this.call({
method: 'system.disk-cleanup',
timeout: 60000,
})
}
}
export const rpcClient = new RPCClient()