Add data export/import (Phase 8): portable JSON backup of config + credentials

GET /api/data/export serializes all integrations (with decrypted secrets, for
cross-instance portability), bookmark categories, bookmarks, and tunnels;
POST /api/data/import restores them additively in a transaction with old->new
id remapping. Wires the Settings "Data & Backup" section to download/upload the
backup file. Verified end-to-end including cross-instance portability under a
different ARCHNEST_SECRET_KEY, plus browser verification of the Settings UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
Claude 2026-06-19 16:13:29 +00:00
parent 92640d0777
commit 5b17bba80e
No known key found for this signature in database
5 changed files with 311 additions and 19 deletions

View file

@ -179,9 +179,26 @@ The **frontend transfer UI was also browser-verified** (Playwright/Chromium): lo
All test artifacts (test `sshd`, both test OS users + their home dirs, test backend instance, test DB, temp files) were cleaned up afterward. All test artifacts (test `sshd`, both test OS users + their home dirs, test backend instance, test DB, temp files) were cleaned up afterward.
### Phase 8 — Data Export / Import (DONE)
**Architecture decision**: a single-file JSON backup/restore of the user's configuration — all integrations (with their credentials), bookmark categories + bookmarks, and tunnels. Secrets are exported **decrypted** on purpose: that makes a backup portable to a different ArchNest instance whose `ARCHNEST_SECRET_KEY` differs (an encrypted export would be useless after a key change / on a fresh install). The export is only ever served to an authenticated user — the same person who can already read those secrets via the integrations they own — and the UI labels it as containing plaintext credentials. Import is **additive** (insert-as-new, never destructive), with old→new id remapping so tunnels and bookmarks keep pointing at their correct newly-created parents, all wrapped in a single SQLite transaction.
**What was built:**
- `backend/src/routes/data.ts``GET /api/data/export` (serializes integrations+decrypted secrets, bookmark categories, bookmarks, tunnels with a `version` field) and `POST /api/data/import` (zod-validated, transactional, additive, with `integrationIdMap`/`categoryIdMap` remapping; tunnels referencing an integration absent from the import are skipped rather than orphaned).
- `src/lib/api.ts``exportData()`/`importData()` + `DataExport` type.
- `src/pages/Settings.tsx` — wired the previously-placeholder "Data & Backup" section to the real endpoints: Export downloads `archnest-backup-<date>.json`; Import reads a chosen file and POSTs it, with success/error feedback. (Replaced the old mock "Export Bookmarks"/"Clear Cache"/"Reset" buttons.)
**Verified end-to-end** against a real backend (not mocked): seeded an instance with an SSH integration (password + passphrase secrets), a bookmark category + bookmark, and a tunnel; then:
- **Export** returned `version: 1` with the secrets correctly **decrypted** to plaintext and all four entity types present.
- **Additive import** into the same instance doubled every count, and the new tunnel's `integrationId` pointed at the *newly-created* integration (id remapping confirmed, not the stale original id).
- **Cross-instance portability**: imported the backup into a second backend started with a **completely different `ARCHNEST_SECRET_KEY`**; re-exporting from that instance showed the credentials decrypt correctly under the new key — proving they were re-encrypted on import, which is the whole point of the decrypted-export design.
- **Browser-verified** (Playwright/Chromium): the Settings → Data & Backup page exports a real downloaded JSON file (correct contents + success message) and imports an uploaded backup file (correct "Imported N integrations…" confirmation).
All test artifacts (two test backend instances, test DBs, downloaded backup files, temp files) were cleaned up afterward.
### Also worth checking during/after the phases above ### Also worth checking during/after the phases above
- Data export/import of SSH hosts/credentials/file-manager data — a nice-to-have, not yet scheduled. _All previously-listed follow-ups are now complete: host-metrics widgets (Phase 6), host-to-host transfer (Phase 7), and data export/import (Phase 8) are done, and the verification gaps noted in Phases 1, 5, and 6 have been closed (cert auth, Telnet, RDP, guacd compose wiring, host-metrics network/ports/firewall + browser UI, and the Phase 1b/7 UI click-throughs)._
## Tracking ## Tracking

205
backend/src/routes/data.ts Normal file
View file

