diff --git a/README.md b/README.md index adfed20..c51e596 100644 --- a/README.md +++ b/README.md @@ -37,21 +37,22 @@ finchctl service deploy root@10.19.80.100 ``` This deploys the stack and exposes services at `https://10.19.80.100`. -Admin credentials are in `~/.finch/config.json`. -Visualization and dashboards are available under `/grafana`. -TLS uses Traefik's default self-signed certificate. +Visualization and dashboards are available under `/grafana`. TLS uses Traefik's +default self-signed certificate. Credentials for Grafana are user `admin` and +password `admin`. mTLS secures communication between the Finch client and +service. The client certificate and key are generated during deployment and +stored in `~/.config/finch.json`. For a public machine with DNS and Let's Encrypt certificate: ```bash finchctl service deploy \ --service.letsencrypt --service.letsencrypt.email acme@example.com \ - --service.user admin --service.password secret \ --service.host finch.example.com root@cloud.machine ``` Services are exposed at `https://finch.example.com` with a Let's Encrypt -certificate. Credentials for Grafana and Finch: user `admin`, password `secret`. +certificate. To use a custom TLS certificate: diff --git a/cmd/completion/completion.go b/cmd/completion/completion.go index 4cabf6e..a95a11f 100644 --- a/cmd/completion/completion.go +++ b/cmd/completion/completion.go @@ -18,13 +18,8 @@ func CompleteStackName(cmd *cobra.Command, args []string, toComplete string) ([] return nil, cobra.ShellCompDirectiveNoFileComp } - config, err := config.ReadConfig() + stacks, err := config.ListStacks() cobra.CheckErr(err) - var stackNames []string - for _, stack := range config.Stacks { - stackNames = append(stackNames, stack.Name) - } - - return stackNames, cobra.ShellCompDirectiveNoFileComp + return stacks, cobra.ShellCompDirectiveNoFileComp } diff --git a/cmd/service/deploy.go b/cmd/service/deploy.go index c475298..0f8e66e 100644 --- a/cmd/service/deploy.go +++ b/cmd/service/deploy.go @@ -5,7 +5,6 @@ Licensed under the MIT license, see LICENSE in the project root for details. package service import ( - "crypto/rand" "fmt" "net/url" "strings" @@ -29,8 +28,6 @@ func init() { deployCmd.Flags().String("run.format", "progress", "output format") deployCmd.Flags().Bool("run.dry-run", false, "do not deploy, just print the commands that would be run") deployCmd.Flags().String("service.host", "", "service host (default: auto-detected from target URL)") - deployCmd.Flags().String("service.user", "", "service user (default: 'admin')") - deployCmd.Flags().String("service.password", "", "service password (default: random password)") deployCmd.Flags().Bool("service.letsencrypt", false, "use Let's Encrypt for TLS certificate (default: false)") deployCmd.Flags().String("service.letsencrypt.email", "", "email address for Let's Encrypt registration (required if --service.letsencrypt is true)") deployCmd.Flags().Bool("service.customtls", false, "use custom TLS certificate (default: false)") @@ -76,18 +73,6 @@ func deployConfig(cmd *cobra.Command, args []string, formatType target.Format) ( } config.Hostname = hostname - user, _ := cmd.Flags().GetString("service.user") - if user == "" { - user = "admin" - } - config.Username = user - - password, _ := cmd.Flags().GetString("service.password") - if password == "" { - password = rand.Text() - } - config.Password = password - letsencrypt, _ := cmd.Flags().GetBool("service.letsencrypt") letsencryptEmail, _ := cmd.Flags().GetString("service.letsencrypt.email") customTLS, _ := cmd.Flags().GetBool("service.customtls") diff --git a/internal/agent/errors_test.go b/internal/agent/errors_test.go index d9da06c..762004f 100644 --- a/internal/agent/errors_test.go +++ b/internal/agent/errors_test.go @@ -14,7 +14,7 @@ func raiseError() error { return &DeployAgentError{Message: "deployment failed", Reason: "insufficient resources"} } -func Test_convertError(t *testing.T) { +func Test_ConvertError(t *testing.T) { err := raiseError() assert.Error(t, err, "expected an error from raiseError function") assert.IsType(t, &DeployAgentError{}, err, "expected error to be of type DeployAgentError") diff --git a/internal/config/config.go b/internal/config/config.go index 35cd575..fca8230 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,84 +26,93 @@ const ( ConfigLocationEnv string = "FINCH_CONFIG" ) -type Config struct { - Stacks []Stack `json:"stacks"` +type Stacks struct { + List []Stack `json:"stacks"` } type Stack struct { - Name string `json:"name,omitempty"` - Token string `json:"token,omitempty"` + Name string `json:"name,omitempty"` + Cert string `json:"cert,omitempty"` + Key string `json:"key,omitempty"` } -func UpdateStackAuth(name, username, password string) error { - var config Config - if fileExists() { - cfg, err := ReadConfig() +func UpdateStack(name string, certPEM, keyPEM []byte) error { + var stacks Stacks + if exist() { + if err := backup(); err != nil { + return &ConfigError{Message: "failed to backup config", Reason: err.Error()} + } + + cfg, err := read() if err != nil { - return err + return &ConfigError{Message: "failed to read config", Reason: err.Error()} } - config = *cfg + stacks = *cfg } exists := false - for _, authConfig := range config.Stacks { - if authConfig.Name == name { + for _, stack := range stacks.List { + if stack.Name == name { exists = true break } } if exists { - config.Stacks = slices.DeleteFunc(config.Stacks, func(s Stack) bool { + stacks.List = slices.DeleteFunc(stacks.List, func(s Stack) bool { return s.Name == name }) } - config.Stacks = append(config.Stacks, Stack{ - Name: name, - Token: encodeToken(username, password), + stacks.List = append(stacks.List, Stack{ + Name: name, + Cert: base64.StdEncoding.EncodeToString(certPEM), + Key: base64.StdEncoding.EncodeToString(keyPEM), }) - return WriteConfig(&config) + return write(&stacks) } -func LookupStackAuth(name string) (string, error) { - if !fileExists() { - return "", &ConfigError{Message: "config file does not exist", Reason: ""} +func LookupStack(name string) ([]byte, []byte, error) { + if !exist() { + return nil, nil, &ConfigError{Message: "config file does not exist", Reason: ""} } - config, err := ReadConfig() + stacks, err := read() if err != nil { - return "", &ConfigError{Message: "failed to read config", Reason: err.Error()} - } - - var token string - for _, authConfig := range config.Stacks { - if authConfig.Name == name { - token = authConfig.Token - break + return nil, nil, &ConfigError{Message: "failed to read config", Reason: err.Error()} + } + + for _, stack := range stacks.List { + if stack.Name == name { + certPEM, err := base64.StdEncoding.DecodeString(stack.Cert) + if err != nil { + return nil, nil, &ConfigError{Message: "failed to decode certificate", Reason: err.Error()} + } + keyPEM, err := base64.StdEncoding.DecodeString(stack.Key) + if err != nil { + return nil, nil, &ConfigError{Message: "failed to decode key", Reason: err.Error()} + } + return certPEM, keyPEM, nil } } - if token == "" { - return "", &ConfigError{Message: "stack not found", Reason: ""} - } - return token, nil + return nil, nil, &ConfigError{Message: "stack not found", Reason: ""} } -func RemoveStackAuth(name string) error { - if !fileExists() { +func RemoveStack(name string) error { + if !exist() { return &ConfigError{Message: "config file does not exist", Reason: ""} } - config, err := ReadConfig() + stacks, err := read() if err != nil { return &ConfigError{Message: "failed to read config", Reason: err.Error()} } index := -1 - for i, authConfig := range config.Stacks { - if authConfig.Name == name { + for i, stack := range stacks.List { + if stack.Name == name { index = i break } @@ -113,43 +122,71 @@ func RemoveStackAuth(name string) error { return nil } - config.Stacks = slices.Delete(config.Stacks, index, index+1) + if err := backup(); err != nil { + return &ConfigError{Message: "failed to backup config", Reason: err.Error()} + } - return WriteConfig(config) + stacks.List = slices.Delete(stacks.List, index, index+1) + + return write(stacks) } -func fileExists() bool { - if _, err := os.Stat(configFile()); err != nil && os.IsNotExist(err) { - return false +func ListStacks() ([]string, error) { + if !exist() { + return nil, &ConfigError{Message: "config file does not exist", Reason: ""} } - return true + stacks, err := read() + if err != nil { + return nil, &ConfigError{Message: "failed to read config", Reason: err.Error()} + } + + var names []string + for _, stack := range stacks.List { + names = append(names, stack.Name) + } + + return names, nil } -func WriteConfig(config *Config) error { - data, err := json.MarshalIndent(config, "", " ") +func backup() error { + data, err := os.ReadFile(path()) if err != nil { - return &ConfigError{Message: err.Error(), Reason: ""} + return err } - return os.WriteFile(configFile(), data, 0600) + err = os.WriteFile(fmt.Sprintf("%s~", path()), data, 0400) + if err != nil { + return err + } + + return nil } -func ReadConfig() (*Config, error) { - data, err := os.ReadFile(configFile()) +func write(stacks *Stacks) error { + data, err := json.MarshalIndent(stacks, "", " ") if err != nil { - return nil, &ConfigError{Message: err.Error(), Reason: ""} + return err } - var config Config - if err := json.Unmarshal(data, &config); err != nil { - return nil, &ConfigError{Message: err.Error(), Reason: ""} + return os.WriteFile(path(), data, 0600) +} + +func read() (*Stacks, error) { + data, err := os.ReadFile(path()) + if err != nil { + return nil, err + } + + var stacks Stacks + if err := json.Unmarshal(data, &stacks); err != nil { + return nil, err } - return &config, nil + return &stacks, nil } -func configFile() string { +func path() string { var dir string dir = os.Getenv(ConfigLocationEnv) @@ -165,9 +202,10 @@ func configFile() string { return fmt.Sprintf("%s/finch.json", dir) } -func encodeToken(username, password string) string { - plain := fmt.Sprintf("%s:%s", username, password) - encoded := base64.StdEncoding.EncodeToString([]byte(plain)) +func exist() bool { + if _, err := os.Stat(path()); err != nil && os.IsNotExist(err) { + return false + } - return encoded + return true } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f701790..b65c728 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -15,8 +15,8 @@ import ( type stack struct { Hostname string - Username string - Password string + Cert []byte + Key []byte } func Test_ErrorString(t *testing.T) { @@ -29,7 +29,7 @@ func Test_ErrorString(t *testing.T) { assert.Equal(t, wanted, err.Error(), "error message") } -func Test_UpdateStackAuthFailIfPermissionDenied(t *testing.T) { +func Test_UpdateStackFailIfPermissionDenied(t *testing.T) { user, err := user.Current() assert.NoError(t, err, "get current user") @@ -44,81 +44,100 @@ func Test_UpdateStackAuthFailIfPermissionDenied(t *testing.T) { assert.NoError(t, err, "change permissions of config directory") stack := newStack() - err = UpdateStackAuth(stack.Hostname, stack.Username, stack.Password) - wanted := "Config error: open " + cfgLoc + "/finch.json: permission denied" + err = UpdateStack(stack.Hostname, stack.Cert, stack.Key) + wanted := "Config error: failed to backup config open " + cfgLoc + "/finch.json: permission denied" assert.EqualError(t, err, wanted, "update stack") } -func Test_UpdateStackAuthCreateConfigIfNotExist(t *testing.T) { +func Test_UpdateStackCreateConfigIfNotExist(t *testing.T) { cfgLoc := setup(t) defer teardown(cfgLoc, t) stack := newStack() - err := UpdateStackAuth(stack.Hostname, stack.Username, stack.Password) + err := UpdateStack(stack.Hostname, stack.Cert, stack.Key) assert.NoError(t, err, "update stack") _, err = os.Stat(cfgLoc + "/finch.json") assert.False(t, os.IsNotExist(err), "create config file") } -func Test_LookupStackAuthReturnErrorIfStackNotExist(t *testing.T) { +func Test_LookupStackReturnErrorIfStackNotExist(t *testing.T) { cfgLoc := setup(t) defer teardown(cfgLoc, t) stack := newStack() - err := UpdateStackAuth(stack.Hostname, stack.Username, stack.Password) + err := UpdateStack(stack.Hostname, stack.Cert, stack.Key) assert.NoError(t, err, "update stack") hostname := gofakeit.DomainName() - _, err = LookupStackAuth(hostname) + _, _, err = LookupStack(hostname) wanted := "Config error: stack not found" assert.EqualError(t, err, wanted, "lookup stack") } -func Test_LookupStackAuthReturnCredentialsIfStackExist(t *testing.T) { +func Test_LookupStackReturnPathsIfStackExist(t *testing.T) { cfgLoc := setup(t) defer teardown(cfgLoc, t) stack := newStack() - err := UpdateStackAuth(stack.Hostname, stack.Username, stack.Password) + err := UpdateStack(stack.Hostname, stack.Cert, stack.Key) assert.NoError(t, err, "update stack") - token, err := LookupStackAuth(stack.Hostname) + cert, key, err := LookupStack(stack.Hostname) assert.NoError(t, err, "lookup stack") - assert.Equal(t, encodeToken(stack.Username, stack.Password), token, "auth token") + assert.Equal(t, stack.Cert, cert, "cert PEM") + assert.Equal(t, stack.Key, key, "key PEM") } -func Test_RemoveStackAuthReturnNoErrorIfStackNotExist(t *testing.T) { +func Test_RemoveStackReturnNoErrorIfStackNotExist(t *testing.T) { cfgLoc := setup(t) defer teardown(cfgLoc, t) stack := newStack() - err := UpdateStackAuth(stack.Hostname, stack.Username, stack.Password) + err := UpdateStack(stack.Hostname, stack.Cert, stack.Key) assert.NoError(t, err, "update stack") hostname := gofakeit.DomainName() - err = RemoveStackAuth(hostname) + err = RemoveStack(hostname) assert.NoError(t, err, "remove stack") } -func Test_RemoveStackAuthSucceedIfStackExist(t *testing.T) { +func Test_RemoveStackSucceedIfStackExist(t *testing.T) { cfgLoc := setup(t) defer teardown(cfgLoc, t) stack := newStack() - err := UpdateStackAuth(stack.Hostname, stack.Username, stack.Password) + err := UpdateStack(stack.Hostname, stack.Cert, stack.Key) assert.NoError(t, err, "update stack") - err = RemoveStackAuth(stack.Hostname) + err = RemoveStack(stack.Hostname) assert.NoError(t, err, "remove stack") - _, err = LookupStackAuth(stack.Hostname) + _, _, err = LookupStack(stack.Hostname) assert.Error(t, err, "lookup stack") wanted := "Config error: stack not found" assert.Equal(t, wanted, err.Error(), "error message") + + last := cfgLoc + "/finch.json~" + _, err = os.Stat(last) + assert.False(t, os.IsNotExist(err), "backup config file exists") +} + +func Test_LookupStackReturnErrorIfStackNotExist2(t *testing.T) { + cfgLoc := setup(t) + defer teardown(cfgLoc, t) + + stack := newStack() + err := UpdateStack(stack.Hostname, stack.Cert, stack.Key) + assert.NoError(t, err, "update stack") + + hostname := gofakeit.DomainName() + _, _, err = LookupStack(hostname) + wanted := "Config error: stack not found" + assert.EqualError(t, err, wanted, "lookup stack certs") } func setup(t *testing.T) string { @@ -141,8 +160,8 @@ func teardown(cfgLoc string, t *testing.T) { func newStack() stack { return stack{ - Hostname: gofakeit.DomainName(), - Username: gofakeit.Username(), - Password: gofakeit.Password(true, true, true, true, false, 12), + Hostname: "finch." + gofakeit.DomainName(), + Cert: []byte("certificate data"), + Key: []byte("key data"), } } diff --git a/internal/grpc/grpc.go b/internal/grpc/grpc.go index 8e6edeb..b7dba1a 100644 --- a/internal/grpc/grpc.go +++ b/internal/grpc/grpc.go @@ -16,7 +16,6 @@ import ( "github.com/tschaefer/finchctl/internal/version" "google.golang.org/grpc" "google.golang.org/grpc/credentials" - "google.golang.org/grpc/metadata" ) const ( @@ -29,30 +28,25 @@ type Client[T any] struct { } func NewClient[T any](ctx context.Context, service string, newHandler func(grpc.ClientConnInterface) T) (context.Context, *Client[T], error) { - token, err := config.LookupStackAuth(service) + certPEM, keyPEM, err := config.LookupStack(service) if err != nil { return ctx, nil, err } - ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Basic "+token) - skipTLSVerify := false - if v, ok := os.LookupEnv(SkipTLSVerifyEnv); ok { - l := strings.ToLower(v) - if l == "1" || l == "true" { - skipTLSVerify = true - } + // Parse the certificate and key from PEM + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return ctx, nil, fmt.Errorf("failed to parse client certificate: %w", err) } - var creds credentials.TransportCredentials - if skipTLSVerify { - tlsCfg := &tls.Config{ - InsecureSkipVerify: true, - } - creds = credentials.NewTLS(tlsCfg) - fmt.Fprintf(os.Stderr, "Warning: skipping TLS verification for service %s\n", service) - } else { - creds = credentials.NewClientTLSFromCert(nil, service) + tlsCfg := &tls.Config{ + Certificates: []tls.Certificate{cert}, + ServerName: service, + } + if skipTLSVerify() { + tlsCfg.InsecureSkipVerify = true } + creds := credentials.NewTLS(tlsCfg) userAgent := fmt.Sprintf("finchctl/%s", version.Release()) @@ -80,3 +74,13 @@ func (c *Client[T]) Handler() T { func (c *Client[T]) Close() error { return c.conn.Close() } + +func skipTLSVerify() bool { + if v, ok := os.LookupEnv(SkipTLSVerifyEnv); ok { + l := strings.ToLower(v) + if l == "1" || l == "true" { + return true + } + } + return false +} diff --git a/internal/service/assets/docker-compose.yaml.tmpl b/internal/service/assets/docker-compose.yaml.tmpl index 44014bc..02e8f17 100644 --- a/internal/service/assets/docker-compose.yaml.tmpl +++ b/internal/service/assets/docker-compose.yaml.tmpl @@ -4,8 +4,6 @@ services: container_name: grafana image: grafana/grafana:12.3.1 environment: - - GF_SECURITY_ADMIN_USER={{ .Username }} - - GF_SECURITY_ADMIN_PASSWORD={{ .Password }} - GF_SERVER_ROOT_URL={{ .RootUrl }}/grafana - GF_SERVER_SERVE_FROM_SUB_PATH=true volumes: diff --git a/internal/service/assets/finch.json.tmpl b/internal/service/assets/finch.json.tmpl index a412516..7959aee 100644 --- a/internal/service/assets/finch.json.tmpl +++ b/internal/service/assets/finch.json.tmpl @@ -3,10 +3,7 @@ "database": "{{.Database}}", "hostname": "{{.Hostname}}", "id": "{{.Id}}", + "profiler": "{{.Profiler}}", "secret": "{{.Secret}}", - "version": "{{.Version}}", - "credentials": { - "username": "{{.Credentials.Username}}", - "password": "{{.Credentials.Password}}" - } + "version": "{{.Version}}" } diff --git a/internal/service/assets/http.yaml.tmpl b/internal/service/assets/http.yaml.tmpl index 80b39d6..5e675dd 100644 --- a/internal/service/assets/http.yaml.tmpl +++ b/internal/service/assets/http.yaml.tmpl @@ -23,6 +23,8 @@ http: entrypoints: - websecure service: finch + middlewares: + - finch-passtls tls: true mimir: @@ -87,7 +89,16 @@ http: prefixes: - "/pyroscope" + finch-passtls: + passtlsclientcert: + pem: true + tls: certificates: - certfile: /etc/traefik/certs.d/cert.pem keyfile: /etc/traefik/certs.d/key.pem + options: + default: + clientauth: + clientauthtype: RequestClientCert + diff --git a/internal/service/deploy.go b/internal/service/deploy.go index 60ec920..b2164ef 100644 --- a/internal/service/deploy.go +++ b/internal/service/deploy.go @@ -17,7 +17,7 @@ import ( "text/template" "time" - "golang.org/x/crypto/bcrypt" + "github.com/tschaefer/finchctl/internal/config" ) func (s *Service) __deployMakeDirHierarchy() error { @@ -71,25 +71,6 @@ func (s *Service) __deployCopyLokiConfig() error { return s.__helperCopyConfig(path, "400", "10001:10001") } -func (s *Service) __deployCopyLokiUserAuthFile() error { - path := fmt.Sprintf("%s/traefik/etc/conf.d/loki-users.yaml", s.libDir()) - - hash, err := bcrypt.GenerateFromPassword([]byte(s.config.Password), bcrypt.DefaultCost) - if err != nil { - return err - } - - data := struct { - Username string - Password string - }{ - Username: s.config.Username, - Password: string(hash), - } - - return s.__helperCopyTemplate(path, "400", "0:0", data) -} - func (s *Service) __deployCopyTraefikConfig() error { path := fmt.Sprintf("%s/traefik/etc/traefik.yaml", s.libDir()) @@ -152,6 +133,43 @@ func (s *Service) __deployCopyTraefikHttpTlsConfig() error { return nil } +func (s *Service) __deployGenerateMTLSCertificates() error { + if s.dryRun { + return nil + } + + caCertPEM, caKeyPEM, err := __mtlsGenerateCA(s.config.Hostname) + if err != nil { + return &DeployServiceError{Message: "failed to generate CA certificate", Reason: err.Error()} + } + + clientCertPEM, clientKeyPEM, err := __mtlsGenerateClientCert(s.config.Hostname, caCertPEM, caKeyPEM) + if err != nil { + return &DeployServiceError{Message: "failed to generate client certificate", Reason: err.Error()} + } + + caCertPath := fmt.Sprintf("%s/traefik/etc/certs.d/ca.pem", s.libDir()) + f, err := os.CreateTemp("", "ca.pem") + if err != nil { + return &DeployServiceError{Message: err.Error(), Reason: ""} + } + defer func() { + _ = os.Remove(f.Name()) + }() + if _, err := f.Write(caCertPEM); err != nil { + return &DeployServiceError{Message: err.Error(), Reason: ""} + } + if err := s.target.Copy(f.Name(), caCertPath, "400", "0:0"); err != nil { + return &DeployServiceError{Message: err.Error(), Reason: ""} + } + + if err := config.UpdateStack(s.config.Hostname, clientCertPEM, clientKeyPEM); err != nil { + return &DeployServiceError{Message: "failed to update stack certificates", Reason: err.Error()} + } + + return nil +} + func (s *Service) __deployCopyAlloyConfig() error { path := fmt.Sprintf("%s/alloy/etc/alloy.config", s.libDir()) @@ -182,13 +200,6 @@ func (s *Service) __deployCopyFinchConfig() error { Profiler: "http://pyroscope:4040", Secret: secret, Version: "1.3.0", - Credentials: struct { - Username string `json:"username"` - Password string `json:"password"` - }{ - Username: s.config.Username, - Password: s.config.Password, - }, } return s.__helperCopyTemplate(path, "400", "10002:10002", data) @@ -282,13 +293,9 @@ func (s *Service) __deployCopyComposeFile() error { path := fmt.Sprintf("%s/docker-compose.yaml", s.libDir()) data := struct { - RootUrl string - Username string - Password string + RootUrl string }{ - RootUrl: fmt.Sprintf("https://%s", s.config.Hostname), - Username: s.config.Username, - Password: s.config.Password, + RootUrl: fmt.Sprintf("https://%s", s.config.Hostname), } return s.__helperCopyTemplate(path, "400", "0:0", data) @@ -327,10 +334,6 @@ func (s *Service) deployService() error { return err } - if err := s.__deployCopyLokiUserAuthFile(); err != nil { - return err - } - if err := s.__deployCopyTraefikConfig(); err != nil { return err } @@ -343,6 +346,10 @@ func (s *Service) deployService() error { return err } + if err := s.__deployGenerateMTLSCertificates(); err != nil { + return err + } + if err := s.__deployCopyAlloyConfig(); err != nil { return err } diff --git a/internal/service/errors_test.go b/internal/service/errors_test.go index e751c07..87fe708 100644 --- a/internal/service/errors_test.go +++ b/internal/service/errors_test.go @@ -14,7 +14,7 @@ func raiseError() error { return &DeployServiceError{Message: "deployment failed", Reason: "insufficient resources"} } -func Test_convertError(t *testing.T) { +func Test_ConvertError(t *testing.T) { err := raiseError() assert.Error(t, err, "expected an error from raiseError function") assert.IsType(t, &DeployServiceError{}, err, "expected error to be of type DeployServiceError") diff --git a/internal/service/mtls.go b/internal/service/mtls.go new file mode 100644 index 0000000..840f3ab --- /dev/null +++ b/internal/service/mtls.go @@ -0,0 +1,146 @@ +/* +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/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "time" +) + +const ( + certValidityDays = 90 + certExpirationThreshold = 3 * 24 * time.Hour +) + +func __mtlsGenerateCA(hostname string) ([]byte, []byte, error) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate CA private key: %w", err) + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate CA serial number: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Finch"}, + CommonName: fmt.Sprintf("Finch CA - %s", hostname), + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(certValidityDays * 24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + IsCA: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to create CA certificate: %w", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certDER, + }) + + keyBytes, err := x509.MarshalECPrivateKey(privateKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal EC private key: %w", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: keyBytes, + }) + + return certPEM, keyPEM, nil +} + +func __mtlsGenerateClientCert(hostname string, caCertPEM, caKeyPEM []byte) ([]byte, []byte, error) { + caCertBlock, _ := pem.Decode(caCertPEM) + if caCertBlock == nil { + return nil, nil, fmt.Errorf("failed to decode CA certificate PEM") + } + caCert, err := x509.ParseCertificate(caCertBlock.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse CA certificate: %w", err) + } + + caKeyBlock, _ := pem.Decode(caKeyPEM) + if caKeyBlock == nil { + return nil, nil, fmt.Errorf("failed to decode CA private key PEM") + } + caKey, err := x509.ParseECPrivateKey(caKeyBlock.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse CA private key: %w", err) + } + + clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate client private key: %w", err) + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate client serial number: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Finch"}, + CommonName: fmt.Sprintf("Finch Client - %s", hostname), + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(certValidityDays * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + + clientCertDER, err := x509.CreateCertificate(rand.Reader, &template, caCert, &clientKey.PublicKey, caKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to create client certificate: %w", err) + } + + clientCertPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: clientCertDER, + }) + + keyBytes, err := x509.MarshalECPrivateKey(clientKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal EC private key: %w", err) + } + clientKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: keyBytes, + }) + + return clientCertPEM, clientKeyPEM, nil +} + +func __mtlsIsCertificateExpired(certPEM []byte) (bool, error) { + certBlock, _ := pem.Decode(certPEM) + if certBlock == nil { + return true, fmt.Errorf("failed to decode certificate PEM") + } + + cert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return true, fmt.Errorf("failed to parse certificate: %w", err) + } + + expirationThreshold := time.Now().Add(certExpirationThreshold) + return cert.NotAfter.Before(expirationThreshold), nil +} diff --git a/internal/service/mtls_test.go b/internal/service/mtls_test.go new file mode 100644 index 0000000..59912c8 --- /dev/null +++ b/internal/service/mtls_test.go @@ -0,0 +1,106 @@ +/* +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/x509" + "encoding/pem" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v7" + "github.com/stretchr/testify/assert" + "github.com/tschaefer/finchctl/internal/config" +) + +func Test_GenerateCA(t *testing.T) { + hostname := "finch." + gofakeit.DomainName() + + caCertPEM, caKeyPEM, err := __mtlsGenerateCA(hostname) + assert.NoError(t, err, "generate CA should not error") + assert.NotNil(t, caCertPEM, "CA cert should not be nil") + assert.NotNil(t, caKeyPEM, "CA key should not be nil") + + block, _ := pem.Decode(caCertPEM) + assert.NotNil(t, block, "CA cert PEM should decode") + + cert, err := x509.ParseCertificate(block.Bytes) + assert.NoError(t, err, "CA cert should parse") + assert.True(t, cert.IsCA, "cert should be CA") + assert.Contains(t, cert.Subject.CommonName, hostname, "cert CN should contain hostname") +} + +func Test_GenerateClientCert(t *testing.T) { + hostname := "finch." + gofakeit.DomainName() + + caCertPEM, caKeyPEM, err := __mtlsGenerateCA(hostname) + assert.NoError(t, err, "generate CA should not error") + + clientCertPEM, clientKeyPEM, err := __mtlsGenerateClientCert(hostname, caCertPEM, caKeyPEM) + assert.NoError(t, err, "generate client cert should not error") + assert.NotNil(t, clientCertPEM, "client cert should not be nil") + assert.NotNil(t, clientKeyPEM, "client key should not be nil") + + block, _ := pem.Decode(clientCertPEM) + assert.NotNil(t, block, "client cert PEM should decode") + + cert, err := x509.ParseCertificate(block.Bytes) + assert.NoError(t, err, "client cert should parse") + assert.False(t, cert.IsCA, "cert should not be CA") + assert.Contains(t, cert.Subject.CommonName, hostname, "cert CN should contain hostname") + assert.Contains(t, cert.ExtKeyUsage, x509.ExtKeyUsageClientAuth, "cert should have client auth usage") +} + +func Test_IsCertificateExpired(t *testing.T) { + hostname := "finch." + gofakeit.DomainName() + + caCertPEM, _, err := __mtlsGenerateCA(hostname) + assert.NoError(t, err, "generate CA should not error") + + expired, err := __mtlsIsCertificateExpired(caCertPEM) + assert.NoError(t, err, "check expiration should not error") + assert.False(t, expired, "newly created cert should not be expired") +} + +func Test_SaveAndLoadCertificate(t *testing.T) { + hostname := "finch." + gofakeit.DomainName() + + caCertPEM, caKeyPEM, err := __mtlsGenerateCA(hostname) + assert.NoError(t, err, "generate CA should not error") + + clientCertPEM, clientKeyPEM, err := __mtlsGenerateClientCert(hostname, caCertPEM, caKeyPEM) + assert.NoError(t, err, "generate client cert should not error") + + tmpDir := t.TempDir() + t.Setenv("FINCH_CONFIG", tmpDir) + + err = config.UpdateStack(hostname, clientCertPEM, clientKeyPEM) + assert.NoError(t, err, "update stack certs should not error") + + loadedCertPEM, loadedKeyPEM, err := config.LookupStack(hostname) + assert.NoError(t, err, "lookup stack certs should not error") + assert.Equal(t, clientCertPEM, loadedCertPEM, "loaded cert should match saved cert") + assert.Equal(t, clientKeyPEM, loadedKeyPEM, "loaded key should match saved key") +} + +func Test_CertificateValidity(t *testing.T) { + hostname := "finch." + gofakeit.DomainName() + + caCertPEM, caKeyPEM, err := __mtlsGenerateCA(hostname) + assert.NoError(t, err, "generate CA should not error") + + clientCertPEM, _, err := __mtlsGenerateClientCert(hostname, caCertPEM, caKeyPEM) + assert.NoError(t, err, "generate client cert should not error") + + block, _ := pem.Decode(clientCertPEM) + cert, err := x509.ParseCertificate(block.Bytes) + assert.NoError(t, err, "client cert should parse") + + assert.True(t, cert.NotBefore.Before(time.Now()), "cert should be valid from the past") + assert.True(t, cert.NotAfter.After(time.Now()), "cert should be valid in the future") + + minValidity := time.Now().Add(87 * 24 * time.Hour) + assert.True(t, cert.NotAfter.After(minValidity), "cert should be valid for at least 87 days") +} diff --git a/internal/service/service.go b/internal/service/service.go index 440b990..ababc2c 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -28,8 +28,6 @@ type ServiceConfig struct { Enabled bool Email string } - Username string - Password string CustomTLS struct { Enabled bool CertFilePath string @@ -38,17 +36,13 @@ type ServiceConfig struct { } type FinchConfig struct { - CreatedAt string `json:"created_at"` - Id string `json:"id"` - Database string `json:"database"` - Profiler string `json:"profiler"` - Secret string `json:"secret"` - Hostname string `json:"hostname"` - Version string `json:"version"` - Credentials struct { - Username string `json:"username"` - Password string `json:"password"` - } `json:"credentials"` + CreatedAt string `json:"created_at"` + Id string `json:"id"` + Database string `json:"database"` + Profiler string `json:"profiler"` + Secret string `json:"secret"` + Hostname string `json:"hostname"` + Version string `json:"version"` } func New(config *ServiceConfig, targetUrl string, format target.Format, dryRun bool) (*Service, error) { @@ -88,7 +82,7 @@ func (s *Service) Teardown() error { return nil } - return config.RemoveStackAuth(s.config.Hostname) + return config.RemoveStack(s.config.Hostname) } func (s *Service) Deploy() error { @@ -110,11 +104,7 @@ func (s *Service) Deploy() error { return err } - if s.dryRun { - return nil - } - - return config.UpdateStackAuth(s.config.Hostname, s.config.Username, s.config.Password) + return nil } func (s *Service) Update() error { diff --git a/internal/service/service_test.go b/internal/service/service_test.go index a684cc6..d9063a0 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -29,7 +29,7 @@ func Test_Deploy(t *testing.T) { assert.NoError(t, err, "deploy service") tracks := strings.Split(record, "\n") - assert.Len(t, tracks, 43, "number of log lines") + assert.Len(t, tracks, 42, "number of log lines") wanted := "Running 'command -v sudo' as .+@localhost" assert.Regexp(t, wanted, tracks[0], "first log line") diff --git a/internal/service/update.go b/internal/service/update.go index a874393..586a93b 100644 --- a/internal/service/update.go +++ b/internal/service/update.go @@ -7,6 +7,8 @@ package service import ( "encoding/json" "fmt" + + "github.com/tschaefer/finchctl/internal/config" ) func (s *Service) __updateSetTargetConfiguration() error { @@ -26,8 +28,6 @@ func (s *Service) __updateSetTargetConfiguration() error { } s.config.Hostname = config.Hostname - s.config.Username = config.Credentials.Username - s.config.Password = config.Credentials.Password yaml := fmt.Sprintf("%s/traefik/etc/conf.d/letsencrypt.yaml", s.libDir()) _, err = s.target.Run(fmt.Sprintf("test -e %s", yaml)) @@ -38,6 +38,28 @@ func (s *Service) __updateSetTargetConfiguration() error { return nil } +func (s *Service) __updateRegenerateMTLSCertificatesIfNeeded() error { + certPEM, _, err := config.LookupStack(s.config.Hostname) + + needsRegeneration := false + if err != nil { + needsRegeneration = true + } else { + expired, err := __mtlsIsCertificateExpired(certPEM) + if err != nil || expired { + needsRegeneration = true + } + } + + if needsRegeneration { + if err := s.__deployGenerateMTLSCertificates(); err != nil { + return convertError(err, &UpdateServiceError{}) + } + } + + return nil +} + func (s *Service) __updateRecomposeDockerServices() error { err := s.__deployCopyComposeFile() if err != nil { @@ -71,6 +93,10 @@ func (s *Service) updateService() error { return err } + if err := s.__updateRegenerateMTLSCertificatesIfNeeded(); err != nil { + return err + } + if err := s.__deployMakeDirHierarchy(); err != nil { return convertError(err, &UpdateServiceError{}) }