// Package secret provides functionality for generating and storing secrets // both in a remote swarm and locally within supported storage such as pass // stores. package secret import ( "context" "fmt" "slices" "strconv" "strings" "sync" "coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/upstream/stack" loader "coopcloud.tech/abra/pkg/upstream/stack" "github.com/decentral1se/passgen" "github.com/docker/docker/api/types" dockerClient "github.com/docker/docker/client" "github.com/sirupsen/logrus" ) // Secret represents a secret. type Secret struct { // Version comes from the secret version environment variable. // For example: // SECRET_FOO=v1 Version string // Length comes from the length modifier at the secret version environment // variable. For Example: // SECRET_FOO=v1 # length=12 Length int // RemoteName is the name of the secret on the server. For example: // name: ${STACK_NAME}_test_pass_two_${SECRET_TEST_PASS_TWO_VERSION} // With the following: // STACK_NAME=test_example_com // SECRET_TEST_PASS_TWO_VERSION=v2 // Will have this remote name: // test_example_com_test_pass_two_v2 RemoteName string } // GeneratePasswords generates passwords. func GeneratePasswords(count, length uint) ([]string, error) { passwords, err := passgen.GeneratePasswords( count, length, passgen.AlphabetDefault, ) if err != nil { return nil, err } logrus.Debugf("generated %s", strings.Join(passwords, ", ")) return passwords, nil } // GeneratePassphrases generates human readable and rememberable passphrases. func GeneratePassphrases(count uint) ([]string, error) { passphrases, err := passgen.GeneratePassphrases( count, passgen.PassphraseWordCountDefault, rune('-'), passgen.PassphraseCasingDefault, passgen.WordListDefault, ) if err != nil { return nil, err } logrus.Debugf("generated %s", strings.Join(passphrases, ", ")) return passphrases, nil } // ReadSecretsConfig reads secret names/versions from the recipe config. The // function generalises appEnv/composeFiles because some times you have an app // and some times you don't (as the caller). We need to be able to handle the // "app new" case where we pass in the .env.sample and the "secret generate" // case where the app is created. func ReadSecretsConfig(appEnvPath string, composeFiles []string, stackName string) (map[string]Secret, error) { appEnv, appModifiers, err := config.ReadEnvWithModifiers(appEnvPath) if err != nil { return nil, err } // Set the STACK_NAME to be able to generate the remote name correctly. appEnv["STACK_NAME"] = stackName opts := stack.Deploy{Composefiles: composeFiles} config, err := loader.LoadComposefile(opts, appEnv) if err != nil { return nil, err } // Read the compose files without injecting environment variables. configWithoutEnv, err := loader.LoadComposefile(opts, map[string]string{}, loader.SkipInterpolation) if err != nil { return nil, err } var enabledSecrets []string for _, service := range config.Services { for _, secret := range service.Secrets { enabledSecrets = append(enabledSecrets, secret.Source) } } if len(enabledSecrets) == 0 { logrus.Debugf("not generating app secrets, none enabled in recipe config") return nil, nil } secretValues := map[string]Secret{} for secretId, secretConfig := range config.Secrets { if string(secretConfig.Name[len(secretConfig.Name)-1]) == "_" { return nil, fmt.Errorf("missing version for secret? (%s)", secretId) } if !(slices.Contains(enabledSecrets, secretId)) { logrus.Warnf("%s not enabled in recipe config, skipping", secretId) continue } lastIdx := strings.LastIndex(secretConfig.Name, "_") secretVersion := secretConfig.Name[lastIdx+1:] value := Secret{Version: secretVersion, RemoteName: secretConfig.Name} // Check if the length modifier is set for this secret. for envName, modifierValues := range appModifiers { // configWithoutEnv contains the raw name as defined in the compose.yaml // The name will look something like this: // name: ${STACK_NAME}_test_pass_two_${SECRET_TEST_PASS_TWO_VERSION} // To check if the current modifier is for the current secret we check // if the raw name contains the env name (e.g. SECRET_TEST_PASS_TWO_VERSION). if !strings.Contains(configWithoutEnv.Secrets[secretId].Name, envName) { continue } lengthRaw, ok := modifierValues["length"] if ok { length, err := strconv.Atoi(lengthRaw) if err != nil { return nil, err } value.Length = length } break } secretValues[secretId] = value } return secretValues, nil } // GenerateSecrets generates secrets locally and sends them to a remote server for storage. func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server string) (map[string]string, error) { secretsGenerated := map[string]string{} var mutex sync.Mutex var wg sync.WaitGroup ch := make(chan error, len(secrets)) for n, v := range secrets { wg.Add(1) go func(secretName string, secret Secret) { defer wg.Done() logrus.Debugf("attempting to generate and store %s on %s", secret.RemoteName, server) if secret.Length > 0 { passwords, err := GeneratePasswords(1, uint(secret.Length)) if err != nil { ch <- err return } if err := client.StoreSecret(cl, secret.RemoteName, passwords[0], server); err != nil { if strings.Contains(err.Error(), "AlreadyExists") { logrus.Warnf("%s already exists, moving on...", secret.RemoteName) ch <- nil } else { ch <- err } return } mutex.Lock() defer mutex.Unlock() secretsGenerated[secretName] = passwords[0] } else { passphrases, err := GeneratePassphrases(1) if err != nil { ch <- err return } if err := client.StoreSecret(cl, secret.RemoteName, passphrases[0], server); err != nil { if strings.Contains(err.Error(), "AlreadyExists") { logrus.Warnf("%s already exists, moving on...", secret.RemoteName) ch <- nil } else { ch <- err } return } mutex.Lock() defer mutex.Unlock() secretsGenerated[secretName] = passphrases[0] } ch <- nil }(n, v) } wg.Wait() for range secrets { err := <-ch if err != nil { return nil, err } } logrus.Debugf("generated and stored %v on %s", secrets, server) return secretsGenerated, nil } type secretStatus struct { LocalName string RemoteName string Version string CreatedOnRemote bool } type secretStatuses []secretStatus // PollSecretsStatus checks status of secrets by comparing the local recipe // config and deploymend server state. func PollSecretsStatus(cl *dockerClient.Client, app config.App) (secretStatuses, error) { var secStats secretStatuses composeFiles, err := config.GetComposeFiles(app.Recipe, app.Env) if err != nil { return secStats, err } secretsConfig, err := ReadSecretsConfig(app.Path, composeFiles, app.StackName()) if err != nil { return secStats, err } filters, err := app.Filters(false, false) if err != nil { return secStats, err } secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filters}) if err != nil { return secStats, err } remoteSecretNames := make(map[string]bool) for _, cont := range secretList { remoteSecretNames[cont.Spec.Annotations.Name] = true } for secretName, val := range secretsConfig { createdRemote := false secretRemoteName := fmt.Sprintf("%s_%s_%s", app.StackName(), secretName, val.Version) if _, ok := remoteSecretNames[secretRemoteName]; ok { createdRemote = true } secStats = append(secStats, secretStatus{ LocalName: secretName, RemoteName: secretRemoteName, Version: val.Version, CreatedOnRemote: createdRemote, }) } return secStats, nil }