diff --git a/src/lib/server/backups/restore-service.ts b/src/lib/server/backups/restore-service.ts index 4f52164..1e393d8 100644 --- a/src/lib/server/backups/restore-service.ts +++ b/src/lib/server/backups/restore-service.ts @@ -90,8 +90,10 @@ export interface RestorePorts { /** Recreate a container AS-IS from the snapshot's stored inspect config; throws * with a clear message if that metadata is missing/insufficient. */ recreateContainerFromMetadata(name: string, envId: number | null | undefined, snapshotMetaContainer: unknown): Promise; - /** Redeploy a stack from the snapshot's stored compose files; throws if absent. */ - redeployStack(name: string, envId: number | null | undefined, destinationId: number, snapshotId: string): Promise; + /** Redeploy a stack from the snapshot's stored compose files; throws if absent. + * restoreSecrets (opt-in, default on): write the stack's secrets carried in the + * snapshot into the target env's DB before redeploy so the stack comes up complete. */ + redeployStack(name: string, envId: number | null | undefined, destinationId: number, snapshotId: string, restoreSecrets?: boolean): Promise; /** Read the snapshot's stored restore metadata (metadata.json), or null. */ readSnapshotMetadata(destinationId: number, snapshotId: string): Promise; /** Write the snapshot's captured stack files (/metadata/stacks//stackfiles/) @@ -147,6 +149,10 @@ export interface RestoreJob { * Presence of ANY entry (or a recreate/redeploy postRestore) selects the clone * path. */ volumeDestinations?: Array<{ volume: string; kind: 'volume' | 'path'; target: string }>; + /** Whether to restore the stack's secrets carried in the snapshot (default true). + * false = bring the stack up without secrets (the operator re-enters them). Only + * meaningful for a stack redeploy. */ + restoreSecrets?: boolean; } export class RestoreService { @@ -617,7 +623,7 @@ export class RestoreService { return { action, ok: false, error: 'no stack files stored in this snapshot; cannot redeploy' }; } op.progress('redeploying', `Redeploying stack ${name}...`); - await this.ports.redeployStack(name, envId, job.destinationId, job.snapshotId); + await this.ports.redeployStack(name, envId, job.destinationId, job.snapshotId, job.restoreSecrets ?? true); return { action, ok: true }; } } diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index f3ed633..7b868c3 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -81,7 +81,7 @@ import { } from './db/drizzle.js'; import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types'; -import { encrypt, decrypt, decryptStrict } from './encryption.js'; +import { encrypt, decrypt, decryptStrict, isEncrypted } from './encryption.js'; import { parseEnvInterpolation } from './env-interpolation'; import { invalidateVulnerabilitiesCache } from './vulnerabilities-cache'; @@ -4999,6 +4999,40 @@ export async function getSecretEnvVarsAsRecord( ); } +/** + * Get a stack's SECRET env vars as their RAW at-rest ciphertext (enc:v1:...), keyed + * by name. Unlike the *AsRecord helpers this does NOT decrypt: it returns the exact + * bytes stored in the DB. Used by backup to carry secrets in a snapshot still + * encrypted under this instance's key — never plaintext. A value that is somehow + * stored plaintext (legacy) is encrypted here so a snapshot never holds a bare secret. + */ +export async function getStackSecretCiphertexts( + stackName: string, + environmentId?: number | null +): Promise> { + let rows; + if (environmentId === undefined) { + rows = await db.select().from(stackEnvironmentVariables) + .where(eq(stackEnvironmentVariables.stackName, stackName)); + } else if (environmentId === null) { + rows = await db.select().from(stackEnvironmentVariables) + .where(and( + eq(stackEnvironmentVariables.stackName, stackName), + isNull(stackEnvironmentVariables.environmentId) + )); + } else { + rows = await db.select().from(stackEnvironmentVariables) + .where(and( + eq(stackEnvironmentVariables.stackName, stackName), + eq(stackEnvironmentVariables.environmentId, environmentId) + )); + } + return rows + .filter((r: typeof rows[number]) => r.isSecret && r.value) + .map((r: typeof rows[number]) => ({ key: r.key, value: isEncrypted(r.value) ? r.value : (encrypt(r.value) ?? '') })) + .filter((r: { key: string; value: string }) => r.value); +} + /** * Get only NON-SECRET environment variables as a key-value record. * Used for .env file operations where secrets should be excluded. diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index dee423f..640590e 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -2126,6 +2126,11 @@ export async function redeployStackFromDir( const envPath = join(stackDir, '.env'); const hasEnv = existsSync(envPath); const envVars = hasEnv ? parseEnvFileContent(readFileSync(envPath, 'utf-8'), stackName) : undefined; + // Secret env vars are stored encrypted in the DB and deliberately never written + // to the snapshot's .env, so the extracted dir has no copy of them. Load them + // from the DB and inject them like every other compose path does (#1329) — + // otherwise a restored stack comes up with its secrets interpolating to "". + const secretVars = await getSecretEnvVarsAsRecord(stackName, envId); // For Hawser, ship the entire tree (compose + include:d files + sidecars + .env). const stackFiles = await readDirFilesAsMap(stackDir); return await executeComposeCommand( @@ -2139,7 +2144,8 @@ export async function redeployStackFromDir( stackFiles }, composeContent, - envVars + envVars, + secretVars ); } @@ -2180,8 +2186,8 @@ export async function startStack( } // Git stacks need useOverrideFile to write .env.dockhand with DB overrides. - // sourceType is plumbed through from requireComposeFile (which already looked - // it up via getStackComposeFile/getStackSource) to avoid a redundant DB lookup. + // sourceType is plumbed through from requireComposeFile (which already looked it up + // via getStackComposeFile/getStackSource) to avoid a redundant DB lookup. const isGitStack = result.sourceType === 'git'; const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack }; @@ -2220,7 +2226,9 @@ export async function stopStack( return fallback; } - // Git stacks need useOverrideFile to write .env.dockhand with DB overrides. + // Git stacks need useOverrideFile so `.env.dockhand` (the panel vars) is passed via + // --env-file; otherwise `docker compose stop` re-interpolates ${VAR:?} in the compose + // file with the panel vars missing and errors (#1313). Matches startStack/deployStack. // sourceType is plumbed through from requireComposeFile to avoid a redundant DB lookup. const isGitStack = result.sourceType === 'git'; @@ -2301,7 +2309,7 @@ export async function downStack( return withContainerFallback(stackName, envId, 'stop'); } - // Git stacks need useOverrideFile to write .env.dockhand with DB overrides. + // useOverrideFile for git stacks — same reason as stopStack (#1313). // sourceType is plumbed through from requireComposeFile to avoid a redundant DB lookup. const isGitStack = result.sourceType === 'git'; @@ -2333,6 +2341,11 @@ export async function removeStack( // Get compose file (may not exist for external stacks) const composeResult = await getStackComposeFile(stackName, envId); + // useOverrideFile for git stacks — same reason as stopStack (#1313). + // sourceType is plumbed through from getStackComposeFile (which already looked it + // up via getStackSource) to avoid a redundant DB lookup. + const isGitStack = composeResult.sourceType === 'git'; + // Get stack containers BEFORE removing them (for cleanup later) const stackContainers = await getStackContainers(stackName, envId); @@ -2367,6 +2380,7 @@ export async function removeStack( workingDir: composeResult.stackDir, composePath: composeResult.composePath ?? undefined, envPath: composeResult.envPath ?? undefined, + useOverrideFile: isGitStack, // Full stack removal: the Hawser agent cleans its stack dir (#1162) removeFiles: true, filesToDelete: removalFiles diff --git a/src/routes/api/backup/restore/+server.ts b/src/routes/api/backup/restore/+server.ts index 1bbd5a7..a55f5f9 100644 --- a/src/routes/api/backup/restore/+server.ts +++ b/src/routes/api/backup/restore/+server.ts @@ -21,6 +21,10 @@ export const POST: RequestHandler = async (event) => { const postRestore = ['start', 'recreate', 'redeploy', 'none'].includes(body.postRestore) ? body.postRestore : undefined; // New-location clone: per-volume destinations on the target env. const volumeDestinations = Array.isArray(body.volumeDestinations) ? body.volumeDestinations : undefined; + // Stack restore: reproduce the stack's secrets carried in the snapshot (default on). + // The client sends false to bring the stack up without them. Only a stack redeploy + // consumes this; container restores ignore it. + const restoreSecrets = body.restoreSecrets !== false; // Authorization gates run BEFORE request validation, so an unauthorized caller // never learns anything about the request shape. @@ -71,6 +75,7 @@ export const POST: RequestHandler = async (event) => { targetName: body.targetName ?? null, postRestore, volumeDestinations, + restoreSecrets, }, access, // Stream progress to the client (the restore modal's log). Without this the diff --git a/src/routes/api/backup/snapshots/[id]/metadata/+server.ts b/src/routes/api/backup/snapshots/[id]/metadata/+server.ts index 936185a..3de77a0 100644 --- a/src/routes/api/backup/snapshots/[id]/metadata/+server.ts +++ b/src/routes/api/backup/snapshots/[id]/metadata/+server.ts @@ -28,7 +28,15 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { try { const metadata = await getSnapshotMetadata(destinationId, snapshotId); if (!metadata) return json({ error: 'No metadata available' }, { status: 404 }); - return json(metadata); + // Never expose the stored secret VALUES (ciphertext) to the client — replace the + // `secrets` array with `secretKeys` (names only) so the UI can list them. + const { secrets, ...safe } = metadata as Record; + if (Array.isArray(secrets)) { + (safe as Record).secretKeys = secrets + .map((s: any) => s?.key) + .filter((k: unknown): k is string => typeof k === 'string'); + } + return json(safe); } catch (error) { const msg = error instanceof Error ? error.message : String(error); return json({ error: msg }, { status: 500 }); diff --git a/src/routes/containers/RestoreModal.svelte b/src/routes/containers/RestoreModal.svelte index dad77ad..a1ad579 100644 --- a/src/routes/containers/RestoreModal.svelte +++ b/src/routes/containers/RestoreModal.svelte @@ -6,7 +6,7 @@ import { Checkbox } from '$lib/components/ui/checkbox'; import { Label } from '$lib/components/ui/label'; import { Badge } from '$lib/components/ui/badge'; - import { RotateCcw, AlertTriangle, Loader2, HardDrive, Folder, Clock, Play, CheckCircle2, XCircle, Server, PackagePlus, Ban, Rocket, Box, Layers, HelpCircle, Info } from 'lucide-svelte'; + import { RotateCcw, AlertTriangle, Loader2, HardDrive, Folder, Clock, Play, CheckCircle2, XCircle, Server, PackagePlus, Ban, Rocket, Box, Layers, HelpCircle, Info, KeyRound } from 'lucide-svelte'; import * as Tooltip from '$lib/components/ui/tooltip'; import { toast } from 'svelte-sonner'; import { watchJob } from '$lib/utils/sse-fetch'; @@ -89,6 +89,12 @@ // in-place still knows which env holds the live target (otherwise the button stays // disabled and the header shows "on " with no name). let sourceEnvId = $state(undefined); + // Names (never values) of the secrets carried IN this snapshot. The values are + // stored encrypted under this instance's key; restoring writes them back into the + // target env's DB so the stack comes up complete. Default ON — a restore is meant to + // reproduce a working stack. Off = bring it up without secrets (re-enter by hand). + let sourceSecretKeys = $state([]); + let restoreSecrets = $state(true); // Volume names that already exist on the chosen target env (for conflict marks). let existingTargetVolumes = $state([]); // Target-NAME collision: a container/stack of the restore's name already exists @@ -113,6 +119,9 @@ // The snapshot's source env (for the "from " phrasing in the summary). const sourceEnv = $derived(envList.find((e) => e.id === sourceEnvId)); const sourceEnvName = $derived(sourceEnv?.name ?? ''); + // Offer the "restore secrets from this backup" toggle whenever this is a stack + // snapshot that actually carries secrets — same UI for in-place and cross-env. + const showSecretRestore = $derived(targetIsStack && sourceSecretKeys.length > 0); const postRestoreLabel = $derived(postRestoreOptions.find((o) => o.value === postRestore)?.label ?? ''); // Step-rail styling: always neutral (primary). Only the What-will-happen box // turns red on the destructive (in-place) path — the rail/numbers stay calm. @@ -309,6 +318,7 @@ // unselected on purpose — the user picks it. if (sourceEnvId == null) sourceEnvId = data.sourceEnvironmentId; } + sourceSecretKeys = Array.isArray(data.sourceSecretKeys) ? data.sourceSecretKeys : []; } catch { error = 'Failed to read the snapshot'; } finally { @@ -342,6 +352,10 @@ volumes: selectedRows.map((v) => v.name), environmentId: effectiveEnvId, targetName: containerName, + // Restore the stack's secrets carried in the snapshot. Only send false + // when the toggle is shown and the user turned it off; otherwise the + // server default (restore them) applies. + restoreSecrets: showSecretRestore ? restoreSecrets : undefined, confirmOverwrite: mode === 'in-place' ? confirmOverwrite : undefined, // postRestore is sent for BOTH modes now (clone brings the target up). postRestore, @@ -538,6 +552,9 @@ {#if volumes.length === 0}

