diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md index caafac3..19937f8 100644 --- a/TERMIX_MIGRATION.md +++ b/TERMIX_MIGRATION.md @@ -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. +### 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-.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 -- 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 diff --git a/backend/src/routes/data.ts b/backend/src/routes/data.ts new file mode 100644 index 0000000..43aa27e --- /dev/null +++ b/backend/src/routes/data.ts @@ -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 = {} + 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() + 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() + 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 } + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 6f5d9f0..a04e19e 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -15,6 +15,7 @@ import { dockerRoutes, dockerExecRoutes } from './routes/docker.js' import { guacamoleRoutes } from './routes/guacamole.js' import { metricsRoutes } from './routes/metrics.js' import { transferRoutes } from './routes/transfer.js' +import { dataRoutes } from './routes/data.js' import { startAutoStartTunnels } from './tunnels/manager.js' const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET @@ -49,6 +50,7 @@ await app.register(dockerExecRoutes) await app.register(guacamoleRoutes) await app.register(metricsRoutes) await app.register(transferRoutes) +await app.register(dataRoutes) app.get('/api/health', async () => ({ ok: true })) diff --git a/src/lib/api.ts b/src/lib/api.ts index 8c50d52..0fff277 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -152,6 +152,22 @@ export const api = { listTransfers: () => apiFetch<{ transfers: TransferProgress[] }>('/transfers'), getTransfer: (id: string) => apiFetch(`/transfers/${id}`), cancelTransfer: (id: string) => apiFetch<{ ok: boolean }>(`/transfers/${id}/cancel`, { method: 'POST' }), + + exportData: () => apiFetch('/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; secrets: Record }> + 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 { diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index aecbf35..7f5373b 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -894,31 +894,83 @@ function NotificationsSection() { } function DataBackupSection() { + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState(null) + const [error, setError] = useState(null) + const importRef = useRef(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 (

Data & Backup

-
+

+ 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. +

+
- Export Bookmarks (JSON) - Export + Export all data (JSON) + + Export +
- Import Bookmarks (JSON) - Import -
-
- Export Settings - Export -
-
-
- Clear Cache - Clear -
-
- Reset to Defaults - Reset + Import from backup (JSON) + importRef.current?.click()} disabled={busy}> + Import + + { + const file = e.target.files?.[0] + if (file) handleImportFile(file) + e.target.value = '' + }} + />
+ {message &&

{message}

} + {error &&

{error}

}
)