diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 58de776f83..0b3398133a 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -1,88 +1,122 @@ -# 🚚 Migrating Pulse +# Migrating Pulse -This guide covers migrating Pulse to a new host using the built-in encrypted export/import workflow. +**Create Backup** in **Settings → System → Recovery** creates an encrypted +configuration export, not a full backup of the installation. Check its scope +before moving Pulse or retiring the old server. Do not use it alone when you +need to preserve history, agent enrolment state or settings outside that scope. -## 🚀 Quick Migration Guide +## Choose the backup you need -### ❌ DON'T: Copy Files -Never copy `/etc/pulse` (or `/data` in Docker/Kubernetes) manually. Encryption keys and credentials can break. +### Configuration transfer -### ✅ DO: Use Export/Import +Use export/import to move the included settings between installations. Pulse +decrypts the export with its passphrase and saves the imported credentials with +the destination's encryption key. Do not copy individual encrypted files into +a fresh installation with a different key. -#### 1. Export (Old Server) -1. Go to **Settings → System → Recovery**. -2. Click **Create Backup**. -3. Enter a strong passphrase and download the encrypted backup. +The current export format includes: -#### 2. Import (New Server) -1. Install a fresh Pulse instance. -2. Go to **Settings → System → Recovery**. -3. Click **Restore Configuration** and upload your file. -4. Enter the passphrase. - -## 📦 What Gets Migrated - -| Included ✅ | Not Included ❌ | +| Included | Not included | | :--- | :--- | -| Nodes & credentials | Historical metrics history (`metrics.db`) | -| Alerts & overrides | Browser sessions and local cookies | -| Notifications (email, webhooks, Apprise) | Local login username/password (`.env`) | -| System settings (`system.json`) | Update history/backup folders | -| API token records | — | -| OIDC config | — | -| SSO / SAML config | — | -| TrueNAS connections (`truenas.enc`) | — | -| Guest metadata/notes | — | -| — | Relay config (`relay.enc`) — re-enable in Settings | -| — | Host metadata (notes/tags/AI command overrides) | -| — | Docker metadata cache | -| — | Agent profiles and assignments | -| — | AI settings and findings (`ai.enc`, `ai_findings.json`, `ai_patrol_runs.json`, `ai_usage_history.json`) | -| — | RBAC roles (`rbac_roles.json`) — re-create after import | -| — | Relay/Pro/legacy Pro+/Cloud license (`license.enc`) | -| — | Server sessions (`sessions.json`) | -| — | Update history (`update-history.jsonl`) | +| Proxmox VE, PBS and PMG connections and credentials | TrueNAS, vSphere and Machine Availability connections | +| Alert configuration, overrides and intent policies | Active alerts, incidents, notification queue and delivery history | +| Email, webhook, Apprise and external watchdog configuration | Metrics history, audit history and report schedules | +| System settings | Local login credentials, environment overrides and sessions | +| API-token records | Server-side host/Docker/Kubernetes agent inventory and enrolment state | +| SSO providers configured in Pulse | Legacy environment-configured OIDC settings, external CA/certificate files and custom RBAC roles | +| Guest metadata and notes | Host/Docker metadata, agent profiles and assignments | +| — | AI settings, findings, usage and chat history | +| — | Licence files, existing Relay configuration, binaries, service definitions and update backups | -## 🔄 Common Scenarios +Contents depend on the version that created the export. Older formats can omit +settings included above. Imported API-token records retain their scopes and +expiry; they do not reveal the original tokens or transfer every agent's state. +SSO configuration **is included**, even though local login credentials are not. -### Moving to New Hardware -Export from old → Install new → Import. +### Full-state recovery -### Docker ↔ Systemd ↔ Kubernetes -The export file works across all installation methods. You can migrate from Docker to Kubernetes or vice versa seamlessly. +If you need the same installation's history and excluded state, retain a +consistent filesystem/volume backup as well. Stop Pulse before taking that +backup; monitoring and alert delivery pause while it is stopped. Preserve every +effective data path, the matching `.encryption.key`, audit data and signing key, +environment files, external certificates, binaries and service/container +configuration together. Custom paths and organisation directories matter. +Copying a live database file alone can omit its sidecar files. -### Disaster Recovery -1. Install Pulse using Docker or your preferred method (see [INSTALL.md](INSTALL.md)). -2. Import your latest backup. -3. Restored in < 5 minutes. +Keep that backup private and test recovery on an isolated destination before +retiring the source. Do not mix encrypted files and keys from different +installations or restore over a running Pulse. See [configuration +paths](CONFIGURATION.md) and [audit storage and recovery](AUDIT_LOGGING.md#storage). +The [updater's installation snapshot](AUTO_UPDATE.md#what-an-update-snapshot-contains) +is a third, limited backup; it is not a substitute for either procedure above. -## 📋 Post-Migration Checklist +## Transfer the included configuration -Because local login credentials are stored in `.env` (not part of exports), you must: +1. On the old server, sign in and open **Settings → System → Recovery → + Create Backup**. Use a strong, unique passphrase of at least 12 characters + and store it separately from the downloaded file. With password login, + choose **Use a custom passphrase** if you do not want to use that password. + Keep the old instance and your separate full-state backup until validation + is complete. +2. Install Pulse at the destination using the [installation guide](INSTALL.md). + Configure destination-local administrator access before importing. Keep the + destination isolated from agents and notification destinations during the + trial so two servers do not send duplicate alerts or remote actions. +3. If the destination already has useful settings, export them first. Open + **Settings → System → Recovery → Restore Configuration**, select the file + and enter its passphrase. Import replaces the included configuration and + API-token records; it does not merge them with the destination's records. +4. Check the result before changing DNS or agent addresses. A success message + establishes neither full recovery nor fresh monitoring. If import reports a + reload/apply failure, settings may already have been written: preserve both + installations and inspect the error before repeating it. -1. **Re-create Admin User**: If not using `.env` overrides, create your admin account on the new instance. -2. **Confirm API access**: - * If you created API tokens in the UI, those token records are included in the export and should continue working. -3. **Update Agents**: - * **Unified Agent**: Update the `--token` flag in your service definition. - * **Containerized agent**: Update `PULSE_TOKEN` in the agent container environment. - * **New server address?** Agents dial the address they were installed - with (the `--url` flag, or `PULSE_URL` for containerized agents), so - agents disappear from the new instance until that value is updated in - each agent's service definition. Fronting Pulse with a DNS name - instead of an IP address makes future moves invisible to agents. - * *Tip: Use **Settings → Infrastructure → Install on a host** to generate updated install commands.* -4. **Relay/Pro/legacy Pro+/Cloud**: Re-activate your license key after migration (license files are not included in exports). +Keep the archive, passphrase, configuration files and credentials out of issue +threads. Use the authenticated settings form rather than a command containing +a password, API token or session cookie. -## 🔒 Security +## Cut over and verify -* **Encryption**: Exports are encrypted with passphrase-based encryption (PBKDF2 + AES-GCM). -* **Storage**: Safe to store in cloud backups or password managers. -* **Passphrase**: Use a strong, unique passphrase (min 12 chars). +- **Login and permissions:** verify destination-local administrator access and + any imported SSO provider, group mappings and callback address. Re-create + custom roles and supply external CA/certificate files if needed. Environment + overrides can take precedence over imported settings. +- **Connections and history:** check every expected platform, guest and backup + against a fresh observation, not a cached row. Re-create excluded TrueNAS, + vSphere and Machine Availability connections. Missing historical data after + configuration-only transfer is not proof that collection has failed. +- **Agents:** preserve each agent's existing identity and private credential. + With a changed server address, use the [agent retargeting + guide](UNIFIED_AGENT.md#moving-pulse-to-a-new-address) on the monitored host. + Do not create another token just for a server move or paste it into `--token`. + Confirm fresh reports and any enrolment/authorisation result; imported token + records alone do not prove that every agent is admitted. Re-create excluded + profiles and assignments before enabling remote actions. +- **Alerts and notifications:** avoid running both instances against the same + destinations. After cutover, check thresholds, schedules and one controlled + notification test with its actual delivery result. Importing destination + settings does not migrate pending notifications or incident history. +- **Licence and remaining settings:** re-activate Pro if required and verify + any excluded settings you rely on. Existing paired phones need their retained + configuration; configuration export alone is not a Mobile/Relay migration. + Relay is no longer sold; do not enable it as a new migration route. -## 🔧 Troubleshooting +Retire the old installation only after these checks succeed. Keep the private, +consistent pre-move backup for recovery. If validation fails, stop the trial +destination before returning traffic to the intact source; check agent URLs +and avoid two active writers. There is no guaranteed five-minute recovery time. -* **"Invalid passphrase"**: Ensure exact match (case-sensitive). -* **Missing Nodes**: Verify export date. -* **Connection Errors**: Update node IPs in Settings if they changed. -* **Logging**: Adjust `LOG_LEVEL`/`LOG_FORMAT` via environment variables if needed. +## Troubleshooting + +- **Wrong passphrase or rejected file:** use the original export file and its + exact passphrase. Do not delete destination data or regenerate encryption + keys to make an import succeed. +- **A setting is missing:** compare it with the export scope above and the + exporting version. Use the old installation or full-state backup rather + than assuming import contained it. +- **Agents are absent:** check their primary URL, TLS trust and fresh admission + evidence using the linked guide. Do not uninstall, re-enrol or delete saved + identities just because the dashboard is empty. +- **Need help:** retain the source/destination versions, deployment types, + operation time and exact redacted error. Follow [safe + reporting](TROUBLESHOOTING.md#getting-help); do not attach the recovery backup. diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md index adf8371792..405e50271d 100644 --- a/docs/UNIFIED_AGENT.md +++ b/docs/UNIFIED_AGENT.md @@ -52,10 +52,12 @@ first unless you are intentionally removing that host from Pulse. ### Moving Pulse to a new address -Configuration export/import restores the server-side agent records and API -tokens. It cannot rewrite the primary Pulse URL on remote machines because -agents initiate the connection. Prefer a stable DNS name for the primary URL -so replacing the Pulse host does not require an agent migration. +Configuration export/import restores API-token records, not the server-side +agent inventory, enrolment state, profiles or assignments. See the +[configuration-transfer scope](MIGRATION.md#configuration-transfer) before +retiring the old server. Import cannot rewrite the primary Pulse URL on remote +machines because agents initiate the connection. Prefer a stable DNS name for +the primary URL, but still verify fresh reports and agent admission after a move. After importing the configuration on a Pulse server with a different address, retarget each existing standard Linux agent from that agent machine. Download diff --git a/frontend-modern/browser-tests/configuration-migration.cjs b/frontend-modern/browser-tests/configuration-migration.cjs new file mode 100644 index 0000000000..448ff8c78e --- /dev/null +++ b/frontend-modern/browser-tests/configuration-migration.cjs @@ -0,0 +1,140 @@ +// Run from the assigned root with pulse-worker-browser. This checks the real +// dialog/flow and shipped guides, not a native migration or release delivery. +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const { createHash } = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const workspace = process.cwd(); + const root = path.join(workspace, 'frontend-modern'); + const output = path.join(workspace, 'tmp', 'configuration-migration'); + fs.mkdirSync(output, { recursive: true }); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, configFile: path.join(root, 'vite.config.ts'), + plugins: [{ name: 'migration-doc-harness', configureServer(server) { + server.middlewares.use((req, _res, next) => { + if (/^\/docs\/[^?]+$/.test(req.url || '') && !req.url.endsWith('.md')) + req.url = '/browser-tests/configuration-migration.html'; + next(); + }); + } }], + server: { host: '127.0.0.1', port: 5264, strictPort: true }, + }); + const observations = []; + let browser; + try { + await server.listen(); + for (const [engine, width, height] of [['chromium', 1440, 1000], ['webkit', 390, 844]]) { + browser = await (engine === 'chromium' ? chromium : webkit).launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const context = await browser.newContext({ viewport: { width, height }, acceptDownloads: true, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) }); + const errors = [], operations = []; + await context.route('**/api/**', async (route) => { + const req = route.request(); + if (req.url().endsWith('/api/config/export')) { + operations.push({ path: '/api/config/export', method: req.method(), body: req.postDataJSON() }); + await route.fulfill({ status: 200, contentType: 'application/json', + body: JSON.stringify({ status: 'success', data: 'synthetic-archive-not-a-real-backup' }) }); + } else if (req.url().endsWith('/api/config/import')) { + operations.push({ path: '/api/config/import', method: req.method(), body: req.postDataJSON() }); + // Deliberately reject this synthetic archive: warning, error and + // cancel paths must remain available without pretending to migrate. + await route.fulfill({ status: 400, contentType: 'text/plain', body: 'Synthetic rejected archive' }); + } else await route.fulfill({ status: 200, contentType: 'application/json', + body: JSON.stringify({ hasAuthentication: false }) }); + }); + context.on('page', (p) => p.on('pageerror', (err) => errors.push(err.message))); + const page = await context.newPage(); + await page.goto('http://127.0.0.1:5264/browser-tests/configuration-migration.html'); + if (engine === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark')); + await page.getByRole('button', { name: 'Create Backup', exact: true }).click(); + const exporting = page.getByRole('dialog', { name: 'Export configuration', exact: true }); + await exporting.waitFor(); + await exporting.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished))); + let text = await exporting.innerText(); + for (const phrase of ['Configuration only:', 'SSO settings', 'API-token records', + 'not history, TrueNAS/vSphere connections', 'Local login credentials and sessions are not included']) + assert.ok(text.includes(phrase), phrase); + assert.ok(await exporting.getByRole('button', { name: 'Export', exact: true }).isDisabled()); + const guide = exporting.getByRole('link', { name: 'migration guide', exact: true }); + await guide.scrollIntoViewIfNeeded(); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-export.png`) }); + await guide.focus(); + const popupPromise = page.waitForEvent('popup'); + await page.keyboard.press('Enter'); + const docs = await popupPromise; + await docs.locator('article').waitFor(); + const article = await docs.locator('article').innerText(); + for (const phrase of ['not a full backup', 'TrueNAS, vSphere and Machine Availability', + 'SSO configuration is included', 'Stop Pulse', 'does not merge', 'reload/apply failure']) + assert.ok(article.includes(phrase), phrase); + assert.ok(docs.url().endsWith('/docs/MIGRATION')); + const scope = docs.locator('#configuration-transfer'); + await scope.scrollIntoViewIfNeeded(); + assert.ok(await docs.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await docs.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-scope.png`) }); + const retarget = docs.getByRole('link', { name: 'agent retargeting guide', exact: true }); + await retarget.focus(); + await docs.keyboard.press('Enter'); + await docs.waitForFunction(() => document.activeElement?.id === 'moving-pulse-to-a-new-address'); + assert.ok((await docs.locator('article').innerText()).includes('restores API-token records, not the server-side')); + assert.ok(docs.url().endsWith('/docs/UNIFIED_AGENT#moving-pulse-to-a-new-address')); + await docs.close(); + await exporting.getByLabel('Encryption Passphrase', { exact: true }).fill('synthetic-migration-passphrase'); + const downloadPromise = page.waitForEvent('download'); + await exporting.getByRole('button', { name: 'Export', exact: true }).click(); + const download = await downloadPromise; + assert.deepEqual(JSON.parse(fs.readFileSync(await download.path(), 'utf8')), + { status: 'success', data: 'synthetic-archive-not-a-real-backup' }); + await page.getByRole('button', { name: 'Restore Configuration', exact: true }).click(); + const importing = page.getByRole('dialog', { name: 'Import configuration', exact: true }); + await importing.waitFor(); + await importing.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished))); + text = await importing.innerText(); + for (const phrase of ['Back up the destination first', 'not the whole installation', + 'inventory, enrolment state, profiles and assignments are not', 'verify fresh admission']) + assert.ok(text.includes(phrase), phrase); + assert.ok(!text.includes('restores server-side agent records')); + await importing.getByRole('link', { name: 'migration guide' }).scrollIntoViewIfNeeded(); + await page.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-import.png`) }); + await importing.getByLabel('Backup Password', { exact: true }).fill('synthetic-migration-passphrase'); + await importing.getByLabel('Configuration File', { exact: true }).setInputFiles({ name: 'synthetic-backup.json', + mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ data: 'synthetic-archive-not-a-real-backup' })) }); + const rejectedResponse = page.waitForResponse((r) => r.url().endsWith('/api/config/import')); + await importing.getByRole('button', { name: 'Import', exact: true }).click(); + await rejectedResponse; + assert.equal(operations.length, 2); + assert.deepEqual(operations[1], { path: '/api/config/import', method: 'POST', + body: { passphrase: 'synthetic-migration-passphrase', data: 'synthetic-archive-not-a-real-backup' } }); + assert.ok(await importing.isVisible(), 'failed import retains the dialog'); + await importing.getByRole('button', { name: 'Cancel', exact: true }).click(); + await importing.waitFor({ state: 'hidden' }); + assert.equal(operations.length, 2, 'copy/navigation/cancel never send another operation'); + assert.deepEqual(errors, []); + observations.push({ engine, browserVersion: browser.version(), width, height, + warningScope: true, keyboardGuideAndRetargetLinks: true, syntheticExportAndRejectedImport: true, + noPageOverflow: true, errors }); + await browser.close(); + browser = undefined; + } + const files = ['frontend-modern/src/components/Settings/BackupTransferDialogs.tsx', + 'frontend-modern/public/docs/MIGRATION.md', 'frontend-modern/public/docs/UNIFIED_AGENT.md']; + const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version, + baseSha: 'b62042798c7c9b326408dd5532d7fa330d6bb089', + scope: 'Real dialog/flow/production Docs rendering with synthetic API; no native migration or shipment', + contentSha256: Object.fromEntries(files.map((file) => [file, + createHash('sha256').update(fs.readFileSync(path.join(workspace, file))).digest('hex')])), observations }; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((err) => { console.error(err); process.exitCode = 1; }); diff --git a/frontend-modern/browser-tests/configuration-migration.html b/frontend-modern/browser-tests/configuration-migration.html new file mode 100644 index 0000000000..b27dd2abbd --- /dev/null +++ b/frontend-modern/browser-tests/configuration-migration.html @@ -0,0 +1,12 @@ + + + + + + Configuration transfer safety + + +
+ + + diff --git a/frontend-modern/browser-tests/configuration-migration.tsx b/frontend-modern/browser-tests/configuration-migration.tsx new file mode 100644 index 0000000000..275f2a2902 --- /dev/null +++ b/frontend-modern/browser-tests/configuration-migration.tsx @@ -0,0 +1,32 @@ +// Actual transfer dialogs and flow, plus the production Docs renderer/router. +// The browser driver supplies only synthetic API responses; no server or +// operational credentials are involved in this copy/interaction proof. +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { BackupTransferDialogs } from '../src/components/Settings/BackupTransferDialogs'; +import { useBackupTransferFlow } from '../src/components/Settings/useBackupTransferFlow'; +import Docs from '../src/pages/Docs'; +import '../src/index.css'; + +function Transfer() { + const securityStatus = () => null; + const flow = useBackupTransferFlow({ securityStatus }); + return ( +
+

