diff --git a/cmd/serum/main.go b/cmd/serum/main.go deleted file mode 100644 index 9f93a09..0000000 --- a/cmd/serum/main.go +++ /dev/null @@ -1,129 +0,0 @@ -package main - -import ( - "bufio" - "context" - "flag" - "fmt" - "log" - "os" - "regexp" - "strings" - - secretmanager "cloud.google.com/go/secretmanager/apiv1" - secretmanagerpb "google.golang.org/genproto/googleapis/cloud/secretmanager/v1" -) - -var secretRe *regexp.Regexp - -const secretRegex = `^(?P.+)=\${(?P.+)}$` - -func init() { - //TODO: remove this global - secretRe = regexp.MustCompile(secretRegex) -} - -func main() { - var env string - - inject := flag.NewFlagSet("inject", flag.ExitOnError) - inject.StringVar(&env, "env", "local", "the current running environment") - inject.StringVar(&env, "e", "local", "shorthand for env") - - flag.Parse() - - args := flag.Args() - //TODO: args validation - //0 is the inject arg - filePath := args[1] - - if err := parseFile(filePath); err != nil { - log.Fatalf("error injecting config: %s\n", err) - } -} - -func parseFile(fp string) error { - f, err := os.Open(fp) - if err != nil { - return fmt.Errorf("error opening file %s: %s", fp, err) - } - defer f.Close() - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - val, err := parseLine(line) - if err != nil { - return fmt.Errorf("error parsing line: %s: %s", line, err) - } - if val == "" { - //line is a comment, skip it - continue - } - - fmt.Printf("parsed value: %s\n", val) - } - if err := scanner.Err(); err != nil { - return fmt.Errorf("error parsing config file: %s", err) - } - - return nil -} - -func parseLine(l string) (string, error) { - //ignore commented line - if strings.HasPrefix(l, "#") { - return "", nil - } - //TODO: ignore inline comments - - res := secretRe.FindStringSubmatch(l) - if res == nil { - return l, nil - } - - //0 index is for the entire match - key := res[1] - secret := res[2] - - //lookup secret in secret manager - c, err := secretmanager.NewClient(context.Background()) - if err != nil { - return "", err - } - - req := &secretmanagerpb.AccessSecretVersionRequest{ - Name: secret, - } - - result, err := c.AccessSecretVersion(context.Background(), req) - if err != nil { - return "", fmt.Errorf("failed to access secret version: %v", err) - } - - return fmt.Sprintf("%s=%s", key, result.Payload.Data), nil -} - -//split line into two pieces based on key value seperator "=" -// splits := strings.SplitN(l, "=", 2) -// if len(splits) != 2 { -// return "", fmt.Errorf("invalid config format %s", l) -// } - -// //key is first index, value is second -// key := strings.TrimSpace(splits[0]) -// value := strings.TrimSpace(splits[1]) -// //TODO: ignore inline comments - -// //check if value matches a secret that needs to be replaced using the secret manager -// regex, err := regexp.Compile(secretRegex) -// if err != nil { -// return "", fmt.Errorf("error compiling secret regex") -// } - -// if regex.Match([]byte(value)) { -// value, err := lookupSecret(value) -// } - -// return fmt.Sprintf("") -// } diff --git a/internal/envparser/envparser.go b/internal/envparser/envparser.go new file mode 100644 index 0000000..6e664bd --- /dev/null +++ b/internal/envparser/envparser.go @@ -0,0 +1,85 @@ +package envparser + +import ( + "bufio" + "fmt" + "os" + "regexp" + "strings" +) + +const ( + commentToken = "#" + kvSeparator = "=" + secretRegex = `^${(?P.+)}$` +) + +var secretRe *regexp.Regexp + +func init() { + secretRe = regexp.MustCompile(secretRegex) +} + +//EnvVars contains the plain text key value mappings as well as the encrypted secret key value mappings +//parsed from an env file +type EnvVars struct { + Plain map[string]string + Secrets map[string]string +} + +//ParseFile parses a .env file at path and returns the key value +//mappings for plain text variables and secret variables +func ParseFile(path string) (*EnvVars, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("error opening file %s: %s", path, err) + } + defer f.Close() + + envVars := &EnvVars{ + Plain: make(map[string]string), + Secrets: make(map[string]string), + } + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if err := parseLine(envVars, line); err != nil { + return nil, fmt.Errorf("error parsing line: %s: %s", line, err) + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error parsing file: %s", err) + } + + return envVars, nil +} + +func parseLine(envVars *EnvVars, l string) error { + //ignore commented line + //TODO: remove inline comments + if strings.HasPrefix(l, commentToken) { + return nil + } + + //split line into two pieces (k,v) based on key value seperator + splits := strings.SplitN(l, kvSeparator, 2) + if len(splits) != 2 { + return fmt.Errorf("invalid format %s", l) + } + + //key is first index, value is second + k := strings.TrimSpace(splits[0]) + v := strings.TrimSpace(splits[1]) + + //check if value is encrypted secret + if secretRe.MatchString(v) { + //fill in secret value - replace template value with capture group "secretval" + envVars.Secrets[k] = secretRe.ReplaceAllString(v, "$secretval") + return nil + } + + //not a secret, fill in plain text value + envVars.Plain[k] = v + return nil +} diff --git a/secretprovider/gsmanager.go b/secretprovider/gsmanager.go new file mode 100644 index 0000000..4f99a93 --- /dev/null +++ b/secretprovider/gsmanager.go @@ -0,0 +1,44 @@ +package secretprovider + +import ( + "context" + "fmt" + + secretmanager "cloud.google.com/go/secretmanager/apiv1" + secretmanagerpb "google.golang.org/genproto/googleapis/cloud/secretmanager/v1" +) + +//GSManager is a secret provider that communicates with Google Cloud Platform's Secret Manager +//to decrypt secrets. Internally it uses the Google Cloud SDK. +type GSManager struct { + smClient *secretmanager.Client +} + +//NewGSManager return's an initialized GSManager using a new secret manager client. +func NewGSManager() (*GSManager, error) { + c, err := secretmanager.NewClient(context.Background()) + if err != nil { + return nil, fmt.Errorf("gsmanager: failed to initialize client: %w", err) + } + + return &GSManager{smClient: c}, nil +} + +//Decrypt will access the secret on GCP Secret Manager and return the plain text string. +func (g *GSManager) Decrypt(secret string) (string, error) { + req := &secretmanagerpb.AccessSecretVersionRequest{ + Name: secret, + } + + result, err := g.smClient.AccessSecretVersion(context.Background(), req) + if err != nil { + return "", fmt.Errorf("gsmanager: failed to access secret version: %w", err) + } + + return result.Payload.String(), nil +} + +//Close closes the connection to the secret manager API. +func (g *GSManager) Close() error { + return g.smClient.Close() +} diff --git a/secretprovider/secretprovider.go b/secretprovider/secretprovider.go new file mode 100644 index 0000000..de46550 --- /dev/null +++ b/secretprovider/secretprovider.go @@ -0,0 +1,9 @@ +package secretprovider + +//SecretProvider is an interface that wraps the decrypt and close methods. +//Close should be called when the secret provier is no longer needed. +//It may be a no-op in cases where there's no underlying connection to be closed. +type SecretProvider interface { + Decrypt(secret string) (string, error) + Close() error +} diff --git a/serum/serum.go b/serum/serum.go new file mode 100644 index 0000000..02c56b9 --- /dev/null +++ b/serum/serum.go @@ -0,0 +1,56 @@ +package serum + +import ( + "fmt" + "os" + "wingocard/serum/internal/envparser" + "wingocard/serum/secretprovider" +) + +//Injector injects environment variables into the current running process. Key/value pairs can +//be read in from a .env file using the load method. +type Injector struct { + SecretProvider secretprovider.SecretProvider + envVars *envparser.EnvVars +} + +//Inject will inject the loaded environment variables into the current running process' environment. +//Any secret values found will attempt to be decrypted using the provided secret provider. +//The presence of secrets with a nil SecretProvider will return an error. +func (in *Injector) Inject() error { + if len(in.envVars.Secrets) > 0 && in.SecretProvider == nil { + return fmt.Errorf("serum: error injecting env vars: secrets were loaded but the SecretProvider is nil") + } + + //inject secrets + for k, v := range in.envVars.Secrets { + decrypted, err := in.SecretProvider.Decrypt(v) + if err != nil { + return fmt.Errorf("serum: error decrypting secret %s: %s", v, err) + } + + if err := os.Setenv(k, decrypted); err != nil { + return fmt.Errorf("serum: error setting env var %s: %s", k, err) + } + } + + //inject plain text vars + for k, v := range in.envVars.Plain { + if err := os.Setenv(k, v); err != nil { + return fmt.Errorf("serum: error setting env var %s: %s", k, err) + } + } + return nil +} + +//Load will parse a .env file for key/value pairs and prepair them to be injected using the +//Inject method. +func (in *Injector) Load(path string) error { + envVars, err := envparser.ParseFile(path) + if err != nil { + return fmt.Errorf("serum: error loading env vars: %s", err) + } + + in.envVars = envVars + return nil +}