diff --git a/cmd/service/rotate_secret.go b/cmd/service/rotate_secret.go new file mode 100644 index 0000000..5026f1e --- /dev/null +++ b/cmd/service/rotate_secret.go @@ -0,0 +1,43 @@ +/* +Copyright (c) Tobias Schäfer. All rights reserved. +Licensed under the MIT license, see LICENSE in the project root for details. +*/ +package service + +import ( + "github.com/spf13/cobra" + "github.com/tschaefer/finchctl/cmd/completion" + "github.com/tschaefer/finchctl/cmd/errors" + "github.com/tschaefer/finchctl/cmd/format" + "github.com/tschaefer/finchctl/internal/service" +) + +var rotateSecretCmd = &cobra.Command{ + Use: "rotate-secret [user@]host[:port]", + Short: "Rotate secret of a service on a remote host", + Args: cobra.ExactArgs(1), + Run: runRotateSecretCmd, + ValidArgsFunction: completion.CompleteStackName, +} + +func init() { + rotateSecretCmd.Flags().String("run.format", "progress", "output format") + rotateSecretCmd.Flags().Bool("run.dry-run", false, "do not rotate secret, just print the commands that would be run") + + _ = rotateSecretCmd.RegisterFlagCompletionFunc("run.format", completion.CompleteRunFormat) +} + +func runRotateSecretCmd(cmd *cobra.Command, args []string) { + targetUrl := args[0] + + formatName, _ := cmd.Flags().GetString("run.format") + formatType, err := format.GetRunFormat(formatName) + cobra.CheckErr(err) + dryRun, _ := cmd.Flags().GetBool("run.dry-run") + + s, err := service.New(nil, targetUrl, formatType, dryRun) + errors.CheckErr(err, formatType) + + err = s.RotateSecret() + errors.CheckErr(err, formatType) +} diff --git a/cmd/service/service.go b/cmd/service/service.go index 68a71bd..afaf324 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -17,4 +17,5 @@ func init() { Cmd.AddCommand(teardownCmd) Cmd.AddCommand(infoCmd) Cmd.AddCommand(dashboardCmd) + Cmd.AddCommand(rotateSecretCmd) } diff --git a/internal/service/deploy.go b/internal/service/deploy.go index b2164ef..568f606 100644 --- a/internal/service/deploy.go +++ b/internal/service/deploy.go @@ -199,10 +199,10 @@ func (s *Service) __deployCopyFinchConfig() error { Database: "sqlite://finch.db", Profiler: "http://pyroscope:4040", Secret: secret, - Version: "1.3.0", + Version: "1.7.0", } - return s.__helperCopyTemplate(path, "400", "10002:10002", data) + return s.__helperCopyTemplate(path, "400", "0:0", data) } func (s *Service) __deployCopyGrafanaDashboards() error { diff --git a/internal/service/errors.go b/internal/service/errors.go index c535d9a..fcb9396 100644 --- a/internal/service/errors.go +++ b/internal/service/errors.go @@ -46,6 +46,15 @@ func (e *InfoServiceError) Error() string { return strings.TrimSpace(fmt.Sprintf("Failed to get service info: %s %s", e.Message, e.Reason)) } +type RotateServiceSecretError struct { + Message string + Reason string +} + +func (e *RotateServiceSecretError) Error() string { + return strings.TrimSpace(fmt.Sprintf("Failed to rotate service secret: %s %s", e.Message, e.Reason)) +} + func convertError(err error, to any) error { if err == nil { return nil diff --git a/internal/service/rotate_secret.go b/internal/service/rotate_secret.go new file mode 100644 index 0000000..2b04e74 --- /dev/null +++ b/internal/service/rotate_secret.go @@ -0,0 +1,47 @@ +/* +Copyright (c) Tobias Schäfer. All rights reserved. +Licensed under the MIT license, see LICENSE in the project root for details. +*/ +package service + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" +) + +func (s *Service) rotateServiceSecret() error { + cfgPath := fmt.Sprintf("%s/finch.json", s.libDir()) + out, err := s.target.Run(fmt.Sprintf("sudo cat %s", cfgPath)) + if err != nil { + return &RotateServiceSecretError{Message: err.Error(), Reason: string(out)} + } + if s.dryRun { + out = []byte(`{}`) + } + + cfg := FinchConfig{} + err = json.Unmarshal(out, &cfg) + if err != nil { + return &RotateServiceSecretError{Message: err.Error(), Reason: string(out)} + } + + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return &RotateServiceSecretError{Message: err.Error(), Reason: ""} + } + secret := base64.StdEncoding.EncodeToString(key) + cfg.Secret = secret + + if err := s.__helperCopyTemplate(cfgPath, "400", "0:0", cfg); err != nil { + return convertError(err, &RotateServiceSecretError{}) + } + + out, err = s.target.Run(fmt.Sprintf("sudo docker compose --file %s/docker-compose.yaml restart finch", s.libDir())) + if err != nil { + return &RotateServiceSecretError{Message: err.Error(), Reason: string(out)} + } + + return nil +} diff --git a/internal/service/service.go b/internal/service/service.go index 35059fb..665912a 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -149,3 +149,21 @@ func (s *Service) libDir() string { return dir } + +func (s *Service) RotateSecret() error { + defer func() { + if s.format == target.FormatProgress { + println() + } + }() + + if err := s.requirementsService(); err != nil { + return convertError(err, &RotateServiceSecretError{}) + } + + if err := s.rotateServiceSecret(); err != nil { + return err + } + + return nil +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go index d9063a0..a286826 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -127,6 +127,41 @@ func Test_Update(t *testing.T) { assert.NotEmpty(t, track.Timestamp, "first log line timestamp") } +func Test_RotateSecret(t *testing.T) { + s, err := New(nil, "localhost", target.FormatDocumentation, true) + assert.NoError(t, err, "create service") + + record := capture(func() { + err = s.RotateSecret() + }) + assert.NoError(t, err, "rotate secret") + + tracks := strings.Split(record, "\n") + assert.Len(t, tracks, 7, "number of log lines mismatch") + + wanted := "Running 'command -v sudo' as .+@localhost" + assert.Regexp(t, wanted, tracks[0], "first log line") + + wanted = "Running 'sudo docker compose --file .+/docker-compose.yaml restart finch' as .+@localhost" + assert.Regexp(t, wanted, tracks[len(tracks)-2], "last log line") + + s, err = New(nil, "localhost", target.FormatJSON, true) + assert.NoError(t, err, "create service") + + record = capture(func() { + err = s.RotateSecret() + }) + assert.NoError(t, err, "rotate secret") + + tracks = strings.Split(record, "\n") + var track track + err = json.Unmarshal([]byte(tracks[0]), &track) + assert.NoError(t, err, "unmarshal json output") + + wanted = "Running 'command -v sudo' as .+@localhost" + assert.Regexp(t, wanted, tracks[0], "first log line") +} + func capture(f func()) string { originalStdout := os.Stdout