forked from toolshed/abra
		
	
		
			
				
	
	
		
			340 lines
		
	
	
		
			9.3 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			340 lines
		
	
	
		
			9.3 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
// 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"
 | 
						|
	"errors"
 | 
						|
	"fmt"
 | 
						|
	"slices"
 | 
						|
	"strconv"
 | 
						|
	"strings"
 | 
						|
	"sync"
 | 
						|
 | 
						|
	appPkg "coopcloud.tech/abra/pkg/app"
 | 
						|
	"coopcloud.tech/abra/pkg/client"
 | 
						|
	"coopcloud.tech/abra/pkg/config"
 | 
						|
	"coopcloud.tech/abra/pkg/envfile"
 | 
						|
	"coopcloud.tech/abra/pkg/i18n"
 | 
						|
	"coopcloud.tech/abra/pkg/log"
 | 
						|
	"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"
 | 
						|
)
 | 
						|
 | 
						|
// 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
 | 
						|
	// Charset comes from the charset modifier at the secret version environment
 | 
						|
	// variable. For Example:
 | 
						|
	//   SECRET_FOO=v1 # charset=default,special
 | 
						|
	Charset string
 | 
						|
	// Whether or not to skip generation of the secret or not
 | 
						|
	// For example: SECRET_FOO=v1 # generate=false
 | 
						|
	SkipGenerate bool
 | 
						|
	// 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
 | 
						|
 | 
						|
	// LocalName iis the name of the secret in the recipe config. This is also
 | 
						|
	// the name that you pass to `abra app secret insert` and is shown on `abra
 | 
						|
	// app secret list`
 | 
						|
	LocalName string
 | 
						|
}
 | 
						|
 | 
						|
// GeneratePassword generates passwords.
 | 
						|
func GeneratePassword(length uint, charset string) (string, error) {
 | 
						|
	passwords, err := passgen.GeneratePasswords(1, length, charset)
 | 
						|
	if err != nil {
 | 
						|
		return "", err
 | 
						|
	}
 | 
						|
 | 
						|
	log.Debug(i18n.G("generated %s", strings.Join(passwords, ", ")))
 | 
						|
 | 
						|
	return passwords[0], nil
 | 
						|
}
 | 
						|
 | 
						|
// GeneratePassphrase generates human readable and rememberable passphrases.
 | 
						|
