diff --git a/integration/docker_test.go b/integration/docker_test.go index 3a5e662..6b7f0de 100644 --- a/integration/docker_test.go +++ b/integration/docker_test.go @@ -87,24 +87,6 @@ func TestRestoreState(t *testing.T) { assert.Equal(t, app.Settings.Image, restoredApp.Settings.Image) } -func TestApplicationVolume(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - ns, err := docker.NewNamespace("once-volume-label-test") - require.NoError(t, err) - - vol1, err := docker.CreateVolume(ctx, ns, "testapp", docker.ApplicationVolumeSettings{SecretKeyBase: "test-secret"}) - require.NoError(t, err) - assert.Equal(t, "test-secret", vol1.SecretKeyBase()) - - vol2, err := docker.FindVolume(ctx, ns, "testapp") - require.NoError(t, err) - assert.Equal(t, vol1.SecretKeyBase(), vol2.SecretKeyBase()) - - require.NoError(t, vol1.Destroy(ctx)) -} - func TestGaplessDeployment(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() @@ -122,9 +104,12 @@ func TestGaplessDeployment(t *testing.T) { Host: "gapless.localhost", }) + firstSecretKeyBase := app.Settings.Keys.SecretKeyBase + require.NotEmpty(t, firstSecretKeyBase) + vol, err := app.Volume(ctx) require.NoError(t, err) - firstSecretKeyBase := vol.SecretKeyBase() + assert.Equal(t, firstSecretKeyBase, vol.Keys.SecretKeyBase, "the keys should be copied onto the label of a new volume") firstName, err := app.ContainerName(ctx) require.NoError(t, err) @@ -137,9 +122,7 @@ func TestGaplessDeployment(t *testing.T) { countAfter := countContainers(t, ctx, containerPrefix) assert.Equal(t, countBefore, countAfter, "container count should not change") - vol2, err := app.Volume(ctx) - require.NoError(t, err) - assert.Equal(t, firstSecretKeyBase, vol2.SecretKeyBase(), "SecretKeyBase should persist across deploys") + assert.Equal(t, firstSecretKeyBase, app.Settings.Keys.SecretKeyBase, "SecretKeyBase should persist across deploys") secondName, err := app.ContainerName(ctx) require.NoError(t, err) @@ -147,6 +130,150 @@ func TestGaplessDeployment(t *testing.T) { require.NoError(t, ns.Refresh(ctx)) assert.Len(t, ns.Applications(), 1, "should have exactly one application after redeploy and refresh") + assert.Equal(t, firstSecretKeyBase, ns.Applications()[0].Settings.Keys.SecretKeyBase, "SecretKeyBase should round-trip through the container label") +} + +func TestResetSecrets(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + ns, err := docker.NewNamespace("once-reset-secrets-test") + require.NoError(t, err) + defer ns.Teardown(ctx, true) + + require.NoError(t, ns.EnsureNetwork(ctx)) + require.NoError(t, ns.Proxy().Boot(ctx, getProxyPorts(t))) + + app := deployApp(t, ctx, ns, docker.ApplicationSettings{ + Name: "resetapp", + Image: "ghcr.io/basecamp/once-campfire:main", + Host: "resetapp.localhost", + }) + + containerName, err := app.ContainerName(ctx) + require.NoError(t, err) + execInContainer(t, ctx, containerName, []string{ + "sh", "-c", "echo 'sentinel' > /rails/storage/reset-test.txt", + }) + + oldKeys := app.Settings.Keys + require.NotEmpty(t, oldKeys.SecretKeyBase) + + containerPrefix := "once-reset-secrets-test-app-resetapp-" + countBefore := countContainers(t, ctx, containerPrefix) + + require.NoError(t, app.ResetSecrets(ctx, docker.SecretsReset{GenerateSecretKeyBase: true}, nil)) + + assert.Equal(t, countBefore, countContainers(t, ctx, containerPrefix), "container count should not change") + + assert.NotEqual(t, oldKeys.SecretKeyBase, app.Settings.Keys.SecretKeyBase, "SecretKeyBase should change") + assert.Equal(t, oldKeys.VAPIDPublicKey, app.Settings.Keys.VAPIDPublicKey, "VAPID keys should be preserved") + assert.Equal(t, oldKeys.VAPIDPrivateKey, app.Settings.Keys.VAPIDPrivateKey, "VAPID keys should be preserved") + + newContainerName, err := app.ContainerName(ctx) + require.NoError(t, err) + envVars := inspectContainerEnv(t, ctx, newContainerName) + assert.Contains(t, envVars, "SECRET_KEY_BASE="+app.Settings.Keys.SecretKeyBase) + + execInContainer(t, ctx, newContainerName, []string{ + "test", "-f", "/rails/storage/reset-test.txt", + }) + + require.NoError(t, ns.Refresh(ctx)) + refreshed := ns.ApplicationByHost("resetapp.localhost") + require.NotNil(t, refreshed) + assert.Equal(t, app.Settings.Keys, refreshed.Settings.Keys, "rotated keys should persist in the container label") + + currentSecretKeyBase := app.Settings.Keys.SecretKeyBase + require.NoError(t, app.ResetSecrets(ctx, docker.SecretsReset{GenerateVAPID: true}, nil)) + + assert.Equal(t, currentSecretKeyBase, app.Settings.Keys.SecretKeyBase, "SecretKeyBase should be preserved") + assert.NotEqual(t, oldKeys.VAPIDPublicKey, app.Settings.Keys.VAPIDPublicKey, "VAPID keys should change") + assert.NotEqual(t, oldKeys.VAPIDPrivateKey, app.Settings.Keys.VAPIDPrivateKey, "VAPID keys should change") + + vapidContainerName, err := app.ContainerName(ctx) + require.NoError(t, err) + envVars = inspectContainerEnv(t, ctx, vapidContainerName) + assert.Contains(t, envVars, "VAPID_PUBLIC_KEY="+app.Settings.Keys.VAPIDPublicKey) +} + +func TestResetSecretsWithProvidedValues(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + ns, err := docker.NewNamespace("once-provided-secrets-test") + require.NoError(t, err) + defer ns.Teardown(ctx, true) + + require.NoError(t, ns.EnsureNetwork(ctx)) + require.NoError(t, ns.Proxy().Boot(ctx, getProxyPorts(t))) + + app := deployApp(t, ctx, ns, docker.ApplicationSettings{ + Name: "providedapp", + Image: "ghcr.io/basecamp/once-campfire:main", + Host: "providedapp.localhost", + }) + + provided := docker.GenerateKeys() + require.NoError(t, app.ResetSecrets(ctx, docker.SecretsReset{ + SecretKeyBase: "provided-secret-key-base", + VAPIDPrivateKey: provided.VAPIDPrivateKey, + }, nil)) + + assert.Equal(t, "provided-secret-key-base", app.Settings.Keys.SecretKeyBase) + assert.Equal(t, provided.VAPIDPrivateKey, app.Settings.Keys.VAPIDPrivateKey) + assert.Equal(t, provided.VAPIDPublicKey, app.Settings.Keys.VAPIDPublicKey, "the public key should be derived from the provided private key") + + containerName, err := app.ContainerName(ctx) + require.NoError(t, err) + envVars := inspectContainerEnv(t, ctx, containerName) + assert.Contains(t, envVars, "SECRET_KEY_BASE=provided-secret-key-base") + assert.Contains(t, envVars, "VAPID_PUBLIC_KEY="+provided.VAPIDPublicKey) + assert.Contains(t, envVars, "VAPID_PRIVATE_KEY="+provided.VAPIDPrivateKey) + + require.NoError(t, ns.Refresh(ctx)) + refreshed := ns.ApplicationByHost("providedapp.localhost") + require.NotNil(t, refreshed) + assert.Equal(t, app.Settings.Keys, refreshed.Settings.Keys, "provided keys should persist in the container label") + + err = app.ResetSecrets(ctx, docker.SecretsReset{VAPIDPrivateKey: "nonsense"}, nil) + assert.ErrorIs(t, err, docker.ErrInvalidVAPIDPrivateKey) + assert.Equal(t, provided.VAPIDPrivateKey, app.Settings.Keys.VAPIDPrivateKey, "an invalid request should leave the keys alone") +} + +func TestDeployKeyMigration(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + ns, err := docker.NewNamespace("once-key-migration-test") + require.NoError(t, err) + defer ns.Teardown(ctx, true) + + require.NoError(t, ns.EnsureNetwork(ctx)) + require.NoError(t, ns.Proxy().Boot(ctx, getProxyPorts(t))) + + // A volume with keys on its label, as older versions created + legacyKeys := docker.GenerateKeys().WithSecretKeyBase("legacy-secret") + _, err = docker.CreateVolume(ctx, ns, "legacyapp", legacyKeys) + require.NoError(t, err) + + app := deployApp(t, ctx, ns, docker.ApplicationSettings{ + Name: "legacyapp", + Image: "ghcr.io/basecamp/once-campfire:main", + Host: "legacyapp.localhost", + }) + assert.Equal(t, legacyKeys, app.Settings.Keys, "keys should be adopted from the volume label") + + containerName, err := app.ContainerName(ctx) + require.NoError(t, err) + envVars := inspectContainerEnv(t, ctx, containerName) + assert.Contains(t, envVars, "SECRET_KEY_BASE=legacy-secret") + + require.NoError(t, app.ResetSecrets(ctx, docker.SecretsReset{GenerateSecretKeyBase: true}, nil)) + require.NoError(t, ns.Refresh(ctx)) + refreshed := ns.ApplicationByHost("legacyapp.localhost") + require.NotNil(t, refreshed) + assert.NotEqual(t, "legacy-secret", refreshed.Settings.Keys.SecretKeyBase, "settings keys should take precedence over the volume label") } func TestUpdateDetectsLocalImageChange(t *testing.T) { @@ -391,9 +518,10 @@ func TestBackup(t *testing.T) { assert.Equal(t, imageName, appSettings.Image) assert.Contains(t, entries, "once.volume.json") - var volSettings docker.ApplicationVolumeSettings - require.NoError(t, json.Unmarshal(entries["once.volume.json"], &volSettings)) - assert.NotEmpty(t, volSettings.SecretKeyBase) + var volKeys docker.KeysSettings + require.NoError(t, json.Unmarshal(entries["once.volume.json"], &volKeys)) + assert.NotEmpty(t, volKeys.SecretKeyBase) + assert.Equal(t, appSettings.Keys.SecretKeyBase, volKeys.SecretKeyBase, "keys should be duplicated into the volume settings entry") assert.Contains(t, entries, "data/testfile.txt") assert.Equal(t, "test content\n", string(entries["data/testfile.txt"])) @@ -425,9 +553,8 @@ func TestRestore(t *testing.T) { "sh", "-c", "echo 'restore test data' > /rails/storage/restore-test.txt", }) - vol, err := app.Volume(ctx) - require.NoError(t, err) - originalSecretKeyBase := vol.SecretKeyBase() + originalSecretKeyBase := app.Settings.Keys.SecretKeyBase + require.NotEmpty(t, originalSecretKeyBase) backupDir := t.TempDir() require.NoError(t, app.BackupToFile(ctx, backupDir, "backup.tar.gz")) @@ -457,10 +584,12 @@ func TestRestore(t *testing.T) { assert.NotNil(t, ns2.Application(restoredApp.Settings.Name), "app should be in namespace immediately after Restore") assert.True(t, ns2.HostInUse("restore.localhost"), "hostname should be in use after Restore") - // Verify volume settings (SecretKeyBase) were preserved + // Verify the keys were preserved + assert.Equal(t, originalSecretKeyBase, restoredApp.Settings.Keys.SecretKeyBase) + restoredVol, err := restoredApp.Volume(ctx) require.NoError(t, err) - assert.Equal(t, originalSecretKeyBase, restoredVol.SecretKeyBase()) + assert.Equal(t, originalSecretKeyBase, restoredVol.Keys.SecretKeyBase, "the keys should be copied onto the restored volume label") // Verify data was restored restoredContainerName, err := restoredApp.ContainerName(ctx) @@ -480,9 +609,7 @@ func TestRestore(t *testing.T) { assert.Equal(t, imageName, restoredAppFromState.Settings.Image) assert.Equal(t, "restore.localhost", restoredAppFromState.Settings.Host) - volFromState, err := restoredAppFromState.Volume(ctx) - require.NoError(t, err) - assert.Equal(t, originalSecretKeyBase, volFromState.SecretKeyBase(), "volume SecretKeyBase should be preserved") + assert.Equal(t, originalSecretKeyBase, restoredAppFromState.Settings.Keys.SecretKeyBase, "SecretKeyBase should be preserved") } func TestRestoreHostnameConflictFails(t *testing.T) { @@ -825,9 +952,8 @@ func TestUpdatePreservesSettings(t *testing.T) { Resources: docker.ContainerResources{CPUs: 2, MemoryMB: 1024}, }) - vol, err := app.Volume(ctx) - require.NoError(t, err) - originalSecretKeyBase := vol.SecretKeyBase() + originalSecretKeyBase := app.Settings.Keys.SecretKeyBase + require.NotEmpty(t, originalSecretKeyBase) // Update only the env vars, leaving everything else as-is newSettings := app.Settings @@ -855,10 +981,8 @@ func TestUpdatePreservesSettings(t *testing.T) { assert.Contains(t, envVars, "NEW_VAR=new_value") assertEnvAbsent(t, envVars, "MY_VAR") - // Volume preserved - vol2, err := updatedApp.Volume(ctx) - require.NoError(t, err) - assert.Equal(t, originalSecretKeyBase, vol2.SecretKeyBase()) + // Keys preserved + assert.Equal(t, originalSecretKeyBase, updatedApp.Settings.Keys.SecretKeyBase) } func TestUpdateChangeHost(t *testing.T) { @@ -1216,7 +1340,7 @@ func buildTestBackup(t *testing.T, imageName string) []byte { Image: imageName, Host: "hookapp.localhost", } - volSettings := docker.ApplicationVolumeSettings{SecretKeyBase: "test-secret-key"} + volKeys := docker.KeysSettings{SecretKeyBase: "test-secret-key"} var buf bytes.Buffer gw := gzip.NewWriter(&buf) @@ -1230,7 +1354,7 @@ func buildTestBackup(t *testing.T, imageName string) []byte { } writeEntry("once.application.json", []byte(appSettings.Marshal())) - writeEntry("once.volume.json", []byte(volSettings.Marshal())) + writeEntry("once.volume.json", []byte(volKeys.Marshal())) // Add data directory with a marker file for hook testing. // Use UID/GID 1000 to match realistic backup ownership. diff --git a/internal/command/cli_progress.go b/internal/command/cli_progress.go index 5ea8373..38506ac 100644 --- a/internal/command/cli_progress.go +++ b/internal/command/cli_progress.go @@ -2,7 +2,6 @@ package command import ( "fmt" - "os" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -40,7 +39,7 @@ func newCLIProgress(label string, task func(docker.DeployProgressCallback) error func runWithProgress(label string, task func(docker.DeployProgressCallback) error) error { var err error - if isTerminal() { + if stdoutIsTerminal() { p := newCLIProgress(label, task) if _, runErr := tea.NewProgram(p).Run(); runErr != nil { return runErr @@ -127,13 +126,3 @@ func (m *cliProgress) waitForProgress() tea.Cmd { return cliProgressUpdateMsg{p: p} } } - -// Helpers - -func isTerminal() bool { - fi, err := os.Stdout.Stat() - if err != nil { - return false - } - return fi.Mode()&os.ModeCharDevice != 0 -} diff --git a/internal/command/reset_secrets.go b/internal/command/reset_secrets.go new file mode 100644 index 0000000..64f9c7f --- /dev/null +++ b/internal/command/reset_secrets.go @@ -0,0 +1,110 @@ +package command + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/basecamp/once/internal/docker" +) + +type resetSecretsCommand struct { + cmd *cobra.Command + generateSecretKeyBase bool + secretKeyBase string + generateVAPID bool + vapidPrivateKey string + yes bool +} + +func newResetSecretsCommand() *resetSecretsCommand { + r := &resetSecretsCommand{} + r.cmd = &cobra.Command{ + Use: "reset-secrets ", + Short: "Replace secrets and redeploy the application", + Args: cobra.ExactArgs(1), + RunE: WithNamespace(r.run), + } + r.cmd.Flags().BoolVar(&r.generateSecretKeyBase, "generate-secret-key-base", false, "generate a new SECRET_KEY_BASE") + r.cmd.Flags().StringVar(&r.secretKeyBase, "secret-key-base", "", "set SECRET_KEY_BASE to a specific value") + r.cmd.Flags().BoolVar(&r.generateVAPID, "generate-vapid", false, "generate a new VAPID key pair") + r.cmd.Flags().StringVar(&r.vapidPrivateKey, "vapid-private-key", "", "set the VAPID key pair from an existing private key") + r.cmd.Flags().BoolVarP(&r.yes, "yes", "y", false, "skip the confirmation prompt") + r.cmd.MarkFlagsOneRequired("generate-secret-key-base", "secret-key-base", "generate-vapid", "vapid-private-key") + r.cmd.MarkFlagsMutuallyExclusive("generate-secret-key-base", "secret-key-base") + r.cmd.MarkFlagsMutuallyExclusive("generate-vapid", "vapid-private-key") + return r +} + +// Private + +func (r *resetSecretsCommand) run(ctx context.Context, ns *docker.Namespace, cmd *cobra.Command, args []string) error { + host := args[0] + + app := ns.ApplicationByHost(host) + if app == nil { + return fmt.Errorf("no application found at host %q", host) + } + if !app.Running { + return docker.ErrApplicationNotRunning + } + + reset := docker.SecretsReset{ + GenerateSecretKeyBase: r.generateSecretKeyBase, + SecretKeyBase: r.secretKeyBase, + GenerateVAPID: r.generateVAPID, + VAPIDPrivateKey: r.vapidPrivateKey, + } + if err := reset.Validate(); err != nil { + return err + } + + if !r.yes { + confirmed, err := confirmResetSecrets() + if err != nil { + return err + } + if !confirmed { + fmt.Println("Aborted") + return nil + } + } + + if err := ns.Setup(ctx); err != nil { + return fmt.Errorf("%w: %w", docker.ErrSetupFailed, err) + } + + return runWithProgress("Resetting secrets for "+host, func(progress docker.DeployProgressCallback) error { + return app.ResetSecrets(ctx, reset, progress) + }) +} + +// Helpers + +func confirmResetSecrets() (bool, error) { + if !stdinIsTerminal() { + return false, errors.New("refusing to reset secrets without confirmation; pass --yes to proceed") + } + + fmt.Print(`WARNING: changing secrets can invalidate existing user sessions, subscriptions +and encrypted content. Consider taking a backup first. + +Continue? [y/N] `) + return readConfirmation(os.Stdin) +} + +func readConfirmation(r io.Reader) (bool, error) { + line, err := bufio.NewReader(r).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return false, err + } + + answer := strings.ToLower(strings.TrimSpace(line)) + return answer == "y" || answer == "yes", nil +} diff --git a/internal/command/reset_secrets_test.go b/internal/command/reset_secrets_test.go new file mode 100644 index 0000000..728f3ca --- /dev/null +++ b/internal/command/reset_secrets_test.go @@ -0,0 +1,28 @@ +package command + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadConfirmation(t *testing.T) { + confirm := func(input string) bool { + confirmed, err := readConfirmation(strings.NewReader(input)) + require.NoError(t, err) + return confirmed + } + + assert.True(t, confirm("y\n")) + assert.True(t, confirm("Y\n")) + assert.True(t, confirm("yes\n")) + assert.True(t, confirm(" yes \n")) + + assert.False(t, confirm("n\n")) + assert.False(t, confirm("no\n")) + assert.False(t, confirm("\n")) + assert.False(t, confirm("")) + assert.False(t, confirm("nonsense\n")) +} diff --git a/internal/command/root.go b/internal/command/root.go index b9a0326..89e8762 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "os" + "github.com/charmbracelet/x/term" "github.com/spf13/cobra" "github.com/basecamp/once/internal/docker" @@ -44,6 +46,7 @@ func NewRootCommand() *RootCommand { r.cmd.AddCommand(newExecCommand().cmd) r.cmd.AddCommand(newListCommand().cmd) r.cmd.AddCommand(newRemoveCommand().cmd) + r.cmd.AddCommand(newResetSecretsCommand().cmd) r.cmd.AddCommand(newRestoreCommand().cmd) r.cmd.AddCommand(newStartCommand().cmd) r.cmd.AddCommand(newStopCommand().cmd) @@ -94,6 +97,14 @@ func namespaceFlag(cmd *cobra.Command) string { return namespace } +func stdinIsTerminal() bool { + return term.IsTerminal(os.Stdin.Fd()) +} + +func stdoutIsTerminal() bool { + return term.IsTerminal(os.Stdout.Fd()) +} + type exitCoder interface { error ExitCode() int diff --git a/internal/docker/application.go b/internal/docker/application.go index 900a06e..058274f 100644 --- a/internal/docker/application.go +++ b/internal/docker/application.go @@ -86,25 +86,18 @@ func (a *Application) ContainerName(ctx context.Context) (string, error) { func (a *Application) Volume(ctx context.Context) (*ApplicationVolume, error) { vol, err := FindVolume(ctx, a.namespace, a.Settings.Name) if err == nil { + a.ensureKeys(vol.Keys) return vol, nil } if !errors.Is(err, ErrVolumeNotFound) { return nil, err } - skb, err := generateSecretKeyBase() - if err != nil { - return nil, fmt.Errorf("generating secret key base: %w", err) - } - vapidPub, vapidPriv, err := generateVAPIDKeyPair() - if err != nil { - return nil, fmt.Errorf("generating VAPID key pair: %w", err) - } - return CreateVolume(ctx, a.namespace, a.Settings.Name, ApplicationVolumeSettings{ - SecretKeyBase: skb, - VAPIDPublicKey: vapidPub, - VAPIDPrivateKey: vapidPriv, - }) + a.ensureKeys(KeysSettings{}) + + // The keys go onto the volume label too, where older versions of Once expect + // to find them + return CreateVolume(ctx, a.namespace, a.Settings.Name, a.Settings.Keys) } func (a *Application) URL() string { @@ -216,6 +209,32 @@ func (a *Application) Deploy(ctx context.Context, progress DeployProgressCallbac return a.deployWithVolume(ctx, vol, progress) } +func (a *Application) ResetSecrets(ctx context.Context, reset SecretsReset, progress DeployProgressCallback) error { + if err := reset.Validate(); err != nil { + return err + } + + vol, err := a.Volume(ctx) + if err != nil { + return fmt.Errorf("getting volume: %w", err) + } + + keys, err := a.Settings.Keys.WithReset(reset) + if err != nil { + return err + } + + oldSettings := a.Settings + a.Settings.Keys = keys + + if err := a.deployWithVolume(ctx, vol, progress); err != nil { + a.Settings = oldSettings + return err + } + + return nil +} + func (a *Application) VerifyHTTPOrRemove(ctx context.Context) error { if err := a.verifyHTTP(ctx); err != nil { if cleanupErr := a.Remove(context.Background(), true); cleanupErr != nil { @@ -318,6 +337,24 @@ func (a *Application) runningImageID(ctx context.Context) string { return info.Image } +func (a *Application) ensureKeysFromVolume(ctx context.Context) error { + _, err := a.Volume(ctx) + return err +} + +func (a *Application) ensureKeys(fromVolume KeysSettings) { + if a.Settings.Keys.Present() { + return + } + + if fromVolume.Present() { + a.Settings.Keys = fromVolume + return + } + + a.Settings.Keys = GenerateKeys() +} + func (a *Application) deployWithVolume(ctx context.Context, vol *ApplicationVolume, progress DeployProgressCallback) error { if progress != nil { progress(DeployProgress{Stage: DeployStageStarting}) @@ -330,7 +367,7 @@ func (a *Application) deployWithVolume(ctx context.Context, vol *ApplicationVolu containerName := fmt.Sprintf("%s-app-%s-%s", a.namespace.name, a.Settings.Name, id) - env := a.Settings.BuildEnv(vol.Settings) + env := a.Settings.BuildEnv() hostConfig := &container.HostConfig{ RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyAlways}, diff --git a/internal/docker/application_backup.go b/internal/docker/application_backup.go index 474c0a0..c3dce15 100644 --- a/internal/docker/application_backup.go +++ b/internal/docker/application_backup.go @@ -111,7 +111,7 @@ func (a *Application) TrimBackups() error { return errors.Join(errs...) } -func (a *Application) Restore(ctx context.Context, volSettings ApplicationVolumeSettings, volumeData []byte) (returnErr error) { +func (a *Application) Restore(ctx context.Context, volumeData []byte) (returnErr error) { slog.Info("Restoring application", "app", a.Settings.Name) defer func() { @@ -126,7 +126,8 @@ func (a *Application) Restore(ctx context.Context, volSettings ApplicationVolume return err } - vol, err := CreateVolume(ctx, a.namespace, a.Settings.Name, volSettings) + a.ensureKeys(KeysSettings{}) + vol, err := CreateVolume(ctx, a.namespace, a.Settings.Name, a.Settings.Keys) if err != nil { return fmt.Errorf("creating volume: %w", err) } @@ -162,8 +163,7 @@ func (a *Application) backupToWriter(ctx context.Context, w io.Writer) error { return fmt.Errorf("checking for pre-backup hook: %w", hookErr) } - vol, err := a.Volume(ctx) - if err != nil { + if err := a.ensureKeysFromVolume(ctx); err != nil { return fmt.Errorf("getting volume: %w", err) } @@ -176,7 +176,7 @@ func (a *Application) backupToWriter(ctx context.Context, w io.Writer) error { return fmt.Errorf("writing application settings: %w", err) } - if err := writeTarEntry(tw, backupVolSettingsEntry, []byte(vol.Settings.Marshal())); err != nil { + if err := writeTarEntry(tw, backupVolSettingsEntry, []byte(a.Settings.Keys.Marshal())); err != nil { return fmt.Errorf("writing volume settings: %w", err) } @@ -272,7 +272,7 @@ func (a *Application) runRestoreHook(ctx context.Context, vol *ApplicationVolume Image: a.Settings.Image, Entrypoint: []string{}, Cmd: []string{"sleep", "infinity"}, - Env: a.Settings.BuildEnv(vol.Settings), + Env: a.Settings.BuildEnv(), }, &container.HostConfig{Mounts: a.volumeMounts(vol)}, nil, nil, containerName, diff --git a/internal/docker/application_backup_test.go b/internal/docker/application_backup_test.go index 4d3e224..d9ac19b 100644 --- a/internal/docker/application_backup_test.go +++ b/internal/docker/application_backup_test.go @@ -143,25 +143,53 @@ func TestCopyTarEntriesWithPrefix(t *testing.T) { } func TestWriteTarEntryAndCopyRoundTrip(t *testing.T) { - // Build a complete backup-like tar - var backupBuf bytes.Buffer - gw := gzip.NewWriter(&backupBuf) + backup := buildBackup(t, `{"name":"app"}`, `{"secretKeyBase":"secret","vapidPublicKey":"pub123","vapidPrivateKey":"priv456"}`) + + ns := &Namespace{name: "test"} + appSettings, volumeData, err := ns.parseBackup(backup) + require.NoError(t, err) + assert.Equal(t, "app", appSettings.Name) + assert.NotEmpty(t, volumeData) +} + +func TestParseBackupAdoptsLegacyKeys(t *testing.T) { + backup := buildBackup(t, `{"name":"app"}`, `{"secretKeyBase":"secret","vapidPublicKey":"pub123","vapidPrivateKey":"priv456"}`) + + ns := &Namespace{name: "test"} + appSettings, _, err := ns.parseBackup(backup) + require.NoError(t, err) + + expected := KeysSettings{ + SecretKeyBase: "secret", + VAPIDPublicKey: "pub123", + VAPIDPrivateKey: "priv456", + } + assert.Equal(t, expected, appSettings.Keys) +} + +func TestParseBackupPrefersApplicationKeys(t *testing.T) { + backup := buildBackup(t, `{"name":"app","keys":{"secretKeyBase":"new-secret"}}`, `{"secretKeyBase":"old-secret"}`) + + ns := &Namespace{name: "test"} + appSettings, _, err := ns.parseBackup(backup) + require.NoError(t, err) + + assert.Equal(t, "new-secret", appSettings.Keys.SecretKeyBase) +} + +func buildBackup(t *testing.T, appSettings, volSettings string) *bytes.Buffer { + t.Helper() + + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) tw := tar.NewWriter(gw) - require.NoError(t, writeTarEntry(tw, backupAppSettingsEntry, []byte(`{"name":"app"}`))) - require.NoError(t, writeTarEntry(tw, backupVolSettingsEntry, []byte(`{"secretKeyBase":"secret","vapidPublicKey":"pub123","vapidPrivateKey":"priv456"}`))) + require.NoError(t, writeTarEntry(tw, backupAppSettingsEntry, []byte(appSettings))) + require.NoError(t, writeTarEntry(tw, backupVolSettingsEntry, []byte(volSettings))) require.NoError(t, writeTarEntry(tw, "data/file.txt", []byte("file content"))) require.NoError(t, tw.Close()) require.NoError(t, gw.Close()) - // Parse the backup - ns := &Namespace{name: "test"} - appSettings, volSettings, volumeData, err := ns.parseBackup(&backupBuf) - require.NoError(t, err) - assert.Equal(t, "app", appSettings.Name) - assert.Equal(t, "secret", volSettings.SecretKeyBase) - assert.Equal(t, "pub123", volSettings.VAPIDPublicKey) - assert.Equal(t, "priv456", volSettings.VAPIDPrivateKey) - assert.NotEmpty(t, volumeData) + return &buf } diff --git a/internal/docker/application_settings.go b/internal/docker/application_settings.go index 25d2f8e..018bdf7 100644 --- a/internal/docker/application_settings.go +++ b/internal/docker/application_settings.go @@ -42,6 +42,7 @@ type ApplicationSettings struct { Host string `json:"host"` DisableTLS bool `json:"disableTLS"` EnvVars map[string]string `json:"env"` + Keys KeysSettings `json:"keys"` SMTP SMTPSettings `json:"smtp"` Resources ContainerResources `json:"resources"` AutoUpdate bool `json:"autoUpdate"` @@ -80,6 +81,9 @@ func (s ApplicationSettings) Equal(other ApplicationSettings) bool { if s.Resources != other.Resources { return false } + if s.Keys != other.Keys { + return false + } if s.SMTP != other.SMTP { return false } @@ -100,11 +104,11 @@ func (s ApplicationSettings) Equal(other ApplicationSettings) bool { return true } -func (s ApplicationSettings) BuildEnv(vol ApplicationVolumeSettings) []string { +func (s ApplicationSettings) BuildEnv() []string { env := []string{ - "SECRET_KEY_BASE=" + vol.SecretKeyBase, - "VAPID_PUBLIC_KEY=" + vol.VAPIDPublicKey, - "VAPID_PRIVATE_KEY=" + vol.VAPIDPrivateKey, + "SECRET_KEY_BASE=" + s.Keys.SecretKeyBase, + "VAPID_PUBLIC_KEY=" + s.Keys.VAPIDPublicKey, + "VAPID_PRIVATE_KEY=" + s.Keys.VAPIDPrivateKey, } if !s.TLSEnabled() { diff --git a/internal/docker/application_settings_test.go b/internal/docker/application_settings_test.go index 72f677d..376b74c 100644 --- a/internal/docker/application_settings_test.go +++ b/internal/docker/application_settings_test.go @@ -18,7 +18,7 @@ func TestBuildEnvWithSMTP(t *testing.T) { }, } - env := settings.BuildEnv(ApplicationVolumeSettings{SecretKeyBase: "test-secret-key"}) + env := settings.BuildEnv() assert.Contains(t, env, "SMTP_ADDRESS=smtp.example.com") assert.Contains(t, env, "SMTP_PORT=587") @@ -30,7 +30,7 @@ func TestBuildEnvWithSMTP(t *testing.T) { func TestBuildEnvWithCPULimit(t *testing.T) { settings := ApplicationSettings{Resources: ContainerResources{CPUs: 4}} - env := settings.BuildEnv(ApplicationVolumeSettings{SecretKeyBase: "test-secret-key"}) + env := settings.BuildEnv() assert.Contains(t, env, "NUM_CPUS=4") } @@ -38,7 +38,7 @@ func TestBuildEnvWithCPULimit(t *testing.T) { func TestBuildEnvWithoutCPULimit(t *testing.T) { settings := ApplicationSettings{} - env := settings.BuildEnv(ApplicationVolumeSettings{SecretKeyBase: "test-secret-key"}) + env := settings.BuildEnv() assert.NotContains(t, env, "NUM_CPUS=0") } @@ -46,7 +46,7 @@ func TestBuildEnvWithoutCPULimit(t *testing.T) { func TestBuildEnvWithoutSMTP(t *testing.T) { settings := ApplicationSettings{} - env := settings.BuildEnv(ApplicationVolumeSettings{SecretKeyBase: "test-secret-key"}) + env := settings.BuildEnv() for _, e := range env { assert.NotContains(t, e, "SMTP_") @@ -98,20 +98,48 @@ func TestBackupSettingsEqualDiffers(t *testing.T) { assert.False(t, base.Equal(noBackup)) } -func TestBuildEnvWithVAPIDKeys(t *testing.T) { - settings := ApplicationSettings{} - - vol := ApplicationVolumeSettings{ - SecretKeyBase: "test-secret-key", - VAPIDPublicKey: "test-vapid-public", - VAPIDPrivateKey: "test-vapid-private", +func TestBuildEnvWithKeys(t *testing.T) { + settings := ApplicationSettings{ + Keys: KeysSettings{ + SecretKeyBase: "test-secret-key", + VAPIDPublicKey: "test-vapid-public", + VAPIDPrivateKey: "test-vapid-private", + }, } - env := settings.BuildEnv(vol) + env := settings.BuildEnv() + + assert.Contains(t, env, "SECRET_KEY_BASE=test-secret-key") assert.Contains(t, env, "VAPID_PUBLIC_KEY=test-vapid-public") assert.Contains(t, env, "VAPID_PRIVATE_KEY=test-vapid-private") } +func TestKeysEqualDiffers(t *testing.T) { + base := ApplicationSettings{Name: "app", Keys: KeysSettings{SecretKeyBase: "secret1"}} + + different := ApplicationSettings{Name: "app", Keys: KeysSettings{SecretKeyBase: "secret2"}} + assert.False(t, base.Equal(different)) + + none := ApplicationSettings{Name: "app"} + assert.False(t, base.Equal(none)) +} + +func TestKeysMarshalRoundTrip(t *testing.T) { + original := ApplicationSettings{ + Name: "app", + Image: "img:latest", + Keys: KeysSettings{ + SecretKeyBase: "secret", + VAPIDPublicKey: "pub123", + VAPIDPrivateKey: "priv456", + }, + } + restored, err := UnmarshalApplicationSettings(original.Marshal()) + require.NoError(t, err) + assert.Equal(t, original.Keys, restored.Keys) + assert.True(t, original.Equal(restored)) +} + func TestBuildEnvWithEnvVars(t *testing.T) { settings := ApplicationSettings{ EnvVars: map[string]string{ @@ -120,7 +148,7 @@ func TestBuildEnvWithEnvVars(t *testing.T) { }, } - env := settings.BuildEnv(ApplicationVolumeSettings{SecretKeyBase: "test-secret-key"}) + env := settings.BuildEnv() assert.Contains(t, env, "DB_HOST=postgres.local") assert.Contains(t, env, "DB_NAME=mydb") diff --git a/internal/docker/application_test.go b/internal/docker/application_test.go index e580061..1212e31 100644 --- a/internal/docker/application_test.go +++ b/internal/docker/application_test.go @@ -135,3 +135,29 @@ func TestURL(t *testing.T) { assert.Equal(t, "http://chat.localhost:9090", app.URL()) }) } + +func TestEnsureKeys(t *testing.T) { + legacyKeys := GenerateKeys() + + t.Run("keeps the keys from the settings", func(t *testing.T) { + keys := GenerateKeys() + app := &Application{Settings: ApplicationSettings{Keys: keys}} + + app.ensureKeys(legacyKeys) + assert.Equal(t, keys, app.Settings.Keys) + }) + + t.Run("adopts the keys from the volume label", func(t *testing.T) { + app := &Application{} + + app.ensureKeys(legacyKeys) + assert.Equal(t, legacyKeys, app.Settings.Keys) + }) + + t.Run("generates keys when the volume label has none", func(t *testing.T) { + app := &Application{} + + app.ensureKeys(KeysSettings{}) + assert.True(t, app.Settings.Keys.Present()) + }) +} diff --git a/internal/docker/keys.go b/internal/docker/keys.go new file mode 100644 index 0000000..f1e7f57 --- /dev/null +++ b/internal/docker/keys.go @@ -0,0 +1,178 @@ +package docker + +import ( + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" +) + +var ( + ErrInvalidVAPIDPrivateKey = errors.New("invalid VAPID private key") + ErrNoSecretsToReset = errors.New("no secrets to reset") +) + +type KeysSettings struct { + SecretKeyBase string `json:"secretKeyBase,omitempty"` + VAPIDPublicKey string `json:"vapidPublicKey,omitempty"` + VAPIDPrivateKey string `json:"vapidPrivateKey,omitempty"` +} + +func UnmarshalKeysSettings(s string) (KeysSettings, error) { + var keys KeysSettings + err := json.Unmarshal([]byte(s), &keys) + return keys, err +} + +type SecretsReset struct { + GenerateSecretKeyBase bool + SecretKeyBase string + GenerateVAPID bool + VAPIDPrivateKey string +} + +func (r SecretsReset) ResetsSecretKeyBase() bool { + return r.GenerateSecretKeyBase || r.SecretKeyBase != "" +} + +func (r SecretsReset) ResetsVAPID() bool { + return r.GenerateVAPID || r.VAPIDPrivateKey != "" +} + +func (r SecretsReset) Validate() error { + if !r.ResetsSecretKeyBase() && !r.ResetsVAPID() { + return ErrNoSecretsToReset + } + if r.VAPIDPrivateKey != "" { + if _, _, err := vapidKeyPairFromPrivateKey(r.VAPIDPrivateKey); err != nil { + return err + } + } + return nil +} + +func GenerateKeys() KeysSettings { + vapidPub, vapidPriv := generateVAPIDKeyPair() + + return KeysSettings{ + SecretKeyBase: generateSecretKeyBase(), + VAPIDPublicKey: vapidPub, + VAPIDPrivateKey: vapidPriv, + } +} + +func (k KeysSettings) Marshal() string { + b, _ := json.Marshal(k) + return string(b) +} + +func (k KeysSettings) Present() bool { + return k.SecretKeyBase != "" +} + +func (k KeysSettings) WithRotatedSecretKeyBase() KeysSettings { + k.SecretKeyBase = generateSecretKeyBase() + return k +} + +func (k KeysSettings) WithRotatedVAPIDKeys() KeysSettings { + k.VAPIDPublicKey, k.VAPIDPrivateKey = generateVAPIDKeyPair() + return k +} + +func (k KeysSettings) WithSecretKeyBase(secretKeyBase string) KeysSettings { + k.SecretKeyBase = secretKeyBase + return k +} + +func (k KeysSettings) WithVAPIDKeysFromPrivateKey(privateKey string) (KeysSettings, error) { + pub, priv, err := vapidKeyPairFromPrivateKey(privateKey) + if err != nil { + return k, err + } + + k.VAPIDPublicKey, k.VAPIDPrivateKey = pub, priv + return k, nil +} + +func (k KeysSettings) WithReset(reset SecretsReset) (KeysSettings, error) { + switch { + case reset.SecretKeyBase != "": + k = k.WithSecretKeyBase(reset.SecretKeyBase) + case reset.GenerateSecretKeyBase: + k = k.WithRotatedSecretKeyBase() + } + + switch { + case reset.VAPIDPrivateKey != "": + var err error + if k, err = k.WithVAPIDKeysFromPrivateKey(reset.VAPIDPrivateKey); err != nil { + return k, err + } + case reset.GenerateVAPID: + k = k.WithRotatedVAPIDKeys() + } + + return k, nil +} + +// Helpers + +func generateSecretKeyBase() string { + bytes := make([]byte, 32) + _, _ = rand.Read(bytes) // never returns an error; it crashes the program on a randomness failure + return hex.EncodeToString(bytes) +} + +func generateVAPIDKeyPair() (publicKey, privateKey string) { + key, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + // Randomness failure; no fallback possible + panic(fmt.Errorf("generating VAPID key pair: %w", err)) + } + + privateKey = base64.RawURLEncoding.EncodeToString(key.Bytes()) + publicKey = base64.RawURLEncoding.EncodeToString(key.PublicKey().Bytes()) + + return publicKey, privateKey +} + +func vapidKeyPairFromPrivateKey(s string) (publicKey, privateKey string, err error) { + decoded, err := decodeVAPIDPrivateKey(s) + if err != nil { + return "", "", err + } + + key, err := ecdh.P256().NewPrivateKey(decoded) + if err != nil { + return "", "", fmt.Errorf("%w: %w", ErrInvalidVAPIDPrivateKey, err) + } + + privateKey = base64.RawURLEncoding.EncodeToString(key.Bytes()) + publicKey = base64.RawURLEncoding.EncodeToString(key.PublicKey().Bytes()) + + return publicKey, privateKey, nil +} + +// decodeVAPIDPrivateKey accepts any of the base64 variants a key might be pasted +// in as, so that padding or the standard alphabet isn't a stumbling block. Keys +// are always re-encoded as unpadded base64url. +func decodeVAPIDPrivateKey(s string) ([]byte, error) { + encodings := []*base64.Encoding{ + base64.RawURLEncoding, + base64.URLEncoding, + base64.RawStdEncoding, + base64.StdEncoding, + } + + for _, encoding := range encodings { + if decoded, err := encoding.DecodeString(s); err == nil { + return decoded, nil + } + } + + return nil, fmt.Errorf("%w: not valid base64", ErrInvalidVAPIDPrivateKey) +} diff --git a/internal/docker/keys_test.go b/internal/docker/keys_test.go new file mode 100644 index 0000000..f797400 --- /dev/null +++ b/internal/docker/keys_test.go @@ -0,0 +1,185 @@ +package docker + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateKeys(t *testing.T) { + keys := GenerateKeys() + + assert.True(t, keys.Present()) + assert.Len(t, keys.SecretKeyBase, 64) + assert.NotEmpty(t, keys.VAPIDPublicKey) + assert.NotEmpty(t, keys.VAPIDPrivateKey) + + assert.NotEqual(t, keys, GenerateKeys()) +} + +func TestWithRotatedSecretKeyBase(t *testing.T) { + keys := GenerateKeys() + + rotated := keys.WithRotatedSecretKeyBase() + + assert.Len(t, rotated.SecretKeyBase, 64) + assert.NotEqual(t, keys.SecretKeyBase, rotated.SecretKeyBase) + assert.Equal(t, keys.VAPIDPublicKey, rotated.VAPIDPublicKey) + assert.Equal(t, keys.VAPIDPrivateKey, rotated.VAPIDPrivateKey) + + assert.NotEqual(t, rotated.SecretKeyBase, rotated.WithRotatedSecretKeyBase().SecretKeyBase) +} + +func TestWithRotatedVAPIDKeys(t *testing.T) { + keys := GenerateKeys() + + rotated := keys.WithRotatedVAPIDKeys() + + assert.NotEqual(t, keys.VAPIDPublicKey, rotated.VAPIDPublicKey) + assert.NotEqual(t, keys.VAPIDPrivateKey, rotated.VAPIDPrivateKey) + assert.Equal(t, keys.SecretKeyBase, rotated.SecretKeyBase) +} + +func TestWithSecretKeyBase(t *testing.T) { + keys := GenerateKeys() + + updated := keys.WithSecretKeyBase("provided-secret") + + assert.Equal(t, "provided-secret", updated.SecretKeyBase) + assert.Equal(t, keys.VAPIDPublicKey, updated.VAPIDPublicKey) + assert.Equal(t, keys.VAPIDPrivateKey, updated.VAPIDPrivateKey) +} + +func TestWithVAPIDKeysFromPrivateKey(t *testing.T) { + original := GenerateKeys() + + updated, err := KeysSettings{SecretKeyBase: "secret"}.WithVAPIDKeysFromPrivateKey(original.VAPIDPrivateKey) + require.NoError(t, err) + + assert.Equal(t, original.VAPIDPrivateKey, updated.VAPIDPrivateKey) + assert.Equal(t, original.VAPIDPublicKey, updated.VAPIDPublicKey, "the public key should be derived from the private key") + assert.Equal(t, "secret", updated.SecretKeyBase) +} + +func TestWithVAPIDKeysFromPrivateKeyAcceptsOtherEncodings(t *testing.T) { + original := GenerateKeys() + raw, err := base64.RawURLEncoding.DecodeString(original.VAPIDPrivateKey) + require.NoError(t, err) + + fromEncoding := func(encoding *base64.Encoding) KeysSettings { + keys, err := KeysSettings{}.WithVAPIDKeysFromPrivateKey(encoding.EncodeToString(raw)) + require.NoError(t, err) + return keys + } + + for _, encoding := range []*base64.Encoding{base64.URLEncoding, base64.RawStdEncoding, base64.StdEncoding} { + keys := fromEncoding(encoding) + assert.Equal(t, original.VAPIDPrivateKey, keys.VAPIDPrivateKey, "keys should be re-encoded as unpadded base64url") + assert.Equal(t, original.VAPIDPublicKey, keys.VAPIDPublicKey) + } +} + +func TestWithVAPIDKeysFromInvalidPrivateKey(t *testing.T) { + assertInvalid := func(privateKey string) { + _, err := KeysSettings{}.WithVAPIDKeysFromPrivateKey(privateKey) + assert.ErrorIs(t, err, ErrInvalidVAPIDPrivateKey) + } + + assertInvalid("not base64 !!") + assertInvalid(base64.RawURLEncoding.EncodeToString([]byte("too short"))) + assertInvalid(base64.RawURLEncoding.EncodeToString(make([]byte, 32))) +} + +func TestWithReset(t *testing.T) { + original := GenerateKeys() + provided := GenerateKeys() + + reset := func(r SecretsReset) KeysSettings { + keys, err := original.WithReset(r) + require.NoError(t, err) + return keys + } + + generatedBase := reset(SecretsReset{GenerateSecretKeyBase: true}) + assert.NotEqual(t, original.SecretKeyBase, generatedBase.SecretKeyBase) + assert.Equal(t, original.VAPIDPrivateKey, generatedBase.VAPIDPrivateKey) + + providedBase := reset(SecretsReset{SecretKeyBase: "provided-secret"}) + assert.Equal(t, "provided-secret", providedBase.SecretKeyBase) + assert.Equal(t, original.VAPIDPrivateKey, providedBase.VAPIDPrivateKey) + + generatedVAPID := reset(SecretsReset{GenerateVAPID: true}) + assert.NotEqual(t, original.VAPIDPrivateKey, generatedVAPID.VAPIDPrivateKey) + assert.Equal(t, original.SecretKeyBase, generatedVAPID.SecretKeyBase) + + providedVAPID := reset(SecretsReset{VAPIDPrivateKey: provided.VAPIDPrivateKey}) + assert.Equal(t, provided.VAPIDPrivateKey, providedVAPID.VAPIDPrivateKey) + assert.Equal(t, provided.VAPIDPublicKey, providedVAPID.VAPIDPublicKey) + assert.Equal(t, original.SecretKeyBase, providedVAPID.SecretKeyBase) + + both := reset(SecretsReset{SecretKeyBase: "provided-secret", VAPIDPrivateKey: provided.VAPIDPrivateKey}) + assert.Equal(t, "provided-secret", both.SecretKeyBase) + assert.Equal(t, provided.VAPIDPrivateKey, both.VAPIDPrivateKey) + + assert.Equal(t, original, reset(SecretsReset{})) +} + +func TestWithResetInvalidVAPIDPrivateKey(t *testing.T) { + original := GenerateKeys() + + keys, err := original.WithReset(SecretsReset{VAPIDPrivateKey: "nonsense"}) + + assert.ErrorIs(t, err, ErrInvalidVAPIDPrivateKey) + assert.Equal(t, original, keys, "the keys should be left alone when the request is invalid") +} + +func TestSecretsResetValidate(t *testing.T) { + assert.ErrorIs(t, SecretsReset{}.Validate(), ErrNoSecretsToReset) + assert.ErrorIs(t, SecretsReset{SecretKeyBase: "", VAPIDPrivateKey: ""}.Validate(), ErrNoSecretsToReset) + assert.ErrorIs(t, SecretsReset{VAPIDPrivateKey: "nonsense"}.Validate(), ErrInvalidVAPIDPrivateKey) + + assert.NoError(t, SecretsReset{GenerateSecretKeyBase: true}.Validate()) + assert.NoError(t, SecretsReset{SecretKeyBase: "provided-secret"}.Validate()) + assert.NoError(t, SecretsReset{GenerateVAPID: true}.Validate()) + assert.NoError(t, SecretsReset{VAPIDPrivateKey: GenerateKeys().VAPIDPrivateKey}.Validate()) +} + +func TestSecretsResetRequests(t *testing.T) { + assert.False(t, SecretsReset{}.ResetsSecretKeyBase()) + assert.False(t, SecretsReset{}.ResetsVAPID()) + + assert.True(t, SecretsReset{GenerateSecretKeyBase: true}.ResetsSecretKeyBase()) + assert.True(t, SecretsReset{SecretKeyBase: "provided-secret"}.ResetsSecretKeyBase()) + assert.True(t, SecretsReset{GenerateVAPID: true}.ResetsVAPID()) + assert.True(t, SecretsReset{VAPIDPrivateKey: "priv"}.ResetsVAPID()) +} + +func TestKeysPresent(t *testing.T) { + assert.False(t, KeysSettings{}.Present()) + assert.False(t, KeysSettings{VAPIDPublicKey: "pub"}.Present()) + assert.True(t, KeysSettings{SecretKeyBase: "secret"}.Present()) +} + +func TestGenerateVAPIDKeyPair(t *testing.T) { + pub, priv := generateVAPIDKeyPair() + assert.NotEmpty(t, pub) + assert.NotEmpty(t, priv) + + pubBytes, err := base64.RawURLEncoding.DecodeString(pub) + require.NoError(t, err) + assert.Len(t, pubBytes, 65) + + privBytes, err := base64.RawURLEncoding.DecodeString(priv) + require.NoError(t, err) + assert.Len(t, privBytes, 32) +} + +func TestGenerateVAPIDKeyPairUniqueness(t *testing.T) { + pub1, priv1 := generateVAPIDKeyPair() + pub2, priv2 := generateVAPIDKeyPair() + + assert.NotEqual(t, pub1, pub2) + assert.NotEqual(t, priv1, priv2) +} diff --git a/internal/docker/namespace.go b/internal/docker/namespace.go index f75785c..fdf24eb 100644 --- a/internal/docker/namespace.go +++ b/internal/docker/namespace.go @@ -227,7 +227,7 @@ func (n *Namespace) SaveState(ctx context.Context, state *State) error { } func (n *Namespace) Restore(ctx context.Context, r io.Reader) (*Application, error) { - appSettings, volSettings, volumeData, err := n.parseBackup(r) + appSettings, volumeData, err := n.parseBackup(r) if err != nil { return nil, fmt.Errorf("parsing backup: %w", err) } @@ -243,7 +243,7 @@ func (n *Namespace) Restore(ctx context.Context, r io.Reader) (*Application, err appSettings.Name = name app := NewApplication(n, appSettings) - if err := app.Restore(ctx, volSettings, volumeData); err != nil { + if err := app.Restore(ctx, volumeData); err != nil { if cleanupErr := app.Destroy(context.Background(), true); cleanupErr != nil { slog.Error("Failed to clean up after restore failure", "app", appSettings.Name, "error", cleanupErr) } @@ -352,14 +352,14 @@ func (n *Namespace) sortApplications() { }) } -func (n *Namespace) parseBackup(r io.Reader) (ApplicationSettings, ApplicationVolumeSettings, []byte, error) { +func (n *Namespace) parseBackup(r io.Reader) (ApplicationSettings, []byte, error) { var appSettings ApplicationSettings - var volSettings ApplicationVolumeSettings + var volKeys KeysSettings var volumeData bytes.Buffer gr, err := gzip.NewReader(r) if err != nil { - return appSettings, volSettings, nil, fmt.Errorf("%w: %v", ErrInvalidBackup, err) + return appSettings, nil, fmt.Errorf("%w: %v", ErrInvalidBackup, err) } defer gr.Close() @@ -376,29 +376,29 @@ func (n *Namespace) parseBackup(r io.Reader) (ApplicationSettings, ApplicationVo break } if err != nil { - return appSettings, volSettings, nil, fmt.Errorf("%w: %v", ErrInvalidBackup, err) + return appSettings, nil, fmt.Errorf("%w: %v", ErrInvalidBackup, err) } switch header.Name { case backupAppSettingsEntry: data, err := io.ReadAll(tr) if err != nil { - return appSettings, volSettings, nil, fmt.Errorf("%w: reading application settings: %v", ErrInvalidBackup, err) + return appSettings, nil, fmt.Errorf("%w: reading application settings: %v", ErrInvalidBackup, err) } appSettings, err = UnmarshalApplicationSettings(string(data)) if err != nil { - return appSettings, volSettings, nil, fmt.Errorf("%w: parsing application settings: %v", ErrInvalidBackup, err) + return appSettings, nil, fmt.Errorf("%w: parsing application settings: %v", ErrInvalidBackup, err) } foundApp = true case backupVolSettingsEntry: data, err := io.ReadAll(tr) if err != nil { - return appSettings, volSettings, nil, fmt.Errorf("%w: reading volume settings: %v", ErrInvalidBackup, err) + return appSettings, nil, fmt.Errorf("%w: reading volume settings: %v", ErrInvalidBackup, err) } - volSettings, err = UnmarshalApplicationVolumeSettings(string(data)) + volKeys, err = UnmarshalKeysSettings(string(data)) if err != nil { - return appSettings, volSettings, nil, fmt.Errorf("%w: parsing volume settings: %v", ErrInvalidBackup, err) + return appSettings, nil, fmt.Errorf("%w: parsing volume settings: %v", ErrInvalidBackup, err) } foundVol = true @@ -411,11 +411,11 @@ func (n *Namespace) parseBackup(r io.Reader) (ApplicationSettings, ApplicationVo newHeader.Name = "data" + strings.TrimPrefix(header.Name, BackupDataDir) } if err := tw.WriteHeader(&newHeader); err != nil { - return appSettings, volSettings, nil, err + return appSettings, nil, err } if header.Size > 0 { if _, err := io.Copy(tw, tr); err != nil { - return appSettings, volSettings, nil, err + return appSettings, nil, err } } } @@ -423,10 +423,14 @@ func (n *Namespace) parseBackup(r io.Reader) (ApplicationSettings, ApplicationVo } if !foundApp || !foundVol { - return appSettings, volSettings, nil, fmt.Errorf("%w: missing required metadata files", ErrInvalidBackup) + return appSettings, nil, fmt.Errorf("%w: missing required metadata files", ErrInvalidBackup) } - return appSettings, volSettings, volumeData.Bytes(), nil + if !appSettings.Keys.Present() { + appSettings.Keys = volKeys + } + + return appSettings, volumeData.Bytes(), nil } // Helpers diff --git a/internal/docker/volume.go b/internal/docker/volume.go index 27a6317..a0807da 100644 --- a/internal/docker/volume.go +++ b/internal/docker/volume.go @@ -2,11 +2,6 @@ package docker import ( "context" - "crypto/ecdh" - "crypto/rand" - "encoding/base64" - "encoding/hex" - "encoding/json" "errors" "fmt" @@ -16,31 +11,10 @@ import ( var ErrVolumeNotFound = errors.New("volume not found") -type ApplicationVolumeSettings struct { - SecretKeyBase string `json:"secretKeyBase"` - VAPIDPublicKey string `json:"vapidPublicKey"` - VAPIDPrivateKey string `json:"vapidPrivateKey"` -} - -func UnmarshalApplicationVolumeSettings(s string) (ApplicationVolumeSettings, error) { - var settings ApplicationVolumeSettings - err := json.Unmarshal([]byte(s), &settings) - return settings, err -} - -func (s ApplicationVolumeSettings) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - type ApplicationVolume struct { namespace *Namespace name string - Settings ApplicationVolumeSettings -} - -func (v *ApplicationVolume) SecretKeyBase() string { - return v.Settings.SecretKeyBase + Keys KeysSettings } func (v *ApplicationVolume) Name() string { @@ -72,7 +46,7 @@ func FindVolume(ctx context.Context, ns *Namespace, name string) (*ApplicationVo return nil, fmt.Errorf("volume %s exists but has no once label", volumeName) } - settings, err := UnmarshalApplicationVolumeSettings(label) + keys, err := UnmarshalKeysSettings(label) if err != nil { return nil, fmt.Errorf("parsing volume settings: %w", err) } @@ -80,17 +54,17 @@ func FindVolume(ctx context.Context, ns *Namespace, name string) (*ApplicationVo return &ApplicationVolume{ namespace: ns, name: volumeName, - Settings: settings, + Keys: keys, }, nil } -func CreateVolume(ctx context.Context, ns *Namespace, name string, settings ApplicationVolumeSettings) (*ApplicationVolume, error) { +func CreateVolume(ctx context.Context, ns *Namespace, name string, keys KeysSettings) (*ApplicationVolume, error) { volumeName := fmt.Sprintf("%s-app-%s", ns.name, name) _, err := ns.client.VolumeCreate(ctx, volume.CreateOptions{ Name: volumeName, Labels: map[string]string{ - labelKey: settings.Marshal(), + labelKey: keys.Marshal(), }, }) if err != nil { @@ -100,28 +74,6 @@ func CreateVolume(ctx context.Context, ns *Namespace, name string, settings Appl return &ApplicationVolume{ namespace: ns, name: volumeName, - Settings: settings, + Keys: keys, }, nil } - -// Helpers - -func generateSecretKeyBase() (string, error) { - bytes := make([]byte, 32) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return hex.EncodeToString(bytes), nil -} - -func generateVAPIDKeyPair() (publicKey, privateKey string, err error) { - key, err := ecdh.P256().GenerateKey(rand.Reader) - if err != nil { - return "", "", err - } - - privateKey = base64.RawURLEncoding.EncodeToString(key.Bytes()) - publicKey = base64.RawURLEncoding.EncodeToString(key.PublicKey().Bytes()) - - return publicKey, privateKey, nil -} diff --git a/internal/docker/volume_test.go b/internal/docker/volume_test.go deleted file mode 100644 index dad7d98..0000000 --- a/internal/docker/volume_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package docker - -import ( - "encoding/base64" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGenerateVAPIDKeyPair(t *testing.T) { - pub, priv, err := generateVAPIDKeyPair() - require.NoError(t, err) - assert.NotEmpty(t, pub) - assert.NotEmpty(t, priv) - - pubBytes, err := base64.RawURLEncoding.DecodeString(pub) - require.NoError(t, err) - assert.Len(t, pubBytes, 65) - - privBytes, err := base64.RawURLEncoding.DecodeString(priv) - require.NoError(t, err) - assert.Len(t, privBytes, 32) -} - -func TestGenerateVAPIDKeyPairUniqueness(t *testing.T) { - pub1, priv1, err := generateVAPIDKeyPair() - require.NoError(t, err) - - pub2, priv2, err := generateVAPIDKeyPair() - require.NoError(t, err) - - assert.NotEqual(t, pub1, pub2) - assert.NotEqual(t, priv1, priv2) -} - -func TestVolumeSettingsMarshalRoundTrip(t *testing.T) { - original := ApplicationVolumeSettings{ - SecretKeyBase: "secret", - VAPIDPublicKey: "pub123", - VAPIDPrivateKey: "priv456", - } - - restored, err := UnmarshalApplicationVolumeSettings(original.Marshal()) - require.NoError(t, err) - assert.Equal(t, original, restored) -}