diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 9be09661..a04ee10a 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -28,3 +28,7 @@ func TestHelperProcess(t *testing.T) { fmt.Fprint(os.Stdout, strings.Join(os.Args[3:], " ")) os.Exit(0) } + +func TestCommandHelperProcess(t *testing.T) { + command.CommandHelperProcess(t) +} diff --git a/cmd/new.go b/cmd/new.go index 2219a2cb..5ac3ca57 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -167,13 +167,12 @@ func (c *NewCommand) Run(args []string) int { c.UI.Error("This is probably a trellis-cli bug. Please open an issue at: https://github.com/roots/trellis-cli") } - stringGenerator := trellis.RandomStringGenerator{Length: 64} - vault := c.trellis.GenerateVaultConfig(c.name, env, &stringGenerator) - _ = c.trellis.WriteYamlFile( - vault, - filepath.Join("group_vars", env, "vault.yml"), - c.YamlHeader("https://roots.io/trellis/docs/vault/"), - ) + vaultPath := filepath.Join("group_vars", env, "vault.yml") + if err := c.trellis.UpdateDefaultVaultFile(vaultPath, c.name, env, c.createdByComment()); err != nil { + c.UI.Error(fmt.Sprintf("Error updating %s: %s", vaultPath, err)) + c.UI.Error("Your project may not work correctly.") + c.UI.Error("This is probably a trellis-cli bug. Please open an issue at: https://github.com/roots/trellis-cli") + } } if err := c.trellis.GenerateVaultPassFile(c.vaultPass); err != nil { @@ -260,10 +259,6 @@ Options: return strings.TrimSpace(helpText) } -func (c *NewCommand) YamlHeader(doc string) string { - return fmt.Sprintf("%s\n# Documentation: %s\n\n", c.createdByComment(), doc) -} - func (c *NewCommand) createdByComment() string { return fmt.Sprintf("# Created by trellis-cli v%s", c.CliVersion) } diff --git a/cmd/vault_generate.go b/cmd/vault_generate.go new file mode 100644 index 00000000..85653702 --- /dev/null +++ b/cmd/vault_generate.go @@ -0,0 +1,230 @@ +package cmd + +import ( + "bytes" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/fatih/color" + "github.com/hashicorp/cli" + "github.com/posener/complete" + "github.com/roots/trellis-cli/command" + "github.com/roots/trellis-cli/trellis" + "github.com/roots/trellis-cli/trellis/yamledit" +) + +type VaultGenerateCommand struct { + UI cli.Ui + Trellis *trellis.Trellis + flags *flag.FlagSet + force bool + saltsOnly bool +} + +func NewVaultGenerateCommand(ui cli.Ui, trellis *trellis.Trellis) *VaultGenerateCommand { + c := &VaultGenerateCommand{UI: ui, Trellis: trellis} + c.init() + return c +} + +func (c *VaultGenerateCommand) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + c.flags.Usage = func() { c.UI.Info(c.Help()) } + c.flags.BoolVar(&c.force, "force", false, "Regenerate secrets that are already set") + c.flags.BoolVar(&c.saltsOnly, "salts-only", false, "Only generate WordPress keys and salts") +} + +func (c *VaultGenerateCommand) Run(args []string) int { + if err := c.Trellis.LoadProject(); err != nil { + c.UI.Error(err.Error()) + return 1 + } + + if err := c.flags.Parse(args); err != nil { + return 1 + } + + args = c.flags.Args() + + commandArgumentValidator := &CommandArgumentValidator{required: 0, optional: 1} + commandArgumentErr := commandArgumentValidator.validate(args) + if commandArgumentErr != nil { + c.UI.Error(commandArgumentErr.Error()) + c.UI.Output(c.Help()) + return 1 + } + + environments := c.Trellis.EnvironmentNames() + + if len(args) == 1 { + if err := c.Trellis.ValidateEnvironment(args[0]); err != nil { + c.UI.Error(err.Error()) + return 1 + } + + environments = []string{args[0]} + } else if c.force { + c.UI.Error("Error: the --force option requires an ENVIRONMENT argument\n") + c.UI.Output(c.Help()) + return 1 + } + + opts := trellis.VaultGenerateOptions{Force: c.force, SaltsOnly: c.saltsOnly} + + for _, environment := range environments { + if err := c.generate(environment, opts); err != nil { + c.UI.Error(fmt.Sprintf("Error generating secrets for %s: %s", environment, err)) + return 1 + } + } + + return 0 +} + +func (c *VaultGenerateCommand) generate(environment string, opts trellis.VaultGenerateOptions) error { + path := filepath.Join("group_vars", environment, "vault.yml") + + data, encrypted, err := c.readVault(path) + if err != nil { + return err + } + + doc, err := yamledit.Parse(data) + if err != nil { + return err + } + + randomString := trellis.RandomStringGenerator{Length: 64} + sites := c.Trellis.SiteNamesFromEnvironment(environment) + + generated, err := trellis.GenerateVaultSecrets(doc, environment, sites, &randomString, opts) + if err != nil { + return err + } + + if len(generated) == 0 { + c.UI.Info(fmt.Sprintf("No secrets to generate in %s", path)) + return nil + } + + if encrypted { + data, err = doc.Bytes() + if err == nil { + err = c.encryptVault(path, data) + } + } else { + err = doc.Save(path) + } + + if err != nil { + return err + } + + c.UI.Info(color.GreenString(fmt.Sprintf("Generated %d secrets in %s", len(generated), path))) + + return nil +} + +// readVault returns the plaintext contents of a vault file, decrypting it +// with ansible-vault if needed. A missing file is treated as empty. +func (c *VaultGenerateCommand) readVault(path string) (data []byte, encrypted bool, err error) { + encrypted, err = trellis.IsFileEncrypted(path) + if os.IsNotExist(err) { + return nil, false, nil + } + + if err != nil { + return nil, false, err + } + + if !encrypted { + data, err = os.ReadFile(path) + return data, false, err + } + + c.Trellis.CheckVirtualenv(c.UI) + + var stdout, stderr bytes.Buffer + decrypt := command.Cmd("ansible-vault", []string{"decrypt", "--output", "-", path}) + decrypt.Stdout = &stdout + decrypt.Stderr = &stderr + + if err := decrypt.Run(); err != nil { + return nil, true, fmt.Errorf("could not decrypt %s: %s", path, strings.TrimSpace(stderr.String())) + } + + return stdout.Bytes(), true, nil +} + +// encryptVault encrypts data into path by piping it to ansible-vault so the +// plaintext never touches disk. +func (c *VaultGenerateCommand) encryptVault(path string, data []byte) error { + var stderr bytes.Buffer + encrypt := command.Cmd("ansible-vault", []string{"encrypt", "--output", path}) + encrypt.Stdin = bytes.NewReader(data) + encrypt.Stdout = &stderr + encrypt.Stderr = &stderr + + if err := encrypt.Run(); err != nil { + return fmt.Errorf("could not encrypt %s: %s", path, strings.TrimSpace(stderr.String())) + } + + return nil +} + +func (c *VaultGenerateCommand) Synopsis() string { + return "Generates passwords, keys, and salts in vault files" +} + +func (c *VaultGenerateCommand) Help() string { + helpText := ` +Usage: trellis vault generate [options] [ENVIRONMENT] + +Generates secrets (passwords and WordPress keys and salts) in group_vars//vault.yml files. + +Only secrets that are missing, empty, or placeholders (such as "generateme" or the example +passwords in Trellis' default vault files) are generated, so it's safe to run multiple times. +Templated values (containing "{{") are never replaced. + +Encrypted vault files are decrypted, updated, and re-encrypted with your project's vault password. + +Trellis docs: https://roots.io/trellis/docs/vault/ + +Generate missing secrets for all environments: + + $ trellis vault generate + +Generate missing secrets for production: + + $ trellis vault generate production + +Rotate WordPress keys and salts for production (logs out all users): + + $ trellis vault generate --force --salts-only production + +Arguments: + [ENVIRONMENT] Name of environment (ie: production) + +Options: + --force Regenerate all secrets, including ones already set. Requires ENVIRONMENT. + Changed database or user passwords take effect after re-provisioning the server. + --salts-only Only generate WordPress keys and salts + -h, --help Show this help +` + + return strings.TrimSpace(helpText) +} + +func (c *VaultGenerateCommand) AutocompleteArgs() complete.Predictor { + return c.Trellis.AutocompleteEnvironment(c.flags) +} + +func (c *VaultGenerateCommand) AutocompleteFlags() complete.Flags { + return complete.Flags{ + "--force": complete.PredictNothing, + "--salts-only": complete.PredictNothing, + } +} diff --git a/cmd/vault_generate_test.go b/cmd/vault_generate_test.go new file mode 100644 index 00000000..926dbac0 --- /dev/null +++ b/cmd/vault_generate_test.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "os" + "strings" + "testing" + + "github.com/hashicorp/cli" + "github.com/roots/trellis-cli/command" + "github.com/roots/trellis-cli/trellis" + "github.com/roots/trellis-cli/trellis/yamledit" +) + +func TestVaultGenerateRunValidations(t *testing.T) { + defer trellis.LoadFixtureProject(t)() + + cases := []struct { + name string + projectDetected bool + args []string + out string + code int + }{ + { + "no_project", + false, + nil, + "No Trellis project detected", + 1, + }, + { + "too_many_args", + true, + []string{"production", "foo"}, + "Error: too many arguments", + 1, + }, + { + "invalid_env", + true, + []string{"foo"}, + "Error: foo is not a valid environment", + 1, + }, + { + "force_without_env", + true, + []string{"--force"}, + "Error: the --force option requires an ENVIRONMENT argument", + 1, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var project *trellis.Trellis + if tc.projectDetected { + project = trellis.NewTrellis() + } else { + project = trellis.NewMockTrellis(false) + } + + ui := cli.NewMockUi() + code := NewVaultGenerateCommand(ui, project).Run(tc.args) + + if code != tc.code { + t.Errorf("expected code %d to be %d", code, tc.code) + } + + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + + if !strings.Contains(combined, tc.out) { + t.Errorf("expected output %q to contain %q", combined, tc.out) + } + }) + } +} + +func TestVaultGenerateRun(t *testing.T) { + defer trellis.LoadFixtureProject(t)() + + ui := cli.NewMockUi() + code := NewVaultGenerateCommand(ui, trellis.NewTrellis()).Run([]string{}) + + if code != 0 { + t.Fatalf("expected code 0, got %d: %s", code, ui.ErrorWriter.String()) + } + + output := ui.OutputWriter.String() + + for _, expected := range []string{ + "Generated 10 secrets in group_vars/development/vault.yml", + "Generated 12 secrets in group_vars/production/vault.yml", + "Generated 39 secrets in group_vars/valet-link/vault.yml", + } { + if !strings.Contains(output, expected) { + t.Errorf("expected output %q to contain %q", output, expected) + } + } + + doc, err := yamledit.Load("group_vars/production/vault.yml") + if err != nil { + t.Fatal(err) + } + + if node := doc.Lookup("vault_wordpress_sites", "example.com", "env", "auth_salt"); node == nil || len(node.Value) != 64 { + t.Errorf("expected auth_salt to be generated, got %v", node) + } + + data, _ := os.ReadFile("group_vars/production/vault.yml") + if !strings.Contains(string(data), "# Generate your keys here: https://roots.io/salts.html") { + t.Errorf("expected comments to be preserved, got:\n%s", data) + } + + ui = cli.NewMockUi() + NewVaultGenerateCommand(ui, trellis.NewTrellis()).Run([]string{"production"}) + + if !strings.Contains(ui.OutputWriter.String(), "No secrets to generate in group_vars/production/vault.yml") { + t.Errorf("expected second run to generate nothing, got %q", ui.OutputWriter.String()) + } +} + +func TestVaultGenerateRunEncrypted(t *testing.T) { + defer trellis.LoadFixtureProject(t)() + + path := "group_vars/production/vault.yml" + plaintext, _ := os.ReadFile(path) + + if err := os.WriteFile(path, []byte("$ANSIBLE_VAULT;1.1;AES256\nencrypted\n"), 0644); err != nil { + t.Fatal(err) + } + + defer command.MockExecCommands(t, []command.MockCommand{ + {Command: "ansible-vault", Args: []string{"decrypt", "--output", "-", path}, Output: string(plaintext)}, + {Command: "ansible-vault", Args: []string{"encrypt", "--output", path}}, + })() + + ui := cli.NewMockUi() + code := NewVaultGenerateCommand(ui, trellis.NewTrellis()).Run([]string{"production"}) + + if code != 0 { + t.Fatalf("expected code 0, got %d: %s", code, ui.ErrorWriter.String()) + } + + if !strings.Contains(ui.OutputWriter.String(), "Generated 12 secrets in "+path) { + t.Errorf("unexpected output %q", ui.OutputWriter.String()) + } + + data, _ := os.ReadFile(path) + if !strings.HasPrefix(string(data), "$ANSIBLE_VAULT") { + t.Errorf("expected vault file to never be written in plaintext, got:\n%s", data) + } +} + +func TestVaultGenerateRunDecryptFailure(t *testing.T) { + defer trellis.LoadFixtureProject(t)() + + path := "group_vars/production/vault.yml" + if err := os.WriteFile(path, []byte("$ANSIBLE_VAULT;1.1;AES256\nencrypted\n"), 0644); err != nil { + t.Fatal(err) + } + + defer command.MockExecCommands(t, []command.MockCommand{ + {Command: "ansible-vault", Args: []string{"decrypt", "--output", "-", path}, ExitCode: 1}, + })() + + ui := cli.NewMockUi() + code := NewVaultGenerateCommand(ui, trellis.NewTrellis()).Run([]string{"production"}) + + if code != 1 { + t.Errorf("expected code 1, got %d", code) + } + + if !strings.Contains(ui.ErrorWriter.String(), "could not decrypt "+path) { + t.Errorf("unexpected error output %q", ui.ErrorWriter.String()) + } +} diff --git a/main.go b/main.go index 7c187401..e9c34f85 100644 --- a/main.go +++ b/main.go @@ -164,6 +164,9 @@ func main() { "vault encrypt": func() (cli.Command, error) { return cmd.NewVaultEncryptCommand(ui, trellis), nil }, + "vault generate": func() (cli.Command, error) { + return cmd.NewVaultGenerateCommand(ui, trellis), nil + }, "vault decrypt": func() (cli.Command, error) { return cmd.NewVaultDecryptCommand(ui, trellis), nil }, diff --git a/trellis/config.go b/trellis/config.go index e1a28455..79ee0904 100644 --- a/trellis/config.go +++ b/trellis/config.go @@ -150,11 +150,15 @@ func (t *Trellis) UpdateDefaultSiteFile(path string, name string, host string, e } } + prependHeadComment(doc, header) + + return doc.Save(path) +} + +func prependHeadComment(doc *yamledit.Document, header string) { if header != "" { doc.SetHeadComment(strings.TrimRight(header+"\n"+doc.HeadComment(), "\n")) } - - return doc.Save(path) } func (s *Site) SslEnabled() bool { diff --git a/trellis/trellis.go b/trellis/trellis.go index aa93894d..2ce8ea5b 100644 --- a/trellis/trellis.go +++ b/trellis/trellis.go @@ -14,7 +14,6 @@ import ( "github.com/roots/trellis-cli/app_paths" "github.com/roots/trellis-cli/cli_config" "gopkg.in/ini.v1" - "gopkg.in/yaml.v2" ) const ( @@ -368,22 +367,6 @@ func (t *Trellis) UpdateAnsibleConfig(section string, key string, value string) return nil } -func (t *Trellis) WriteYamlFile(s any, path string, header string) error { - data, err := yaml.Marshal(s) - if err != nil { - log.Fatal(err) - } - - path = filepath.Join(t.Path, path) - data = append([]byte(header), data...) - - if err := os.WriteFile(path, data, 0666); err != nil { - log.Fatal(err) - } - - return nil -} - func (t *Trellis) VmManagerType() string { switch t.CliConfig.Vm.Manager { case "auto": diff --git a/trellis/vault.go b/trellis/vault.go index 9ea612ea..51ef1f5b 100644 --- a/trellis/vault.go +++ b/trellis/vault.go @@ -7,7 +7,12 @@ import ( "log" "os" "path/filepath" + "slices" + "strconv" "strings" + + "github.com/roots/trellis-cli/trellis/yamledit" + "go.yaml.in/yaml/v3" ) const fileHeader = "$ANSIBLE_VAULT;1.1;AES256" @@ -47,69 +52,146 @@ func (rs *RandomStringGenerator) Generate() string { return generateRandomString(rs.Length) } -type Vault struct { - MysqlRootPassword string `yaml:"vault_mysql_root_password"` - Users []VaultUser `yaml:"vault_users,omitempty"` - WordPressSites map[string]VaultWordPressSite `yaml:"vault_wordpress_sites"` +// VaultPlaceholders are values treated as unset secrets: empty values and the +// examples shipped in Trellis' default vault files. +var VaultPlaceholders = []string{ + "", + "generateme", + "devpw", + "stagingpw", + "productionpw", + "example_password", + "example_dbpassword", } -type VaultUser struct { - Name string `yaml:"name"` - Password string `yaml:"password"` - Salt string `yaml:"salt"` +var wordpressSaltKeys = []string{ + "auth_key", + "secure_auth_key", + "logged_in_key", + "nonce_key", + "auth_salt", + "secure_auth_salt", + "logged_in_salt", + "nonce_salt", } -type VaultWordPressSite struct { - AdminPassword string `yaml:"admin_password"` - Env VaultWordPressSiteEnv `yaml:"env"` +type VaultGenerateOptions struct { + // Force regenerates secrets that are already set, not just placeholders. + Force bool + // SaltsOnly limits generation to the WordPress keys and salts. + SaltsOnly bool } -type VaultWordPressSiteEnv struct { - DbPassword string `yaml:"db_password"` - AuthKey string `yaml:"auth_key,omitempty"` - SecureAuthKey string `yaml:"secure_auth_key,omitempty"` - LoggedInKey string `yaml:"logged_in_key,omitempty"` - NonceKey string `yaml:"nonce_key,omitempty"` - AuthSalt string `yaml:"auth_salt,omitempty"` - SecureAuthSalt string `yaml:"secure_auth_salt,omitempty"` - LoggedInSalt string `yaml:"logged_in_salt,omitempty"` - NonceSalt string `yaml:"nonce_salt,omitempty"` +// GenerateVaultSecrets sets random values for the secrets of an environment's +// vault.yml document, adding any that are missing. Existing values are kept +// unless they are placeholders or opts.Force is set; Jinja templated values are +// never replaced. It returns the paths of the generated values. +func GenerateVaultSecrets(doc *yamledit.Document, env string, sites []string, randomString StringGenerator, opts VaultGenerateOptions) ([][]string, error) { + assertAvailablePRNG() + + g := &vaultGenerator{doc: doc, randomString: randomString, force: opts.Force} + + if !opts.SaltsOnly { + g.generate("vault_mysql_root_password") + + if env != "development" { + g.generateUsers() + } + } + + for _, site := range sites { + if !opts.SaltsOnly { + // Only used by the wordpress-install role in development + if env == "development" { + g.generate("vault_wordpress_sites", site, "admin_password") + } + + g.generate("vault_wordpress_sites", site, "env", "db_password") + } + + for _, key := range wordpressSaltKeys { + g.generate("vault_wordpress_sites", site, "env", key) + } + } + + return g.generated, g.err } -func (t *Trellis) GenerateVaultConfig(name string, env string, randomString StringGenerator) *Vault { - assertAvailablePRNG() - var siteEnv VaultWordPressSiteEnv - - vault := Vault{MysqlRootPassword: randomString.Generate()} - siteEnv = VaultWordPressSiteEnv{ - DbPassword: randomString.Generate(), - AuthKey: randomString.Generate(), - SecureAuthKey: randomString.Generate(), - LoggedInKey: randomString.Generate(), - NonceKey: randomString.Generate(), - AuthSalt: randomString.Generate(), - SecureAuthSalt: randomString.Generate(), - LoggedInSalt: randomString.Generate(), - NonceSalt: randomString.Generate(), - } - - if env != "development" { - user := VaultUser{ - Name: "{{ admin_user }}", - Password: randomString.Generate(), - Salt: randomString.Generate(), +// UpdateDefaultVaultFile renames the default site in a new project's vault.yml +// file (relative to the project) and generates all of its secrets. +func (t *Trellis) UpdateDefaultVaultFile(path string, name string, env string, header string) error { + path = filepath.Join(t.Path, path) + + doc, err := yamledit.Load(path) + if os.IsNotExist(err) { + doc, err = yamledit.Parse(nil) + } + + if err != nil { + return err + } + + if doc.Lookup("vault_wordpress_sites", DefaultSiteName) != nil { + if err := doc.RenameKey(name, "vault_wordpress_sites", DefaultSiteName); err != nil { + return err } + } - vault.Users = []VaultUser{user} + randomString := RandomStringGenerator{Length: 64} + if _, err := GenerateVaultSecrets(doc, env, []string{name}, &randomString, VaultGenerateOptions{Force: true}); err != nil { + return err } - vault.WordPressSites = make(map[string]VaultWordPressSite) - vault.WordPressSites[name] = VaultWordPressSite{ - AdminPassword: randomString.Generate(), - Env: siteEnv, + prependHeadComment(doc, header) + + return doc.Save(path) +} + +type vaultGenerator struct { + doc *yamledit.Document + randomString StringGenerator + force bool + generated [][]string + err error +} + +func (g *vaultGenerator) generate(path ...string) { + if g.err != nil { + return } - return &vault + if node := g.doc.Lookup(path...); node != nil { + if node.Kind != yaml.ScalarNode || strings.Contains(node.Value, "{{") { + return + } + + if !g.force && !slices.Contains(VaultPlaceholders, node.Value) { + return + } + } + + if g.err = g.doc.Set(g.randomString.Generate(), path...); g.err == nil { + g.generated = append(g.generated, path) + } +} + +func (g *vaultGenerator) generateUsers() { + users := g.doc.Lookup("vault_users") + + if users == nil { + g.err = g.doc.Set([]map[string]string{{"name": "{{ admin_user }}"}}, "vault_users") + users = g.doc.Lookup("vault_users") + } + + if g.err != nil || users.Kind != yaml.SequenceNode { + return + } + + for i := range users.Content { + index := strconv.Itoa(i) + g.generate("vault_users", index, "password") + g.generate("vault_users", index, "salt") + } } func (t *Trellis) GenerateVaultPassFile(path string) error { diff --git a/trellis/vault_test.go b/trellis/vault_test.go index 95f5eb04..73b00984 100644 --- a/trellis/vault_test.go +++ b/trellis/vault_test.go @@ -1,9 +1,12 @@ package trellis import ( - "gopkg.in/yaml.v2" + "os" + "path/filepath" "strings" "testing" + + "github.com/roots/trellis-cli/trellis/yamledit" ) type MockStringGenerator struct{} @@ -12,20 +15,45 @@ func (ms *MockStringGenerator) Generate() string { return "random" } -func TestGenerateVaultConfig(t *testing.T) { - trellis := &Trellis{} - mockStringGenerator := &MockStringGenerator{} +const productionVault = `# Documentation: https://roots.io/trellis/docs/vault/ +vault_mysql_root_password: productionpw + +# Documentation: https://roots.io/trellis/docs/security/ +vault_users: + - name: "{{ admin_user }}" + password: example_password + salt: "generateme" +vault_wordpress_sites: + example.com: + env: + db_password: my_real_password + # Generate your keys here: https://roots.io/salts.html + auth_key: "generateme" + secure_auth_key: "generateme" + logged_in_key: "generateme" + nonce_key: "generateme" + auth_salt: "generateme" + secure_auth_salt: "generateme" + logged_in_salt: "generateme" + nonce_salt: "" +` + +func TestGenerateVaultSecrets(t *testing.T) { cases := []struct { - name string - siteName string - env string - expectedYaml string + name string + input string + env string + sites []string + opts VaultGenerateOptions + expected string }{ { - "development_env", - "testsite.com", + "empty_development", + "", "development", + []string{"testsite.com"}, + VaultGenerateOptions{}, ` vault_mysql_root_password: random vault_wordpress_sites: @@ -44,17 +72,110 @@ vault_wordpress_sites: `, }, { - "production_env", - "testsite.com", + "empty_production", + "", "production", + []string{"testsite.com"}, + VaultGenerateOptions{}, ` vault_mysql_root_password: random vault_users: -- name: '{{ admin_user }}' - password: random - salt: random + - name: '{{ admin_user }}' + password: random + salt: random vault_wordpress_sites: testsite.com: + env: + db_password: random + auth_key: random + secure_auth_key: random + logged_in_key: random + nonce_key: random + auth_salt: random + secure_auth_salt: random + logged_in_salt: random + nonce_salt: random +`, + }, + { + "fills_placeholders_only", + productionVault, + "production", + []string{"example.com"}, + VaultGenerateOptions{}, + ` +# Documentation: https://roots.io/trellis/docs/vault/ +vault_mysql_root_password: random + +# Documentation: https://roots.io/trellis/docs/security/ +vault_users: + - name: "{{ admin_user }}" + password: random + salt: "random" + +vault_wordpress_sites: + example.com: + env: + db_password: my_real_password + # Generate your keys here: https://roots.io/salts.html + auth_key: "random" + secure_auth_key: "random" + logged_in_key: "random" + nonce_key: "random" + auth_salt: "random" + secure_auth_salt: "random" + logged_in_salt: "random" + nonce_salt: "random" +`, + }, + { + "salts_only", + productionVault, + "production", + []string{"example.com"}, + VaultGenerateOptions{SaltsOnly: true}, + ` +# Documentation: https://roots.io/trellis/docs/vault/ +vault_mysql_root_password: productionpw + +# Documentation: https://roots.io/trellis/docs/security/ +vault_users: + - name: "{{ admin_user }}" + password: example_password + salt: "generateme" + +vault_wordpress_sites: + example.com: + env: + db_password: my_real_password + # Generate your keys here: https://roots.io/salts.html + auth_key: "random" + secure_auth_key: "random" + logged_in_key: "random" + nonce_key: "random" + auth_salt: "random" + secure_auth_salt: "random" + logged_in_salt: "random" + nonce_salt: "random" +`, + }, + { + "force_replaces_set_values_but_not_templates", + ` +vault_mysql_root_password: "{{ lookup('env', 'MYSQL_ROOT') }}" +vault_wordpress_sites: + example.com: + admin_password: secret + env: + db_password: my_real_password +`, + "development", + []string{"example.com"}, + VaultGenerateOptions{Force: true, SaltsOnly: false}, + ` +vault_mysql_root_password: "{{ lookup('env', 'MYSQL_ROOT') }}" +vault_wordpress_sites: + example.com: admin_password: random env: db_password: random @@ -66,19 +187,140 @@ vault_wordpress_sites: secure_auth_salt: random logged_in_salt: random nonce_salt: random +`, + }, + { + "multiple_sites_and_non_sequence_users", + ` +vault_mysql_root_password: set +vault_users: "{{ my_users }}" +`, + "production", + []string{"a.com", "b.com"}, + VaultGenerateOptions{SaltsOnly: true}, + ` +vault_mysql_root_password: set +vault_users: "{{ my_users }}" +vault_wordpress_sites: + a.com: + env: + auth_key: random + secure_auth_key: random + logged_in_key: random + nonce_key: random + auth_salt: random + secure_auth_salt: random + logged_in_salt: random + nonce_salt: random + b.com: + env: + auth_key: random + secure_auth_key: random + logged_in_key: random + nonce_key: random + auth_salt: random + secure_auth_salt: random + logged_in_salt: random + nonce_salt: random `, }, } for _, tc := range cases { - vault := trellis.GenerateVaultConfig(tc.siteName, tc.env, mockStringGenerator) + t.Run(tc.name, func(t *testing.T) { + doc, err := yamledit.Parse([]byte(strings.TrimLeft(tc.input, "\n"))) + if err != nil { + t.Fatal(err) + } + + if _, err := GenerateVaultSecrets(doc, tc.env, tc.sites, &MockStringGenerator{}, tc.opts); err != nil { + t.Fatal(err) + } - vaultYaml, _ := yaml.Marshal(vault) - expected := strings.TrimSpace(tc.expectedYaml) - output := strings.TrimSpace(string(vaultYaml)) + output, _ := doc.Bytes() + expected := strings.TrimSpace(tc.expected) + + if strings.TrimSpace(string(output)) != expected { + t.Errorf("expected:\n%s\ngot:\n%s", expected, output) + } + }) + } +} + +func TestGenerateVaultSecretsReturnsGeneratedPaths(t *testing.T) { + doc, _ := yamledit.Parse([]byte(productionVault)) + + generated, err := GenerateVaultSecrets(doc, "production", []string{"example.com"}, &MockStringGenerator{}, VaultGenerateOptions{}) + if err != nil { + t.Fatal(err) + } - if output != expected { - t.Errorf("expected yaml output %s but got %s", expected, output) + // root password, user password + salt, 8 keys/salts (db_password is already set) + if len(generated) != 11 { + t.Errorf("expected 11 generated values, got %d: %v", len(generated), generated) + } + + generated, _ = GenerateVaultSecrets(doc, "production", []string{"example.com"}, &MockStringGenerator{}, VaultGenerateOptions{}) + if len(generated) != 0 { + t.Errorf("expected second run to generate nothing, got %v", generated) + } +} + +func TestUpdateDefaultVaultFile(t *testing.T) { + trellis := &Trellis{Path: t.TempDir()} + + data, _ := os.ReadFile("testdata/trellis/group_vars/development/vault.yml") + if err := os.WriteFile(filepath.Join(trellis.Path, "vault.yml"), data, 0644); err != nil { + t.Fatal(err) + } + + if err := trellis.UpdateDefaultVaultFile("vault.yml", "mysite.com", "development", "# Created by trellis-cli v1.0.0"); err != nil { + t.Fatal(err) + } + + doc, err := yamledit.Load(filepath.Join(trellis.Path, "vault.yml")) + if err != nil { + t.Fatal(err) + } + + if doc.Lookup("vault_wordpress_sites", DefaultSiteName) != nil { + t.Error("expected default site to be renamed") + } + + for _, path := range [][]string{ + {"vault_mysql_root_password"}, + {"vault_wordpress_sites", "mysite.com", "admin_password"}, + {"vault_wordpress_sites", "mysite.com", "env", "db_password"}, + {"vault_wordpress_sites", "mysite.com", "env", "nonce_salt"}, + } { + if node := doc.Lookup(path...); node == nil || len(node.Value) != 64 { + t.Errorf("expected %v to be a generated 64 character value, got %v", path, node) } } + + if !strings.HasPrefix(doc.HeadComment(), "# Created by trellis-cli v1.0.0") { + t.Errorf("expected header comment, got %q", doc.HeadComment()) + } + + output, _ := os.ReadFile(filepath.Join(trellis.Path, "vault.yml")) + if !strings.Contains(string(output), "# Documentation: https://roots.io/trellis/docs/vault/") { + t.Errorf("expected original comments to be preserved, got:\n%s", output) + } +} + +func TestUpdateDefaultVaultFileMissing(t *testing.T) { + trellis := &Trellis{Path: t.TempDir()} + + if err := trellis.UpdateDefaultVaultFile("vault.yml", "mysite.com", "production", ""); err != nil { + t.Fatal(err) + } + + doc, err := yamledit.Load(filepath.Join(trellis.Path, "vault.yml")) + if err != nil { + t.Fatal(err) + } + + if node := doc.Lookup("vault_users", "0", "salt"); node == nil || len(node.Value) != 64 { + t.Errorf("expected vault_users to be generated, got %v", node) + } }