func GeneratePassphrase() (string, error) {
 | 
						|
	passphrases, err := passgen.GeneratePassphrases(
 | 
						|
		1,
 | 
						|
		passgen.PassphraseWordCountDefault,
 | 
						|
		rune('-'),
 | 
						|
		passgen.PassphraseCasingDefault,
 | 
						|
		passgen.WordListDefault,
 | 
						|
	)
 | 
						|
	if err != nil {
 | 
						|
		return "", err
 | 
						|
	}
 | 
						|
 | 
						|
	log.Debug(i18n.G("generated %s", strings.Join(passphrases, ", ")))
 | 
						|
 | 
						|
	return passphrases[0], 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 := envfile.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}
 | 
						|
	composeConfig, 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 composeConfig.Services {
 | 
						|
		for _, secret := range service.Secrets {
 | 
						|
			enabledSecrets = append(enabledSecrets, secret.Source)
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	if len(enabledSecrets) == 0 {
 | 
						|
		log.Debug(i18n.G("not generating app secrets, none enabled in recipe config"))
 | 
						|
		return nil, nil
 | 
						|
	}
 | 
						|
 | 
						|
	secretValues := map[string]Secret{}
 | 
						|
	for secretId, secretConfig := range composeConfig.Secrets {
 | 
						|
		if string(secretConfig.Name[len(secretConfig.Name)-1]) == "_" {
 | 
						|
			return nil, errors.New(i18n.G("missing version for secret? (%s)", secretId))
 | 
						|
		}
 | 
						|
 | 
						|
		if !(slices.Contains(enabledSecrets, secretId)) {
 | 
						|
			log.Warnf(i18n.G("%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,
 | 
						|
			LocalName:  secretId,
 | 
						|
		}
 | 
						|
 | 
						|
		if len(value.RemoteName) > config.MAX_DOCKER_SECRET_LENGTH {
 | 
						|
			return nil, errors.New(i18n.G("secret %s is > %d chars when combined with %s", secretId, config.MAX_DOCKER_SECRET_LENGTH, stackName))
 | 
						|
		}
 | 
						|
 | 
						|
		// 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
 | 
						|
			}
 | 
						|
 | 
						|
			generateRaw, ok := modifierValues["generate"]
 | 
						|
			if ok {
 | 
						|
				if generateRaw == "false" {
 | 
						|
					value.SkipGenerate = true
 | 
						|
				}
 | 
						|
			}
 | 
						|
 | 
						|
			value.Charset = resolveCharset(modifierValues["charset"])
 | 
						|
			break
 | 
						|
		}
 | 
						|
		secretValues[secretId] = value
 | 
						|
	}
 | 
						|
 | 
						|
	return secretValues, nil
 | 
						|
}
 | 
						|
 | 
						|
// resolveCharset sets the passgen Alphabet required for a secret
 | 
						|
func resolveCharset(input string) string {
 | 
						|
	switch strings.ToLower(input) {
 | 
						|
	case "special":
 | 
						|
		return passgen.AlphabetSpecial
 | 
						|
	case "safespecial":
 | 
						|
		return "!@#%^&*_-+="
 | 
						|
	case "default,special", "special,default":
 | 
						|
		return passgen.AlphabetDefault + passgen.AlphabetSpecial
 | 
						|
	case "default,safespecial", "safespecial,default":
 | 
						|
		return passgen.AlphabetDefault + "!@#%^&*_-+="
 | 
						|
	default:
 | 
						|
		return passgen.AlphabetDefault // Fallback to default
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
// 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()
 | 
						|
 | 
						|
			if secret.SkipGenerate {
 | 
						|
				log.Debug(i18n.G("skipping generation of %s (generate=false)", secretName))
 | 
						|
				ch <- nil
 | 
						|
				return
 | 
						|
			}
 | 
						|
 | 
						|
			log.Debug(i18n.G("attempting to generate and store %s on %s", secret.RemoteName, server))
 | 
						|
 | 
						|
			if secret.Length > 0 {
 | 
						|
				password, err := GeneratePassword(uint(secret.Length), secret.Charset)
 | 
						|
				if err != nil {
 | 
						|
					ch <- err
 | 
						|
					return
 | 
						|
				}
 | 
						|
 | 
						|
				if err := client.StoreSecret(cl, secret.RemoteName, password); err != nil {
 | 
						|
					if strings.Contains(err.Error(), "AlreadyExists") {
 | 
						|
						log.Warnf(i18n.G("%s already exists", secret.RemoteName))
 | 
						|
						ch <- nil
 | 
						|
					} else {
 | 
						|
						ch <- err
 | 
						|
					}
 | 
						|
					return
 | 
						|
				}
 | 
						|
 | 
						|
				mutex.Lock()
 | 
						|
				defer mutex.Unlock()
 | 
						|
				secretsGenerated[secretName] = password
 | 
						|
			} else {
 | 
						|
				passphrase, err := GeneratePassphrase()
 | 
						|
				if err != nil {
 | 
						|
					ch <- err
 | 
						|
					return
 | 
						|
				}
 | 
						|
 | 
						|
				if err := client.StoreSecret(cl, secret.RemoteName, passphrase); err != nil {
 | 
						|
					if strings.Contains(err.Error(), "AlreadyExists") {
 | 
						|
						log.Warnf(i18n.G("%s already exists", secret.RemoteName))
 | 
						|
						ch <- nil
 | 
						|
					} else {
 | 
						|
						ch <- err
 | 
						|
					}
 | 
						|
					return
 | 
						|
				}
 | 
						|
 | 
						|
				mutex.Lock()
 | 
						|
				defer mutex.Unlock()
 | 
						|
				secretsGenerated[secretName] = passphrase
 | 
						|
			}
 | 
						|
			ch <- nil
 | 
						|
		}(n, v)
 | 
						|
	}
 | 
						|
 | 
						|
	wg.Wait()
 | 
						|
 | 
						|
	for range secrets {
 | 
						|
		err := <-ch
 | 
						|
		if err != nil {
 | 
						|
			return nil, err
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	log.Debug(i18n.G("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 deployed server state.
 | 
						|
func PollSecretsStatus(cl *dockerClient.Client, app appPkg.App) (secretStatuses, error) {
 | 
						|
	var secStats secretStatuses
 | 
						|
 | 
						|
	composeFiles, err := app.Recipe.GetComposeFiles(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.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
 | 
						|
}
 |