@ -0,0 +1,205 @@
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { db, logEvent } from '../db/index.js'
import { encryptSecret, decryptSecret } from '../db/crypto.js'
/**
* Backup / restore of the user's configuration: integrations (with their
* credentials), bookmarks, and tunnels. Secrets are exported *decrypted* on
* purpose that makes the backup portable to another ArchNest instance with a
* different ARCHNEST_SECRET_KEY. The export therefore contains plaintext
* credentials and should be treated as sensitive (it's only ever served to an
* authenticated user, over the same channel they'd use to read those secrets
* anyway via the integrations they own).
*/
const EXPORT_VERSION = 1
interface IntegrationRow {
id: number
type: string
name: string
enabled: number
config_json: string
}
interface SecretRow {
key: string
value_encrypted: string
}
interface CategoryRow {
id: number
name: string
icon: string | null
sort_order: number
}
interface BookmarkRow {
id: number
category_id: number | null
title: string
url: string
icon: string | null
favorite: number
}
interface TunnelRow {
id: number
name: string
integration_id: number
mode: string
source_port: number
endpoint_host: string
endpoint_port: number
auto_start: number
max_retries: number
retry_interval_ms: number
}
const importSchema = z.object({
version: z.number().optional(),
integrations: z
.array(
z.object({
id: z.number(),
type: z.string(),
name: z.string(),
enabled: z.boolean().default(true),
config: z.record(z.string(), z.string()).default({}),
secrets: z.record(z.string(), z.string()).default({}),
}),
)
.default([]),
bookmarkCategories: z
.array(z.object({ id: z.number(), name: z.string(), icon: z.string().nullable().default(null), sortOrder: z.number().default(0) }))
.default([]),
bookmarks: z
.array(
z.object({
categoryId: z.number().nullable().default(null),
title: z.string(),
url: z.string(),
icon: z.string().nullable().default(null),
favorite: z.boolean().default(false),
}),
)
.default([]),
tunnels: z
.array(
z.object({
name: z.string(),
integrationId: z.number(),
mode: z.string(),
sourcePort: z.number(),
endpointHost: z.string().default(''),
endpointPort: z.number().default(0),
autoStart: z.boolean().default(false),
maxRetries: z.number().default(3),
retryIntervalMs: z.number().default(5000),
}),
)
.default([]),
})
export async function dataRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate)
app.get('/api/data/export', async () => {
const integrations = (db.prepare('SELECT id, type, name, enabled, config_json FROM integrations').all() as IntegrationRow[]).map((row) => {
const secretRows = db.prepare('SELECT key, value_encrypted FROM secrets WHERE integration_id = ?').all(row.id) as SecretRow[]
const secrets: Record<string, string> = {}
for (const s of secretRows) {
try {
secrets[s.key] = decryptSecret(s.value_encrypted)
} catch {
// a secret we can't decrypt (wrong key) is skipped rather than failing the whole export
}
}
return { id: row.id, type: row.type, name: row.name, enabled: !!row.enabled, config: JSON.parse(row.config_json), secrets }
})
const bookmarkCategories = (db.prepare('SELECT id, name, icon, sort_order FROM bookmark_categories').all() as CategoryRow[]).map((c) => ({
id: c.id,
name: c.name,
icon: c.icon,
sortOrder: c.sort_order,
}))
const bookmarks = (db.prepare('SELECT category_id, title, url, icon, favorite FROM bookmarks').all() as BookmarkRow[]).map((b) => ({
categoryId: b.category_id,
title: b.title,
url: b.url,
icon: b.icon,
favorite: !!b.favorite,
}))
const tunnels = (db.prepare('SELECT name, integration_id, mode, source_port, endpoint_host, endpoint_port, auto_start, max_retries, retry_interval_ms FROM tunnels').all() as TunnelRow[]).map(
(t) => ({
name: t.name,
integrationId: t.integration_id,
mode: t.mode,
sourcePort: t.source_port,
endpointHost: t.endpoint_host,
endpointPort: t.endpoint_port,
autoStart: !!t.auto_start,
maxRetries: t.max_retries,
retryIntervalMs: t.retry_interval_ms,
}),
)
return { version: EXPORT_VERSION, exportedAt: new Date().toISOString(), integrations, bookmarkCategories, bookmarks, tunnels }
})
app.post('/api/data/import', async (req, reply) => {
const parsed = importSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid import file' })
}
const { integrations, bookmarkCategories, bookmarks, tunnels } = parsed.data
const counts = { integrations: 0, bookmarkCategories: 0, bookmarks: 0, tunnels: 0 }
// Additive import: insert everything as new rows, remapping old ids -> new ids so
// tunnels and bookmarks keep pointing at the right (newly-created) parents.
const runImport = db.transaction(() => {
const integrationIdMap = new Map<number, number>()
const insertIntegration = db.prepare('INSERT INTO integrations (type, name, enabled, config_json) VALUES (?, ?, ?, ?)')
const insertSecret = db.prepare('INSERT INTO secrets (integration_id, key, value_encrypted) VALUES (?, ?, ?)')
for (const i of integrations) {
const res = insertIntegration.run(i.type, i.name, i.enabled ? 1 : 0, JSON.stringify(i.config))
const newId = Number(res.lastInsertRowid)
integrationIdMap.set(i.id, newId)
for (const [key, value] of Object.entries(i.secrets)) {
insertSecret.run(newId, key, encryptSecret(value))
}
counts.integrations += 1
}
const categoryIdMap = new Map<number, number>()
const insertCategory = db.prepare('INSERT INTO bookmark_categories (name, icon, sort_order) VALUES (?, ?, ?)')
for (const c of bookmarkCategories) {
const res = insertCategory.run(c.name, c.icon, c.sortOrder)
categoryIdMap.set(c.id, Number(res.lastInsertRowid))
counts.bookmarkCategories += 1
}
const insertBookmark = db.prepare('INSERT INTO bookmarks (category_id, title, url, icon, favorite) VALUES (?, ?, ?, ?, ?)')
for (const b of bookmarks) {
const mappedCategory = b.categoryId !== null ? categoryIdMap.get(b.categoryId) ?? null : null
insertBookmark.run(mappedCategory, b.title, b.url, b.icon, b.favorite ? 1 : 0)
counts.bookmarks += 1
}
const insertTunnel = db.prepare(
'INSERT INTO tunnels (name, integration_id, mode, source_port, endpoint_host, endpoint_port, auto_start, max_retries, retry_interval_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
)
for (const t of tunnels) {
const mappedIntegration = integrationIdMap.get(t.integrationId)
if (mappedIntegration === undefined) continue // tunnel referenced an integration not present in this import — skip
insertTunnel.run(t.name, mappedIntegration, t.mode, t.sourcePort, t.endpointHost, t.endpointPort, t.autoStart ? 1 : 0, t.maxRetries, t.retryIntervalMs)
counts.tunnels += 1
}
})
runImport()
logEvent('data_imported', `Imported ${counts.integrations} integrations, ${counts.bookmarks} bookmarks, ${counts.tunnels} tunnels`)
return { ok: true, imported: counts }
})
}

