fix: allow multiple SOFT_SERVE_INITIAL_ADMIN_KEY (#68)

Carlos Alexandro Becker created

* fix: allow multiple SOFT_SERVE_INITIAL_ADMIN_KEY

Signed-off-by: Carlos A Becker <caarlos0@gmail.com>

* fix: no initial keys

Signed-off-by: Carlos A Becker <caarlos0@gmail.com>

* fix: linter

Signed-off-by: Carlos A Becker <caarlos0@gmail.com>

* fix: linter

Signed-off-by: Carlos A Becker <caarlos0@gmail.com>

Change summary

config/config.go                | 23 ++++++++++-----------
config/config_test.go           | 19 ++++++++++++++++++
go.mod                          |  5 ++-
go.sum                          |  4 +-
internal/config/config.go       | 36 +++++++++++++++++++---------------
internal/config/config_test.go  | 35 ++++++++++++++++++++++++++++++++++
internal/config/testdata/k1.pub |  1 
7 files changed, 91 insertions(+), 32 deletions(-)

Detailed changes

config/config.go 🔗

@@ -4,7 +4,7 @@ import (
 	"log"
 	"path/filepath"
 
-	"github.com/meowgorithm/babyenv"
+	"github.com/caarlos0/env/v6"
 )
 
 // Callbacks provides an interface that can be used to run callbacks on different events.
@@ -16,12 +16,12 @@ type Callbacks interface {
 
 // Config is the configuration for Soft Serve.
 type Config struct {
-	Host            string `env:"SOFT_SERVE_HOST"`
-	Port            int    `env:"SOFT_SERVE_PORT"`
-	KeyPath         string `env:"SOFT_SERVE_KEY_PATH"`
-	RepoPath        string `env:"SOFT_SERVE_REPO_PATH"`
-	InitialAdminKey string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY"`
-	Callbacks       Callbacks
+	Host             string   `env:"SOFT_SERVE_HOST"`
+	Port             int      `env:"SOFT_SERVE_PORT"`
+	KeyPath          string   `env:"SOFT_SERVE_KEY_PATH"`
+	RepoPath         string   `env:"SOFT_SERVE_REPO_PATH"`
+	InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"`
+	Callbacks        Callbacks
 }
 
 func (c *Config) applyDefaults() {
@@ -41,8 +41,7 @@ func (c *Config) applyDefaults() {
 // or specified environment variables.
 func DefaultConfig() *Config {
 	var scfg Config
-	err := babyenv.Parse(&scfg)
-	if err != nil {
+	if err := env.Parse(&scfg); err != nil {
 		log.Fatalln(err)
 	}
 	scfg.applyDefaults()
@@ -50,7 +49,7 @@ func DefaultConfig() *Config {
 }
 
 // WithCallbacks applies the given Callbacks to the configuration.
-func (cfg *Config) WithCallbacks(c Callbacks) *Config {
-	cfg.Callbacks = c
-	return cfg
+func (c *Config) WithCallbacks(callbacks Callbacks) *Config {
+	c.Callbacks = callbacks
+	return c
 }

config/config_test.go 🔗

@@ -0,0 +1,19 @@
+package config
+
+import (
+	"os"
+	"testing"
+
+	"github.com/matryer/is"
+)
+
+func TestParseMultipleKeys(t *testing.T) {
+	is := is.New(t)
+	is.NoErr(os.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEY", "testdata/k1.pub\ntestdata/k2.pub"))
+	t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_INITIAL_ADMIN_KEY")) })
+	cfg := DefaultConfig()
+	is.Equal(cfg.InitialAdminKeys, []string{
+		"testdata/k1.pub",
+		"testdata/k2.pub",
+	})
+}

go.mod 🔗

@@ -3,6 +3,7 @@ module github.com/charmbracelet/soft-serve
 go 1.17
 
 require (
+	github.com/caarlos0/env/v6 v6.9.1
 	github.com/charmbracelet/bubbles v0.10.0
 	github.com/charmbracelet/bubbletea v0.19.3
 	github.com/charmbracelet/glamour v0.4.0
@@ -12,8 +13,9 @@ require (
 	github.com/gliderlabs/ssh v0.3.3
 	github.com/go-git/go-billy/v5 v5.3.1
 	github.com/go-git/go-git/v5 v5.4.2
-	github.com/meowgorithm/babyenv v1.3.1
+	github.com/matryer/is v1.2.0
 	github.com/muesli/reflow v0.3.0
+	golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce
 	gopkg.in/yaml.v2 v2.4.0
 )
 
@@ -47,7 +49,6 @@ require (
 	github.com/xanzy/ssh-agent v0.3.1 // indirect
 	github.com/yuin/goldmark v1.4.4 // indirect
 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
-	golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce // indirect
 	golang.org/x/net v0.0.0-20220111093109-d55c255bac03 // indirect
 	golang.org/x/sys v0.0.0-20220111092808-5a964db01320 // indirect
 	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect

go.sum 🔗

@@ -18,6 +18,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY
 github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/caarlos0/env/v6 v6.9.1 h1:zOkkjM0F6ltnQ5eBX6IPI41UP/KDGEK7rRPwGCNos8k=
+github.com/caarlos0/env/v6 v6.9.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc=
 github.com/charmbracelet/bubbles v0.10.0 h1:ZYqBwnmFGp91HSRRbhxKq5jr6bUPsVUBdkrGGWtv0Wk=
 github.com/charmbracelet/bubbles v0.10.0/go.mod h1:4tiDrWzH1MTD4t5NnrcthaedmI3MxU0FIutax7//dvk=
 github.com/charmbracelet/bubbletea v0.19.0/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=
@@ -94,8 +96,6 @@ github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC
 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/meowgorithm/babyenv v1.3.1 h1:18ZEYIgbzoFQfRLF9+lxjRfk/ui6w8U0FWl07CgWvvc=
-github.com/meowgorithm/babyenv v1.3.1/go.mod h1:lwNX+J6AGBFqNrMZ2PTLkM6SO+W4X8DOg9zBDO4j3Ig=
 github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
 github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
 github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE=

internal/config/config.go 🔗

@@ -53,15 +53,19 @@ func NewConfig(cfg *config.Config) (*Config, error) {
 	var displayHost string
 	host := cfg.Host
 	port := cfg.Port
-	pk := cfg.InitialAdminKey
 
-	if bts, err := os.ReadFile(pk); err == nil {
-		// pk is a file, set its contents as pk
-		pk = string(bts)
-	}
-	// it is a valid ssh key, nothing to do
-	if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil {
-		return nil, fmt.Errorf("invalid initial admin key: %w", err)
+	pks := make([]string, 0, len(cfg.InitialAdminKeys))
+	for _, k := range cfg.InitialAdminKeys {
+		var pk = strings.TrimSpace(k)
+		if bts, err := os.ReadFile(k); err == nil {
+			// pk is a file, set its contents as pk
+			pk = string(bts)
+		}
+		// it is a valid ssh key, nothing to do
+		if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil {
+			return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err)
+		}
+		pks = append(pks, pk)
 	}
 
 	rs := git.NewRepoSource(cfg.RepoPath)
@@ -71,7 +75,7 @@ func NewConfig(cfg *config.Config) (*Config, error) {
 	c.Host = cfg.Host
 	c.Port = port
 	c.Source = rs
-	if pk == "" {
+	if len(pks) == 0 {
 		anonAccess = "read-write"
 	} else {
 		anonAccess = "no-access"
@@ -82,14 +86,14 @@ func NewConfig(cfg *config.Config) (*Config, error) {
 		displayHost = host
 	}
 	yamlConfig := fmt.Sprintf(defaultConfig, displayHost, port, anonAccess)
-	if pk != "" {
-		pks := ""
-		for _, key := range strings.Split(strings.TrimSpace(pk), "\n") {
-			pks += fmt.Sprintf("      - %s\n", key)
-		}
-		yamlUsers = fmt.Sprintf(hasKeyUserConfig, pks)
-	} else {
+	if len(pks) == 0 {
 		yamlUsers = defaultUserConfig
+	} else {
+		var result string
+		for _, pk := range pks {
+			result += fmt.Sprintf("      - %s\n", pk)
+		}
+		yamlUsers = fmt.Sprintf(hasKeyUserConfig, result)
 	}
 	yaml := fmt.Sprintf("%s%s%s", yamlConfig, yamlUsers, exampleUserConfig)
 	err := c.createDefaultConfigRepo(yaml)

internal/config/config_test.go 🔗

@@ -0,0 +1,35 @@
+package config
+
+import (
+	"testing"
+
+	"github.com/charmbracelet/soft-serve/config"
+	"github.com/matryer/is"
+)
+
+func TestMultipleInitialKeys(t *testing.T) {
+	cfg, err := NewConfig(&config.Config{
+		RepoPath: t.TempDir(),
+		KeyPath:  t.TempDir(),
+		InitialAdminKeys: []string{
+			"testdata/k1.pub",
+			"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
+		},
+	})
+	is := is.New(t)
+	is.NoErr(err)
+	is.Equal(cfg.Users[0].PublicKeys, []string{
+		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b",
+		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
+	}) // should have both keys
+}
+
+func TestEmptyInitialKeys(t *testing.T) {
+	cfg, err := NewConfig(&config.Config{
+		RepoPath: t.TempDir(),
+		KeyPath:  t.TempDir(),
+	})
+	is := is.New(t)
+	is.NoErr(err)
+	is.Equal(len(cfg.Users), 0) // should not have any users
+}