config.go

  1package config
  2
  3import (
  4	"fmt"
  5	"log"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9	"time"
 10
 11	"github.com/caarlos0/env/v8"
 12	"github.com/charmbracelet/soft-serve/server/sshutils"
 13	"golang.org/x/crypto/ssh"
 14	"gopkg.in/yaml.v3"
 15)
 16
 17// Config is the configuration for Soft Serve.
 18type Config struct {
 19	// Name is the name of the server.
 20	Name string `env:"NAME" yaml:"name"`
 21
 22	// SSH is the configuration for the SSH server.
 23	SSH SSHConfig `envPrefix:"SSH_" yaml:"ssh"`
 24
 25	// GitDaemon is the configuration for the GitDaemon daemon.
 26	GitDaemon GitDaemonConfig `envPrefix:"GIT_DAEMON_" yaml:"git_daemon"`
 27
 28	// HTTP is the configuration for the HTTP server.
 29	HTTP HTTPConfig `envPrefix:"HTTP_" yaml:"http"`
 30
 31	// Stats is the configuration for the stats server.
 32	Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"`
 33
 34	// Log is the logger configuration.
 35	Log LogConfig `envPrefix:"LOG_" yaml:"log"`
 36
 37	// Cache is the cache backend to use.
 38	Cache CacheConfig `env:"CACHE" yaml:"cache"`
 39
 40	// Database is the database configuration.
 41	Database DatabaseConfig `envPrefix:"DATABASE_" yaml:"database"`
 42
 43	// Backend is the backend to use.
 44	Backend BackendConfig `envPrefix:"BACKEND_" yaml:"backend"`
 45
 46	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
 47	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
 48
 49	// DataPath is the path to the directory where Soft Serve will store its data.
 50	DataPath string `env:"DATA_PATH" yaml:"-"`
 51}
 52
 53// Environ returns the config as a list of environment variables.
 54// TODO: use pointer receiver
 55func (c *Config) Environ() []string {
 56	envs := append([]string{},
 57		"SOFT_SERVE_NAME="+c.Name,
 58		"SOFT_SERVE_DATA_PATH="+c.DataPath,
 59		"SOFT_SERVE_INITIAL_ADMIN_KEYS="+strings.Join(c.InitialAdminKeys, "\n"),
 60	)
 61
 62	envs = append(envs, c.SSH.Environ()...)
 63	envs = append(envs, c.GitDaemon.Environ()...)
 64	envs = append(envs, c.HTTP.Environ()...)
 65	envs = append(envs, c.Stats.Environ()...)
 66	envs = append(envs, c.Log.Environ()...)
 67	envs = append(envs, c.Cache.Environ()...)
 68	envs = append(envs, c.Database.Environ()...)
 69	envs = append(envs, c.Backend.Environ()...)
 70
 71	return envs
 72}
 73
 74func parseFile(v interface{}, path string) error {
 75	f, err := os.Open(path)
 76	if err != nil {
 77		return fmt.Errorf("open config file: %w", err)
 78	}
 79
 80	defer f.Close() // nolint: errcheck
 81	if err := yaml.NewDecoder(f).Decode(v); err != nil {
 82		return fmt.Errorf("decode config: %w", err)
 83	}
 84
 85	return nil
 86}
 87
 88func parseEnv(v interface{}) error {
 89	// Override with environment variables
 90	if err := env.ParseWithOptions(v, env.Options{
 91		Prefix: "SOFT_SERVE_",
 92	}); err != nil {
 93		return fmt.Errorf("parse environment variables: %w", err)
 94	}
 95
 96	return nil
 97}
 98
 99// ParseConfig parses the configuration file to server configuration.