View file

@ -15,6 +15,7 @@ import { dockerRoutes, dockerExecRoutes } from './routes/docker.js'
import { guacamoleRoutes } from './routes/guacamole.js' import { guacamoleRoutes } from './routes/guacamole.js'
import { metricsRoutes } from './routes/metrics.js' import { metricsRoutes } from './routes/metrics.js'
import { transferRoutes } from './routes/transfer.js' import { transferRoutes } from './routes/transfer.js'
import { dataRoutes } from './routes/data.js'
import { startAutoStartTunnels } from './tunnels/manager.js' import { startAutoStartTunnels } from './tunnels/manager.js'
const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET
@ -49,6 +50,7 @@ await app.register(dockerExecRoutes)
await app.register(guacamoleRoutes) await app.register(guacamoleRoutes)
await app.register(metricsRoutes) await app.register(metricsRoutes)
await app.register(transferRoutes) await app.register(transferRoutes)
await app.register(dataRoutes)
app.get('/api/health', async () => ({ ok: true })) app.get('/api/health', async () => ({ ok: true }))

View file

@ -152,6 +152,22 @@ export const api = {
listTransfers: () => apiFetch<{ transfers: TransferProgress[] }>('/transfers'), listTransfers: () => apiFetch<{ transfers: TransferProgress[] }>('/transfers'),
getTransfer: (id: string) => apiFetch<TransferProgress>(`/transfers/${id}`), getTransfer: (id: string) => apiFetch<TransferProgress>(`/transfers/${id}`),
cancelTransfer: (id: string) => apiFetch<{ ok: boolean }>(`/transfers/${id}/cancel`, { method: 'POST' }), cancelTransfer: (id: string) => apiFetch<{ ok: boolean }>(`/transfers/${id}/cancel`, { method: 'POST' }),
exportData: () => apiFetch<DataExport>('/data/export'),
importData: (data: DataExport) =>
apiFetch<{ ok: boolean; imported: { integrations: number; bookmarkCategories: number; bookmarks: number; tunnels: number } }>('/data/import', {
method: 'POST',
body: JSON.stringify(data),
}),
}
export interface DataExport {
version: number
exportedAt?: string
integrations: Array<{ id: number; type: string; name: string; enabled: boolean; config: Record<string, string>; secrets: Record<string, string> }>
bookmarkCategories: Array<{ id: number; name: string; icon: string | null; sortOrder: number }>
bookmarks: Array<{ categoryId: number | null; title: string; url: string; icon: string | null; favorite: boolean }>
tunnels: Array<{ name: string; integrationId: number; mode: string; sourcePort: number; endpointHost: string; endpointPort: number; autoStart: boolean; maxRetries: number; retryIntervalMs: number }>
} }
export interface AuthUser { export interface AuthUser {

View file

@ -894,31 +894,83 @@ function NotificationsSection() {
} }
function DataBackupSection() { function DataBackupSection() {
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const importRef = useRef<HTMLInputElement | null>(null)
async function handleExport() {
setBusy(true)
setError(null)
setMessage(null)
try {
const data = await api.exportData()
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `archnest-backup-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
setMessage(`Exported ${data.integrations.length} integrations, ${data.bookmarks.length} bookmarks, ${data.tunnels.length} tunnels.`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Export failed')
} finally {
setBusy(false)
}
}
async function handleImportFile(file: File) {
setBusy(true)
setError(null)
setMessage(null)
try {
const text = await file.text()
const parsed = JSON.parse(text)
const result = await api.importData(parsed)
const c = result.imported
setMessage(`Imported ${c.integrations} integrations, ${c.bookmarkCategories} categories, ${c.bookmarks} bookmarks, ${c.tunnels} tunnels.`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed — is this a valid ArchNest backup file?')
} finally {
setBusy(false)
}
}
return ( return (
<div style={cardBase}> <div style={cardBase}>
<h3 style={sectionTitle}>Data & Backup</h3> <h3 style={sectionTitle}>Data & Backup</h3>
<div className="flex flex-col gap-3" style={{ maxWidth: '320px' }}> <p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '16px', maxWidth: '460px' }}>
Export a portable backup of all integrations (including their credentials), bookmarks, and tunnels as a single JSON file, or
import one into this instance. Imports are additive existing data is kept and the backup's items are added alongside it.
The backup contains plaintext credentials, so store it securely.
</p>
<div className="flex flex-col gap-3" style={{ maxWidth: '460px' }}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Export Bookmarks (JSON)</span> <span style={{ fontSize: '13px', color: '#E8E6E0' }}>Export all data (JSON)</span>
<GoldButton><Download size={13} /> Export</GoldButton> <GoldButton onClick={handleExport} disabled={busy}>
<Download size={13} /> Export
</GoldButton>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Import Bookmarks (JSON)</span> <span style={{ fontSize: '13px', color: '#E8E6E0' }}>Import from backup (JSON)</span>
<GoldButton><Upload size={13} /> Import</GoldButton> <GoldButton onClick={() => importRef.current?.click()} disabled={busy}>
</div> <Upload size={13} /> Import
<div className="flex items-center justify-between"> </GoldButton>
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Export Settings</span> <input
<GoldButton><Download size={13} /> Export</GoldButton> ref={importRef}
</div> type="file"
<div className="border-t" style={{ borderColor: 'rgba(231,76,60,0.15)', margin: '8px 0' }} /> accept="application/json,.json"
<div className="flex items-center justify-between"> className="hidden"
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Clear Cache</span> onChange={(e) => {
<GoldButton danger><Trash2 size={13} /> Clear</GoldButton> const file = e.target.files?.[0]
</div> if (file) handleImportFile(file)
<div className="flex items-center justify-between"> e.target.value = ''
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Reset to Defaults</span> }}
<GoldButton danger><RotateCcw size={13} /> Reset</GoldButton> />
</div> </div>
{message && <p style={{ fontSize: '12px', color: '#2ECC71' }}>{message}</p>}
{error && <p style={{ fontSize: '12px', color: '#E74C3C' }}>{error}</p>}
</div> </div>
</div> </div>
) )