archy/neode-ui/src/api/rpc-client.ts

719 lines
18 KiB
TypeScript
Raw Normal View History

2026-01-24 22:59:20 +00:00
// RPC Client for connecting to Archipelago backend
export interface RPCOptions {
method: string
params?: Record<string, unknown>
2026-01-24 22:59:20 +00:00
timeout?: number
}
export interface RPCResponse<T> {
result?: T
error?: {
code: number
message: string
data?: unknown
2026-01-24 22:59:20 +00:00
}
}
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
}
2026-01-24 22:59:20 +00:00
class RPCClient {
feat: rootless podman, session hardening, boot stability, sidebar fix Rootless podman migration (TASK-11): - Remove sudo from all podman calls in PodmanClient + 8 backend files - Remove sudo from all podman/docker calls in deploy script - Restore full systemd security hardening: NoNewPrivileges, RestrictAddressFamilies, MemoryDenyWriteExecute, RestrictRealtime, RestrictNamespaces, RestrictSUIDSGID, SystemCallFilter, ProtectSystem=strict - Enable loginctl linger for rootless container persistence - Remove Ollama from auto-deploy (marketplace-only) Session & auth hardening: - Increase MAX_CONCURRENT_SESSIONS 20→50 (prevents eviction storms) - Debounced 401 redirect in rpc-client.ts (prevents redirect storms) Boot stability: - optimize-debian.sh: adds chrony, swap, removes policy-rc.d - deploy script: pre-restart chrony + swap setup - ISO build: chrony package, swap file creation - BootScreen: no longer clears localStorage (prevents splash replay) - RootRedirect: sole owner of localStorage clearing on server ready UI fixes: - Sidebar opacity default changed from 0→visible (fixes missing sidebar after page-persistence login without entrance animation) - Console.log/error wrapped in import.meta.env.DEV guards - Remove unused route import from RootRedirect Beta tracking: - CLAUDE.md: beta freeze protocol added - MASTER_PLAN.md: TASK-11, TASK-17, phase structure - BETA-PROGRESS.md: initial tracking doc - Tagged v1.2.0-alpha.1 as pre-rootless baseline Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:53:27 +00:00
private static _sessionExpiredRedirecting = false
2026-01-24 22:59:20 +00:00
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
2026-01-24 22:59:20 +00:00
for (let attempt = 0; attempt < maxRetries; attempt++) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
2026-01-24 22:59:20 +00:00
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,
})
2026-01-24 22:59:20 +00:00
clearTimeout(timeoutId)
2026-01-24 22:59:20 +00:00
if (!response.ok) {
feat: rootless podman, session hardening, boot stability, sidebar fix Rootless podman migration (TASK-11): - Remove sudo from all podman calls in PodmanClient + 8 backend files - Remove sudo from all podman/docker calls in deploy script - Restore full systemd security hardening: NoNewPrivileges, RestrictAddressFamilies, MemoryDenyWriteExecute, RestrictRealtime, RestrictNamespaces, RestrictSUIDSGID, SystemCallFilter, ProtectSystem=strict - Enable loginctl linger for rootless container persistence - Remove Ollama from auto-deploy (marketplace-only) Session & auth hardening: - Increase MAX_CONCURRENT_SESSIONS 20→50 (prevents eviction storms) - Debounced 401 redirect in rpc-client.ts (prevents redirect storms) Boot stability: - optimize-debian.sh: adds chrony, swap, removes policy-rc.d - deploy script: pre-restart chrony + swap setup - ISO build: chrony package, swap file creation - BootScreen: no longer clears localStorage (prevents splash replay) - RootRedirect: sole owner of localStorage clearing on server ready UI fixes: - Sidebar opacity default changed from 0→visible (fixes missing sidebar after page-persistence login without entrance animation) - Console.log/error wrapped in import.meta.env.DEV guards - Remove unused route import from RootRedirect Beta tracking: - CLAUDE.md: beta freeze protocol added - MASTER_PLAN.md: TASK-11, TASK-17, phase structure - BETA-PROGRESS.md: initial tracking doc - Tagged v1.2.0-alpha.1 as pre-rootless baseline Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:53:27 +00:00
// Session expired — debounced redirect to login
// Use a single shared timeout to prevent redirect storms when
// multiple parallel requests all get 401 at once
2026-03-14 17:12:41 +00:00
if (response.status === 401 && method !== 'auth.login') {
feat: rootless podman, session hardening, boot stability, sidebar fix Rootless podman migration (TASK-11): - Remove sudo from all podman calls in PodmanClient + 8 backend files - Remove sudo from all podman/docker calls in deploy script - Restore full systemd security hardening: NoNewPrivileges, RestrictAddressFamilies, MemoryDenyWriteExecute, RestrictRealtime, RestrictNamespaces, RestrictSUIDSGID, SystemCallFilter, ProtectSystem=strict - Enable loginctl linger for rootless container persistence - Remove Ollama from auto-deploy (marketplace-only) Session & auth hardening: - Increase MAX_CONCURRENT_SESSIONS 20→50 (prevents eviction storms) - Debounced 401 redirect in rpc-client.ts (prevents redirect storms) Boot stability: - optimize-debian.sh: adds chrony, swap, removes policy-rc.d - deploy script: pre-restart chrony + swap setup - ISO build: chrony package, swap file creation - BootScreen: no longer clears localStorage (prevents splash replay) - RootRedirect: sole owner of localStorage clearing on server ready UI fixes: - Sidebar opacity default changed from 0→visible (fixes missing sidebar after page-persistence login without entrance animation) - Console.log/error wrapped in import.meta.env.DEV guards - Remove unused route import from RootRedirect Beta tracking: - CLAUDE.md: beta freeze protocol added - MASTER_PLAN.md: TASK-11, TASK-17, phase structure - BETA-PROGRESS.md: initial tracking doc - Tagged v1.2.0-alpha.1 as pre-rootless baseline Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:53:27 +00:00
if (!RPCClient._sessionExpiredRedirecting) {
RPCClient._sessionExpiredRedirecting = true
setTimeout(() => {
window.location.href = '/login'
}, 300)
}
2026-03-14 17:12:41 +00:00
throw new Error('Session expired')
}
// CSRF 403: retry once after short delay (cookie may have been
// updated by a concurrent Set-Cookie response not yet visible to JS)
if (response.status === 403 && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 300))
continue
}
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
}
2026-01-24 22:59:20 +00:00
const data: RPCResponse<T> = await response.json()
2026-01-24 22:59:20 +00:00
if (data.error) {
throw new Error(data.error.message || 'RPC Error')
}
2026-01-24 22:59:20 +00:00
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
2026-01-24 22:59:20 +00:00
}
throw new Error('Unknown error occurred')
2026-01-24 22:59:20 +00:00
}
}
throw new Error('Request failed after retries')
2026-01-24 22:59:20 +00:00
}
// Convenience methods
async login(password: string): Promise<{ requires_totp?: boolean } | null> {
2026-01-24 22:59:20 +00:00
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,
},
})
}
2026-01-24 22:59:20 +00:00
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: {},
})
}
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: {},
})
}
2026-03-12 12:56:59 +00:00
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,
})
}
2026-01-24 22:59:20 +00:00
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>> {
2026-01-24 22:59:20 +00:00
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: {},
})
}
2026-01-24 22:59:20 +00:00
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 },
})
}
async startPackage(id: string): Promise<void> {
return this.call({
method: 'package.start',
params: { id },
})
}
async stopPackage(id: string): Promise<void> {
return this.call({
method: 'package.stop',
params: { id },
})
}
async restartPackage(id: string): Promise<void> {
return this.call({
method: 'package.restart',
params: { id },
})
}
async getMarketplace(url: string): Promise<Record<string, unknown>> {
2026-01-24 22:59:20 +00:00
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
}> {
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,
})
}
2026-01-24 22:59:20 +00:00
}
export const rpcClient = new RPCClient()