100func ParseConfig(c *Config, path string) error {
101	return parseConfig(c, path)
102}
103
104func parseConfig(cfg *Config, path string) error {
105	if cfg == nil {
106		cfg = DefaultConfig()
107	}
108
109	if path != "" {
110		// TODO: make config aware of config.yaml path
111		cfg.DataPath = filepath.Dir(path)
112	}
113
114	exist := cfg.Exist()
115	if exist {
116		if err := parseFile(cfg, cfg.FilePath()); err != nil {
117			return err
118		}
119	}
120
121	// Merge initial admin keys from both config file and environment variables.
122	initialAdminKeys := append([]string{}, cfg.InitialAdminKeys...)
123
124	if err := parseEnv(cfg); err != nil {
125		return err
126	}
127
128	// Merge initial admin keys from environment variables.
129	if initialAdminKeysEnv := os.Getenv("SOFT_SERVE_INITIAL_ADMIN_KEYS"); initialAdminKeysEnv != "" {
130		cfg.InitialAdminKeys = append(cfg.InitialAdminKeys, initialAdminKeys...)
131	}
132
133	// Validate keys
134	pks := make([]string, 0)
135	for _, key := range parseAuthKeys(cfg.InitialAdminKeys) {
136		ak := sshutils.MarshalAuthorizedKey(key)
137		pks = append(pks, ak)
138	}
139
140	cfg.InitialAdminKeys = pks
141
142	if err := cfg.validate(); err != nil {
143		return err
144	}
145
146	return nil
147}
148
149// DefaultConfig returns the default config.
150func DefaultConfig() *Config {
151	dataPath := os.Getenv("SOFT_SERVE_DATA_PATH")
152	if dataPath == "" {
153		dataPath = "data"
154	}
155
156	return &Config{
157		Name:             "Soft Serve",
158		DataPath:         dataPath,
159		InitialAdminKeys: []string{},
160		SSH: SSHConfig{
161			ListenAddr:    ":23231",
162			PublicURL:     "ssh://localhost:23231",
163			KeyPath:       filepath.Join("ssh", "soft_serve_host_ed25519"),
164			ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
165			MaxTimeout:    0,
166			IdleTimeout:   0,
167		},
168		GitDaemon: GitDaemonConfig{
169			ListenAddr:     ":9418",
170			MaxTimeout:     0,
171			IdleTimeout:    3,
172			MaxConnections: 32,
173		},
174		HTTP: HTTPConfig{
175			ListenAddr: ":23232",
176			PublicURL:  "http://localhost:23232",
177		},
178		Stats: StatsConfig{
179			ListenAddr: "localhost:23233",
180		},
181		Log: LogConfig{
182			Format:     "text",
183			TimeFormat: time.DateTime,
184		},
185		Cache: CacheConfig{
186			Backend: "lru",
187		},
188		Database: DatabaseConfig{
189			Driver:     "sqlite",
190			DataSource: "soft-serve.db",
191		},
192		Backend: BackendConfig{
193			Settings: "sqlite",
194			Access:   "sqlite",
195			Auth:     "sqlite",
196			Store:    "sqlite",
197		},
198	}
199}
200
201// FilePath returns the expected config file path.
202func (c *Config) FilePath() string {
203	return filepath.Join(c.DataPath, "config.yaml")
204}
205
206// Exist returns true if the configuration file exists.
207func (c *Config) Exist() bool {
208	_, err := os.Stat(c.FilePath())
209	return err == nil
210}
211
212// ReadConfig parses the configuration file.
213func (c *Config) ReadConfig() error {
214	return parseConfig(c, c.FilePath())
215}
216
217// WriteConfig writes the configuration in the default path.
218func (c *Config) WriteConfig() error {
219	return WriteConfig(c)
220}
221
222// ReposPath returns the expected repositories path.
223func (c *Config) ReposPath() string {
224	return filepath.Join(c.DataPath, "repos")
225}
226
227// WriteConfig writes the configuration in the default path.
228func WriteConfig(c *Config) error {
229	if c == nil {
230		return fmt.Errorf("nil config")
231	}
232
233	fp := c.FilePath()
234	if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
235		return err
236	}
237
238	return os.WriteFile(fp, []byte(newConfigFile(c)), 0o644) // nolint: errcheck
239}
240
241func (c *Config) validate() error {
242	// Use absolute paths
243	if !filepath.IsAbs(c.DataPath) {
244		dp, err := filepath.Abs(c.DataPath)
245		if err != nil {
246			return err
247		}
248		c.DataPath = dp
249	}
250
251	c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/")
252
253	if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) {
254		c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
255	}
256
257	if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {
258		c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)
259	}
260
261	c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/")
262
263	if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
264		c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)
265	}
266
267	if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) {
268		c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)
269	}
270
271	switch c.Database.Driver {
272	case "sqlite":
273		if c.Database.DataSource != "" && !filepath.IsAbs(c.Database.DataSource) {
274			c.Database.DataSource = filepath.Join(c.DataPath, c.Database.DataSource)
275		}
276	}
277
278	return nil
279}
280
281// parseAuthKeys parses authorized keys from either file paths or string authorized_keys.
282func parseAuthKeys(aks []string) []ssh.PublicKey {
283	pks := make([]ssh.PublicKey, 0)
284	for _, key := range aks {
285		if bts, err := os.ReadFile(key); err == nil {
286			// key is a file
287			key = strings.TrimSpace(string(bts))
288		}
289		if pk, _, err := sshutils.ParseAuthorizedKey(key); err == nil {
290			pks = append(pks, pk)
291		}
292	}
293	return pks
294}
295
296// AdminKeys returns the server admin keys.
297func (c *Config) AdminKeys() []ssh.PublicKey {
298	if c.InitialAdminKeys == nil {
299		return []ssh.PublicKey{}
300	}
301
302	log.Print(c.InitialAdminKeys)
303	return parseAuthKeys(c.InitialAdminKeys)
304}