Configuration transfer

+ + + +
+ ); +} + +render( + () => ( + + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index bd2176689c..175cc2ec76 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,52 +1,72 @@ { "version": 1, "result": "passed", - "base_sha": "227657b1b0d6924e98da9382f812bb0cb1874f57", - "verified_at": "2026-10-03T04:13:25.499572Z", + "base_sha": "b62042798c7c9b326408dd5532d7fa330d6bb089", + "verified_at": "2026-10-03T14:52:51.979841Z", "changed_paths": [ - "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts" + "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx" ], "content_sha256": { - "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx": "44f939bd8accdb5662f2f99a2c4d6324d965ca520414d0a0db8daba790af04d9", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "41dadd17f930115374facc8f7a9dc46f3e4eab69e41e4375c90f9a5f57022fe8", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "ee9482a2076b86e62c85bffe0ce816a6ccd542ec3b07a85e829a1cc07dd8b1de", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts": "a39e66fc3ca772f0394ad0b5a74feee67a80daac3fdc604d014cadb52cda02b6" + "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "f07fb69d5bd6b185d0e156d7affcf7d5f5fa78418b3f82dd776d49afbceb9341" }, "routes": [ - "Existing PBS/resource detail Overview change history", - "Existing resource detail Manage action history" + "/settings/system/recovery (real transfer dialog/flow harness)", + "/docs/MIGRATION", + "/docs/UNIFIED_AGENT#moving-pulse-to-a-new-address" + ], + "states": [ + "Configuration export: includes SSO/API-token records, excludes history/TrueNAS/vSphere/agent enrolment; local login/sessions excluded", + "Import: replaces included settings, preserves agent identity and requires fresh admission", + "Desktop light Chromium and touch phone dark WebKit warnings readable; all six final screenshots inspected" + ], + "interactions": [ + "Open Create Backup and Restore Configuration dialogs", + "Keyboard-open migration guide without losing dialog state, then follow agent retargeting guide", + "Synthetic export download through actual flow; deliberately rejected synthetic import retains dialog and Cancel makes no extra operation" ], "viewports": [ { - "width": 1365, - "height": 900 + "width": 1440, + "height": 1000 }, { "width": 390, "height": 844 } ], - "states": [ - "Final facet HTTP 403 withdraws embedded snapshot events/counts instead of reintroducing denied evidence", - "Denied filtered facets do not fall back to the unfiltered bundle", - "Retry retains denial while pending; fresh successful response restores events and clears guidance", - "First action-history HTTP 503 stays visible in Manage with retry and no false empty state", - "Initial facet HTTP 503 retains applicable embedded evidence; snapshot-only table rows still make no remote reads", - "All fourteen final screenshots inspected in desktop Chromium and dark touch phone WebKit; Playwright 1.56.1 matches the integration lock" - ], - "interactions": [ - "Remount full production resource drawer with denied/transient reads", - "Activate scoped Retry with keyboard Enter", - "Apply and clear Change kind filter", - "Switch between Overview and Manage" - ], "limitations": [ - "Synthetic HTTP responses and PBS snapshots through real production drawer/client/CSS, not appliance authorisation, reporter cause, release or installed recovery acceptance.", - "401 withdrawal is unit-covered; real client login redirection is not overridden or browser-proved.", - "Action client existing 401/402/403 unavailable-plan responses are unchanged. Only thrown read failures retain actionable error presentation.", - "Independent reader authorisation remains independent; this is not a global session revocation broadcast." - ] + "Synthetic API and dedicated real-component/router harness, not native migration, licence, agent admission or shipment", + "Final capture waits for real dialog animation; preceding state checks passed but blank transition captures did not establish visual proof" + ], + "browser_versions": [ + { + "engine": "chromium", + "browserVersion": "141.0.7390.37", + "width": 1440, + "height": 1000, + "warningScope": true, + "keyboardGuideAndRetargetLinks": true, + "syntheticExportAndRejectedImport": true, + "noPageOverflow": true, + "errors": [] + }, + { + "engine": "webkit", + "browserVersion": "26.0", + "width": 390, + "height": 844, + "warningScope": true, + "keyboardGuideAndRetargetLinks": true, + "syntheticExportAndRejectedImport": true, + "noPageOverflow": true, + "errors": [] + } + ], + "playwright_version": "1.56.1", + "shipped_asset_sha256": { + "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "f07fb69d5bd6b185d0e156d7affcf7d5f5fa78418b3f82dd776d49afbceb9341", + "frontend-modern/public/docs/MIGRATION.md": "ad4b5a4b07af44e1657f9512ef468a578b8b5957b9b115a30816824bbd8300e9", + "frontend-modern/public/docs/UNIFIED_AGENT.md": "61a15c9651223ac26f4ca900f5731534f03abf96abfd233c47bf034c2796fc05" + }, + "browser_script": "frontend-modern/browser-tests/configuration-migration.cjs" } diff --git a/frontend-modern/public/docs/MIGRATION.md b/frontend-modern/public/docs/MIGRATION.md index 58de776f83..0b3398133a 100644 --- a/frontend-modern/public/docs/MIGRATION.md +++ b/frontend-modern/public/docs/MIGRATION.md @@ -1,88 +1,122 @@ -# 🚚 Migrating Pulse +# Migrating Pulse -This guide covers migrating Pulse to a new host using the built-in encrypted export/import workflow. +**Create Backup** in **Settings → System → Recovery** creates an encrypted +configuration export, not a full backup of the installation. Check its scope +before moving Pulse or retiring the old server. Do not use it alone when you +need to preserve history, agent enrolment state or settings outside that scope. -## 🚀 Quick Migration Guide +## Choose the backup you need -### ❌ DON'T: Copy Files -Never copy `/etc/pulse` (or `/data` in Docker/Kubernetes) manually. Encryption keys and credentials can break. +### Configuration transfer -### ✅ DO: Use Export/Import +Use export/import to move the included settings between installations. Pulse +decrypts the export with its passphrase and saves the imported credentials with +the destination's encryption key. Do not copy individual encrypted files into +a fresh installation with a different key. -#### 1. Export (Old Server) -1. Go to **Settings → System → Recovery**. -2. Click **Create Backup**. -3. Enter a strong passphrase and download the encrypted backup. +The current export format includes: -#### 2. Import (New Server) -1. Install a fresh Pulse instance. -2. Go to **Settings → System → Recovery**. -3. Click **Restore Configuration** and upload your file. -4. Enter the passphrase. - -## 📦 What Gets Migrated - -| Included ✅ | Not Included ❌ | +| Included | Not included | | :--- | :--- | -| Nodes & credentials | Historical metrics history (`metrics.db`) | -| Alerts & overrides | Browser sessions and local cookies | -| Notifications (email, webhooks, Apprise) | Local login username/password (`.env`) | -| System settings (`system.json`) | Update history/backup folders | -| API token records | — | -| OIDC config | — | -| SSO / SAML config | — | -| TrueNAS connections (`truenas.enc`) | — | -| Guest metadata/notes | — | -| — | Relay config (`relay.enc`) — re-enable in Settings | -| — | Host metadata (notes/tags/AI command overrides) | -| — | Docker metadata cache | -| — | Agent profiles and assignments | -| — | AI settings and findings (`ai.enc`, `ai_findings.json`, `ai_patrol_runs.json`, `ai_usage_history.json`) | -| — | RBAC roles (`rbac_roles.json`) — re-create after import | -| — | Relay/Pro/legacy Pro+/Cloud license (`license.enc`) | -| — | Server sessions (`sessions.json`) | -| — | Update history (`update-history.jsonl`) | +| Proxmox VE, PBS and PMG connections and credentials | TrueNAS, vSphere and Machine Availability connections | +| Alert configuration, overrides and intent policies | Active alerts, incidents, notification queue and delivery history | +| Email, webhook, Apprise and external watchdog configuration | Metrics history, audit history and report schedules | +| System settings | Local login credentials, environment overrides and sessions | +| API-token records | Server-side host/Docker/Kubernetes agent inventory and enrolment state | +| SSO providers configured in Pulse | Legacy environment-configured OIDC settings, external CA/certificate files and custom RBAC roles | +| Guest metadata and notes | Host/Docker metadata, agent profiles and assignments | +| — | AI settings, findings, usage and chat history | +| — | Licence files, existing Relay configuration, binaries, service definitions and update backups | -## 🔄 Common Scenarios +Contents depend on the version that created the export. Older formats can omit +settings included above. Imported API-token records retain their scopes and +expiry; they do not reveal the original tokens or transfer every agent's state. +SSO configuration **is included**, even though local login credentials are not. -### Moving to New Hardware -Export from old → Install new → Import. +### Full-state recovery -### Docker ↔ Systemd ↔ Kubernetes -The export file works across all installation methods. You can migrate from Docker to Kubernetes or vice versa seamlessly. +If you need the same installation's history and excluded state, retain a +consistent filesystem/volume backup as well. Stop Pulse before taking that +backup; monitoring and alert delivery pause while it is stopped. Preserve every +effective data path, the matching `.encryption.key`, audit data and signing key, +environment files, external certificates, binaries and service/container +configuration together. Custom paths and organisation directories matter. +Copying a live database file alone can omit its sidecar files. -### Disaster Recovery -1. Install Pulse using Docker or your preferred method (see [INSTALL.md](INSTALL.md)). -2. Import your latest backup. -3. Restored in < 5 minutes. +Keep that backup private and test recovery on an isolated destination before +retiring the source. Do not mix encrypted files and keys from different +installations or restore over a running Pulse. See [configuration +paths](CONFIGURATION.md) and [audit storage and recovery](AUDIT_LOGGING.md#storage). +The [updater's installation snapshot](AUTO_UPDATE.md#what-an-update-snapshot-contains) +is a third, limited backup; it is not a substitute for either procedure above. -## 📋 Post-Migration Checklist +## Transfer the included configuration -Because local login credentials are stored in `.env` (not part of exports), you must: +1. On the old server, sign in and open **Settings → System → Recovery → + Create Backup**. Use a strong, unique passphrase of at least 12 characters + and store it separately from the downloaded file. With password login, + choose **Use a custom passphrase** if you do not want to use that password. + Keep the old instance and your separate full-state backup until validation + is complete. +2. Install Pulse at the destination using the [installation guide](INSTALL.md). + Configure destination-local administrator access before importing. Keep the + destination isolated from agents and notification destinations during the + trial so two servers do not send duplicate alerts or remote actions. +3. If the destination already has useful settings, export them first. Open + **Settings → System → Recovery → Restore Configuration**, select the file + and enter its passphrase. Import replaces the included configuration and + API-token records; it does not merge them with the destination's records. +4. Check the result before changing DNS or agent addresses. A success message + establishes neither full recovery nor fresh monitoring. If import reports a + reload/apply failure, settings may already have been written: preserve both + installations and inspect the error before repeating it. -1. **Re-create Admin User**: If not using `.env` overrides, create your admin account on the new instance. -2. **Confirm API access**: - * If you created API tokens in the UI, those token records are included in the export and should continue working. -3. **Update Agents**: - * **Unified Agent**: Update the `--token` flag in your service definition. - * **Containerized agent**: Update `PULSE_TOKEN` in the agent container environment. - * **New server address?** Agents dial the address they were installed - with (the `--url` flag, or `PULSE_URL` for containerized agents), so - agents disappear from the new instance until that value is updated in - each agent's service definition. Fronting Pulse with a DNS name - instead of an IP address makes future moves invisible to agents. - * *Tip: Use **Settings → Infrastructure → Install on a host** to generate updated install commands.* -4. **Relay/Pro/legacy Pro+/Cloud**: Re-activate your license key after migration (license files are not included in exports). +Keep the archive, passphrase, configuration files and credentials out of issue +threads. Use the authenticated settings form rather than a command containing +a password, API token or session cookie. -## 🔒 Security +## Cut over and verify -* **Encryption**: Exports are encrypted with passphrase-based encryption (PBKDF2 + AES-GCM). -* **Storage**: Safe to store in cloud backups or password managers. -* **Passphrase**: Use a strong, unique passphrase (min 12 chars). +- **Login and permissions:** verify destination-local administrator access and + any imported SSO provider, group mappings and callback address. Re-create + custom roles and supply external CA/certificate files if needed. Environment + overrides can take precedence over imported settings. +- **Connections and history:** check every expected platform, guest and backup + against a fresh observation, not a cached row. Re-create excluded TrueNAS, + vSphere and Machine Availability connections. Missing historical data after + configuration-only transfer is not proof that collection has failed. +- **Agents:** preserve each agent's existing identity and private credential. + With a changed server address, use the [agent retargeting + guide](UNIFIED_AGENT.md#moving-pulse-to-a-new-address) on the monitored host. + Do not create another token just for a server move or paste it into `--token`. + Confirm fresh reports and any enrolment/authorisation result; imported token + records alone do not prove that every agent is admitted. Re-create excluded + profiles and assignments before enabling remote actions. +- **Alerts and notifications:** avoid running both instances against the same + destinations. After cutover, check thresholds, schedules and one controlled + notification test with its actual delivery result. Importing destination + settings does not migrate pending notifications or incident history. +- **Licence and remaining settings:** re-activate Pro if required and verify + any excluded settings you rely on. Existing paired phones need their retained + configuration; configuration export alone is not a Mobile/Relay migration. + Relay is no longer sold; do not enable it as a new migration route. -## 🔧 Troubleshooting +Retire the old installation only after these checks succeed. Keep the private, +consistent pre-move backup for recovery. If validation fails, stop the trial +destination before returning traffic to the intact source; check agent URLs +and avoid two active writers. There is no guaranteed five-minute recovery time. -* **"Invalid passphrase"**: Ensure exact match (case-sensitive). -* **Missing Nodes**: Verify export date. -* **Connection Errors**: Update node IPs in Settings if they changed. -* **Logging**: Adjust `LOG_LEVEL`/`LOG_FORMAT` via environment variables if needed. +## Troubleshooting + +- **Wrong passphrase or rejected file:** use the original export file and its + exact passphrase. Do not delete destination data or regenerate encryption + keys to make an import succeed. +- **A setting is missing:** compare it with the export scope above and the + exporting version. Use the old installation or full-state backup rather + than assuming import contained it. +- **Agents are absent:** check their primary URL, TLS trust and fresh admission + evidence using the linked guide. Do not uninstall, re-enrol or delete saved + identities just because the dashboard is empty. +- **Need help:** retain the source/destination versions, deployment types, + operation time and exact redacted error. Follow [safe + reporting](TROUBLESHOOTING.md#getting-help); do not attach the recovery backup. diff --git a/frontend-modern/public/docs/UNIFIED_AGENT.md b/frontend-modern/public/docs/UNIFIED_AGENT.md index adf8371792..405e50271d 100644 --- a/frontend-modern/public/docs/UNIFIED_AGENT.md +++ b/frontend-modern/public/docs/UNIFIED_AGENT.md @@ -52,10 +52,12 @@ first unless you are intentionally removing that host from Pulse. ### Moving Pulse to a new address -Configuration export/import restores the server-side agent records and API -tokens. It cannot rewrite the primary Pulse URL on remote machines because -agents initiate the connection. Prefer a stable DNS name for the primary URL -so replacing the Pulse host does not require an agent migration. +Configuration export/import restores API-token records, not the server-side +agent inventory, enrolment state, profiles or assignments. See the +[configuration-transfer scope](MIGRATION.md#configuration-transfer) before +retiring the old server. Import cannot rewrite the primary Pulse URL on remote +machines because agents initiate the connection. Prefer a stable DNS name for +the primary URL, but still verify fresh reports and agent admission after a move. After importing the configuration on a Pulse server with a different address, retarget each existing standard Linux agent from that agent machine. Download diff --git a/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx b/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx index 352b99c689..0bd52c607e 100644 --- a/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx +++ b/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx @@ -5,6 +5,7 @@ import { SectionHeader } from '@/components/shared/SectionHeader'; import { controlClass, formField, formHelpText, labelClass } from '@/components/shared/Form'; import type { SecurityStatus as SecurityStatusInfo } from '@/types/config'; import { getAPITokenManagementLocationMessage } from '@/utils/apiTokenPresentation'; +import { getShippedDocUrl } from '@/utils/docsLinks'; interface BackupTransferDialogsProps { securityStatus: Accessor; @@ -140,9 +141,19 @@ export const BackupTransferDialogs: Component = (pro />
- Important: The backup contains node credentials but NOT - authentication settings. Each Pulse instance should configure its own login - credentials for security. Remember your{' '} + Configuration only: Includes Proxmox connections, SSO settings + and API-token records, but not history, TrueNAS/vSphere connections or agent + enrolment state. Local login credentials and sessions are not included. Check + the{' '} + + migration guide + {' '} + before relying on this for recovery. Remember your{' '} {props.useCustomPassphrase() || !props.securityStatus()?.hasAuthentication ? 'passphrase' : 'password'}{' '} @@ -282,13 +293,24 @@ export const BackupTransferDialogs: Component = (pro

- Warning: Importing will replace all current configuration. This - action cannot be undone. + Warning: Import replaces the included settings and API-token + records, not the whole installation. Back up the destination first. History and + excluded settings are not restored; check the{' '} + + migration guide + {' '} + before retiring the old server.

- Agent migration: The backup restores server-side agent records - and credentials, but it cannot change the Pulse URL stored on remote agents. If - this server has a different address, retarget each agent after the import. + Agent migration: API-token records are included, but agent + inventory, enrolment state, profiles and assignments are not. Import cannot + change the Pulse URL stored on remote agents. Preserve each agent's identity and + private credential, retarget if the address changes, and verify fresh admission.

diff --git a/frontend-modern/src/components/Settings/__tests__/BackupTransferDialogs.test.tsx b/frontend-modern/src/components/Settings/__tests__/BackupTransferDialogs.test.tsx index 1a0e2eb131..4820a58ea1 100644 --- a/frontend-modern/src/components/Settings/__tests__/BackupTransferDialogs.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/BackupTransferDialogs.test.tsx @@ -2,40 +2,65 @@ import { cleanup, render, screen } from '@solidjs/testing-library'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { BackupTransferDialogs } from '../BackupTransferDialogs'; +function renderTransfer(kind: 'export' | 'import') { + return render(() => ( + null} + exportPassphrase={() => ''} + setExportPassphrase={vi.fn()} + useCustomPassphrase={() => false} + setUseCustomPassphrase={vi.fn()} + importPassphrase={() => 'secure-passphrase'} + setImportPassphrase={vi.fn()} + importFile={() => null} + setImportFile={vi.fn()} + showExportDialog={() => kind === 'export'} + showImportDialog={() => kind === 'import'} + showApiTokenModal={() => false} + apiTokenInput={() => ''} + setApiTokenInput={vi.fn()} + handleExport={vi.fn()} + handleImport={vi.fn()} + closeExportDialog={vi.fn()} + closeImportDialog={vi.fn()} + closeApiTokenModal={vi.fn()} + handleApiTokenAuthenticate={vi.fn()} + /> + )); +} + describe('BackupTransferDialogs', () => { - afterEach(() => { - cleanup(); + afterEach(cleanup); + + it('discloses the actual archive scope before exporting', () => { + renderTransfer('export'); + expect(screen.getByText('Configuration only:')).toBeInTheDocument(); + const text = screen.getByRole('dialog', { name: 'Export configuration' }).textContent; + expect(text).toContain('SSO settings'); + expect(text).toContain('API-token records'); + expect(text).toContain('not history, TrueNAS/vSphere connections'); + expect(text).toContain('Local login credentials and sessions are not included'); + expect(text).not.toContain('NOT authentication settings'); + expect(screen.getByRole('link', { name: 'migration guide' })).toHaveAttribute( + 'href', + '/docs/MIGRATION', + ); }); - it('discloses that configuration import cannot retarget remote agents', () => { - render(() => ( - null} - exportPassphrase={() => ''} - setExportPassphrase={vi.fn()} - useCustomPassphrase={() => false} - setUseCustomPassphrase={vi.fn()} - importPassphrase={() => 'secure-passphrase'} - setImportPassphrase={vi.fn()} - importFile={() => null} - setImportFile={vi.fn()} - showExportDialog={() => false} - showImportDialog={() => true} - showApiTokenModal={() => false} - apiTokenInput={() => ''} - setApiTokenInput={vi.fn()} - handleExport={vi.fn()} - handleImport={vi.fn()} - closeExportDialog={vi.fn()} - closeImportDialog={vi.fn()} - closeApiTokenModal={vi.fn()} - handleApiTokenAuthenticate={vi.fn()} - /> - )); - + it('distinguishes token transfer from agent admission before importing', () => { + renderTransfer('import'); expect(screen.getByText('Agent migration:')).toBeInTheDocument(); - expect( - screen.getByText(/it cannot change the Pulse URL stored on remote agents/i), - ).toBeInTheDocument(); + const text = screen.getByRole('dialog', { name: 'Import configuration' }).textContent; + expect(text).toContain('Back up the destination first'); + expect(text).toContain('not the whole installation'); + expect(text).toContain('inventory, enrolment state, profiles and assignments are not'); + expect(text).toContain('Import cannot change the Pulse URL stored on remote agents'); + expect(text).toContain('Preserve each agent'); + expect(text).toContain('verify fresh admission'); + expect(text).not.toContain('restores server-side agent records'); + const guide = screen.getByRole('link', { name: 'migration guide' }); + expect(guide).toHaveAttribute('href', '/docs/MIGRATION'); + expect(guide).toHaveAttribute('target', '_blank'); + expect(guide).toHaveAttribute('rel', 'noopener noreferrer'); }); }); diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index d4bac377ae..0d980ec443 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -131,6 +131,31 @@ describe('docsLinks', () => { } }); + it('keeps configuration transfer separate from full-state recovery', () => { + const migration = readFileSync(path.join(repoRoot, 'docs', 'MIGRATION.md'), 'utf8'); + const agent = readFileSync(path.join(repoRoot, 'docs', 'UNIFIED_AGENT.md'), 'utf8'); + const scope = migration + .split('### Configuration transfer')[1] + .split('### Full-state recovery')[0]; + expect(scope).toContain('Proxmox VE, PBS and PMG'); + expect(scope).toContain('| TrueNAS, vSphere and Machine Availability'); + expect(scope).toContain('SSO configuration **is included**'); + expect(scope).toContain( + 'Server-side host/Docker/Kubernetes agent inventory and enrolment state', + ); + expect(migration).toContain('not a full backup of the installation'); + expect(migration).toContain('Stop Pulse before taking that'); + expect(migration).toContain('matching `.encryption.key`'); + expect(migration).toContain('reload/apply failure'); + expect(migration).toContain('it does not merge'); + expect(migration).toContain('UNIFIED_AGENT.md#moving-pulse-to-a-new-address'); + expect(migration).not.toContain('Update the `--token` flag'); + expect(migration).not.toContain('Restored in < 5 minutes'); + expect(migration).not.toContain('re-enable in Settings'); + expect(agent).toContain('restores API-token records, not the server-side'); + expect(agent).toContain('MIGRATION.md#configuration-transfer'); + }); + it('keeps lockout and incident reporting guidance safe for the affected client', () => { const troubleshooting = readFileSync(path.join(repoRoot, 'docs', 'TROUBLESHOOTING.md'), 'utf8'); const recovery = troubleshooting.split('### Recovery Mode')[1].split('\n---')[0]; diff --git a/internal/config/migration_scope_test.go b/internal/config/migration_scope_test.go new file mode 100644 index 0000000000..fc4b58377a --- /dev/null +++ b/internal/config/migration_scope_test.go @@ -0,0 +1,118 @@ +package config + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" + "github.com/stretchr/testify/require" +) + +// Exercise the actual archive, not a fabricated ExportData value: the migration +// warnings must match what a fresh destination can recover with a different key. +func TestConfigurationMigrationArchiveScope(t *testing.T) { + t.Setenv("PULSE_DATA_DIR", t.TempDir()) + sourceDir, targetDir := t.TempDir(), t.TempDir() + source, target := NewConfigPersistence(sourceDir), NewConfigPersistence(targetDir) + require.NoError(t, source.EnsureConfigDir()) + require.NoError(t, target.EnsureConfigDir()) + + nodes := []PVEInstance{{Name: "synthetic-pve", Host: "https://pve.example:8006", TokenName: "pulse@pve!read", TokenValue: "synthetic-node-secret"}} + require.NoError(t, source.SaveNodesConfig(nodes, nil, nil)) + expires := time.Now().Add(time.Hour).UTC() + tokens := []APITokenRecord{{ID: "synthetic-agent-token", Name: "agent", Hash: auth.HashAPIToken("synthetic-agent-secret"), CreatedAt: time.Now().UTC(), ExpiresAt: &expires, Scopes: []string{ScopeAgentReport}, OrgID: "default"}} + require.NoError(t, source.SaveAPITokens(tokens)) + sso := &SSOConfig{Providers: []SSOProvider{{ID: "synthetic-sso", Name: "SSO", Type: SSOProviderTypeOIDC, Enabled: true, OIDC: &OIDCProviderConfig{IssuerURL: "https://idp.example", ClientID: "synthetic", ClientSecret: "synthetic-sso-secret"}}}} + require.NoError(t, source.SaveSSOConfig(sso)) + sourceNAS := []TrueNASInstance{{Name: "source-nas", Host: "https://nas.example", APIKey: "synthetic-nas-secret"}} + require.NoError(t, source.SaveTrueNASConfig(sourceNAS)) + targetNAS := []TrueNASInstance{{Name: "target-nas", Host: "https://target.example", APIKey: "synthetic-target-secret"}} + require.NoError(t, target.SaveTrueNASConfig(targetNAS)) + + // Excluded state exists at both ends. Import must not pretend to transfer it + // or overwrite the destination's excluded files. These are inert sentinels, + // not live databases, credentials or operational agent records. + excluded := []string{".env", "metrics.db", "audit.db", "host_agents.json", "agent_profiles.json", "rbac_roles.json", "license.enc"} + for _, name := range excluded { + require.NoError(t, os.WriteFile(filepath.Join(sourceDir, name), []byte("synthetic-source-only"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, name), []byte("synthetic-destination-only"), 0600)) + } + sourceKey, err := os.ReadFile(filepath.Join(sourceDir, ".encryption.key")) + require.NoError(t, err) + targetKey, err := os.ReadFile(filepath.Join(targetDir, ".encryption.key")) + require.NoError(t, err) + require.False(t, bytes.Equal(sourceKey, targetKey), "fresh destinations must use a different key") + + const passphrase = "synthetic-migration-passphrase" + archive, err := source.ExportConfig(passphrase) + require.NoError(t, err) + ciphertext, err := base64.StdEncoding.DecodeString(archive) + require.NoError(t, err) + plaintext, err := decryptWithPassphrase(ciphertext, passphrase) + require.NoError(t, err) + var payload map[string]json.RawMessage + require.NoError(t, json.Unmarshal(plaintext, &payload)) + require.Equal(t, "4.4", stringValue(t, payload["version"])) + // Changing archive scope requires updating the actual user-facing account, + // not silently assuming every persisted store is in a configuration export. + require.ElementsMatch(t, []string{"version", "exportedAt", "nodes", "alerts", "alertIntentPolicies", "email", "webhooks", "apprise", "deadMan", "system", "sso", "apiTokens"}, rawKeys(payload)) + require.NotContains(t, string(plaintext), "synthetic-nas-secret") + require.NotContains(t, string(plaintext), "synthetic-source-only") + require.Contains(t, string(plaintext), "synthetic-sso-secret") + + baseline := []PVEInstance{{Name: "destination-before", Host: "https://before.example:8006"}} + require.NoError(t, target.SaveNodesConfig(baseline, nil, nil)) + require.Error(t, target.ImportConfig(archive, "wrong-synthetic-passphrase")) + unchanged, err := target.LoadNodesConfig() + require.NoError(t, err) + require.Equal(t, baseline[0].Name, unchanged.PVEInstances[0].Name) + + require.NoError(t, target.ImportConfig(archive, passphrase)) + reopened := NewConfigPersistence(targetDir) + restored, err := reopened.LoadNodesConfig() + require.NoError(t, err) + require.Equal(t, nodes[0].TokenValue, restored.PVEInstances[0].TokenValue) + require.Equal(t, nodes[0].Name, restored.PVEInstances[0].Name) + restoredSSO, err := reopened.LoadSSOConfig() + require.NoError(t, err) + require.Equal(t, sso.Providers[0].OIDC.ClientSecret, restoredSSO.Providers[0].OIDC.ClientSecret) + restoredTokens, err := reopened.LoadAPITokens() + require.NoError(t, err) + cfg := &Config{APITokens: restoredTokens} + record, ok := cfg.ValidateAPIToken("synthetic-agent-secret") + require.True(t, ok, "restored records validate the existing credential without replacing it") + require.Equal(t, tokens[0].ID, record.ID) + require.Equal(t, tokens[0].Scopes, record.Scopes) + require.WithinDuration(t, expires, *record.ExpiresAt, time.Millisecond) + nas, err := reopened.LoadTrueNASConfig() + require.NoError(t, err) + require.Equal(t, targetNAS[0].Name, nas[0].Name) + for _, name := range excluded { + data, err := os.ReadFile(filepath.Join(targetDir, name)) + require.NoError(t, err) + require.Equal(t, "synthetic-destination-only", string(data), name) + } + keyAfter, err := os.ReadFile(filepath.Join(targetDir, ".encryption.key")) + require.NoError(t, err) + require.True(t, bytes.Equal(targetKey, keyAfter), "configuration import keeps the destination key") +} + +func rawKeys(payload map[string]json.RawMessage) []string { + keys := make([]string, 0, len(payload)) + for key := range payload { + keys = append(keys, key) + } + return keys +} + +func stringValue(t *testing.T, raw json.RawMessage) string { + t.Helper() + var value string + require.NoError(t, json.Unmarshal(raw, &value)) + return value +}