This snapshot has no volumes — restore recreates the {targetIsStack ? 'stack' : 'container'} from its saved config.

+ {#if showSecretRestore} +
{@render secretRestoreBlock()}
+ {/if} {:else if mode === 'new-location'} @@ -581,6 +598,7 @@ {/if} {/each} + {@render secretRestoreBlock()} {:else}
@@ -597,6 +615,7 @@ {vol.name} {/each} + {@render secretRestoreBlock()}
{/if} @@ -666,6 +685,42 @@ {#snippet envChip(env: { id: number; icon?: string | null } | undefined, name: string)} {#if env}{/if}{name || '…'} {/snippet} + + {#snippet secretRestoreBlock()} + {#if showSecretRestore} +
+ +
+ {#each sourceSecretKeys as key} + {key} + {/each} +
+ {#if restoreSecrets} +
+ + + Secrets are encrypted with this Dockhand instance's key. Restoring on a + different instance requires the same encryption key + (.encryption_key / + ENCRYPTION_KEY), or they + stay unreadable. + +
+ {/if} +
+ {/if} + {/snippet}
{#if mode === 'in-place'}{:else}{/if} @@ -714,6 +769,15 @@ {:else}

The {targetIsStack ? 'stack' : 'container'} is not started — the data lands on {@render envChip(targetEnv, targetEnvName)} and you bring it up yourself.

{/if} + {#if showSecretRestore} +

+ {#if restoreSecrets} + Its {sourceSecretKeys.length} secret{sourceSecretKeys.length === 1 ? '' : 's'} will be restored from the backup. + {:else} + Its {sourceSecretKeys.length} secret{sourceSecretKeys.length === 1 ? '' : 's'} will not be restored — set {sourceSecretKeys.length === 1 ? 'it' : 'them'} by hand afterwards. + {/if} +

+ {/if} {#if hasRemap} + {#if metadata.type === 'stack' && metadata.secretKeys?.length} +
+

Secrets ({metadata.secretKeys.length})

+
+ {#each metadata.secretKeys as key} + {key} + {/each} +
+

Stored encrypted with this instance's key. Restored to the stack on restore (values never shown).

+
+ {/if} + {#if containerInspect?.Config} @@ -258,50 +274,50 @@
Image
-
{containerInspect.Config.Image || '—'}
+
{containerInspect.Config.Image || '—'}
{#if containerInspect.Id}
Container ID
-
{containerInspect.Id.slice(0, 12)}
+
{containerInspect.Id.slice(0, 12)}
{/if} {#if containerInspect.State?.Status}
State at backup
-
{containerInspect.State.Status}
+
{containerInspect.State.Status}
{/if} {#if containerInspect.Created}
Created
-
{formatDateTime(containerInspect.Created)}
+
{formatDateTime(containerInspect.Created)}
{/if} {#if containerInspect.Config.Cmd?.length}
Command
-
{containerInspect.Config.Cmd.join(' ')}
+
{containerInspect.Config.Cmd.join(' ')}
{/if} {#if containerInspect.Config.Entrypoint?.length}
Entrypoint
-
{containerInspect.Config.Entrypoint.join(' ')}
+
{containerInspect.Config.Entrypoint.join(' ')}
{/if} {#if containerInspect.Config.WorkingDir}
Working dir
-
{containerInspect.Config.WorkingDir}
+
{containerInspect.Config.WorkingDir}
{/if} {#if containerInspect.Config.User}
User
-
{containerInspect.Config.User}
+
{containerInspect.Config.User}
{/if} {#if containerInspect.Config.ExposedPorts}
Exposed ports
-
{Object.keys(containerInspect.Config.ExposedPorts).join(', ')}
+
{Object.keys(containerInspect.Config.ExposedPorts).join(', ')}
{/if} {#if containerInspect.HostConfig?.RestartPolicy?.Name}
Restart policy
-
{containerInspect.HostConfig.RestartPolicy.Name}{containerInspect.HostConfig.RestartPolicy.MaximumRetryCount ? ` (max ${containerInspect.HostConfig.RestartPolicy.MaximumRetryCount})` : ''}
+
{containerInspect.HostConfig.RestartPolicy.Name}{containerInspect.HostConfig.RestartPolicy.MaximumRetryCount ? ` (max ${containerInspect.HostConfig.RestartPolicy.MaximumRetryCount})` : ''}
{/if} {#if containerInspect.HostConfig?.NetworkMode}
Network mode
-
{containerInspect.HostConfig.NetworkMode}
+
{containerInspect.HostConfig.NetworkMode}
{/if} {#if containerInspect.HostConfig?.Privileged}
Privileged
-
yes
+
yes
{/if}
@@ -347,15 +363,15 @@
{#if containerInspect.HostConfig?.LogConfig?.Type}
Log driver
-
{containerInspect.HostConfig.LogConfig.Type}
+
{containerInspect.HostConfig.LogConfig.Type}
{/if} {#if containerInspect.Config?.StopSignal}
Stop signal
-
{containerInspect.Config.StopSignal}
+
{containerInspect.Config.StopSignal}
{/if} {#if containerInspect.Platform}
Platform
-
{containerInspect.Platform}
+
{containerInspect.Platform}
{/if}