config.go

  1// Package config provides configuration management for soft-serve.
  2package config
  3
  4import (
  5	"fmt"
  6	"os"
  7	"path/filepath"
  8	"strconv"
  9	"strings"
 10	"time"
 11
 12	"github.com/caarlos0/env/v11"
 13	"github.com/charmbracelet/soft-serve/pkg/sshutils"
 14	"golang.org/x/crypto/ssh"
 15	"gopkg.in/yaml.v3"
 16)
 17
 18var binPath = "soft"
 19
 20// SSHConfig is the configuration for the SSH server.
 21type SSHConfig struct {
 22	// Enabled toggles the SSH server on/off
 23	Enabled bool `env:"ENABLED" yaml:"enabled"`
 24
 25	// ListenAddr is the address on which the SSH server will listen.
 26	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 27
 28	// PublicURL is the public URL of the SSH server.
 29	PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
 30
 31	// KeyPath is the path to the SSH server's private key.
 32	KeyPath string `env:"KEY_PATH" yaml:"key_path"`
 33
 34	// ClientKeyPath is the path to the server's client private key.
 35	ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`
 36
 37	// MaxTimeout is the maximum number of seconds a connection can take.
 38	MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
 39
 40	// IdleTimeout is the number of seconds a connection can be idle before it is closed.
 41	IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"`
 42}
 43
 44// GitConfig is the Git daemon configuration for the server.
 45type GitConfig struct {
 46	// Enabled toggles the Git daemon on/off
 47	Enabled bool `env:"ENABLED" yaml:"enabled"`
 48
 49	// ListenAddr is the address on which the Git daemon will listen.
 50	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 51
 52	// PublicURL is the public URL of the Git daemon server.
 53	PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
 54
 55	// MaxTimeout is the maximum number of seconds a connection can take.
 56	MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
 57
 58	// IdleTimeout is the number of seconds a connection can be idle before it is closed.
 59	IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"`
 60
 61	// MaxConnections is the maximum number of concurrent connections.
 62	MaxConnections int `env:"MAX_CONNECTIONS" yaml:"max_connections"`
 63}
 64
 65// HTTPConfig is the HTTP configuration for the server.
 66type HTTPConfig struct {
 67	// Enabled toggles the HTTP server on/off
 68	Enabled bool `env:"ENABLED" yaml:"enabled"`
 69
 70	// ListenAddr is the address on which the HTTP server will listen.
 71	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 72
 73	// TLSKeyPath is the path to the TLS private key.
 74	TLSKeyPath string `env:"TLS_KEY_PATH" yaml:"tls_key_path"`
 75
 76	// TLSCertPath is the path to the TLS certificate.
 77	TLSCertPath string `env:"TLS_CERT_PATH" yaml:"tls_cert_path"`
 78
 79	// PublicURL is the public URL of the HTTP server.
 80	PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
 81}
 82
 83// StatsConfig is the configuration for the stats server.
 84type StatsConfig struct {
 85	// Enabled toggles the Stats server on/off
 86	Enabled bool `env:"ENABLED" yaml:"enabled"`
 87
 88	// ListenAddr is the address on which the stats server will listen.
 89	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 90}
 91
 92// LogConfig is the logger configuration.
 93type LogConfig struct {
 94	// Format is the format of the logs.
 95	// Valid values are "json", "logfmt", and "text".
 96	Format string `env:"FORMAT" yaml:"format"`
 97
 98	// Time format for the log `ts` field.
 99	// Format must be described in Golang's time format.
100	TimeFormat string `env:"TIME_FORMAT" yaml:"time_format"`
101
102	// Path to a file to write logs to.
103	// If not set, logs will be written to stderr.
104	Path string `env:"PATH" yaml:"path"`
105}
106
107// DBConfig is the database connection configuration.
108type DBConfig struct {
109	// Driver is the driver for the database.
110	Driver string `env:"DRIVER" yaml:"driver"`
111
112	// DataSource is the database data source name.
113	DataSource string `env:"DATA_SOURCE" yaml:"data_source"`
114}
115
116// LFSConfig is the configuration for Git LFS.
117type LFSConfig struct {
118	// Enabled is whether or not Git LFS is enabled.
119	Enabled bool `env:"ENABLED" yaml:"enabled"`
120
121	// SSHEnabled is whether or not Git LFS over SSH is enabled.
122	// This is only used if LFS is enabled.
123	SSHEnabled bool `env:"SSH_ENABLED" yaml:"ssh_enabled"`
124}
125
126// JobsConfig is the configuration for cron jobs.
127type JobsConfig struct {
128	MirrorPull string `env:"MIRROR_PULL" yaml:"mirror_pull"`
129}
130
131// Config is the configuration for Soft Serve.
132type Config struct {
133	// Name is the name of the server.
134	Name string `env:"NAME" yaml:"name"`
135
136	// SSH is the configuration for the SSH server.
137	SSH SSHConfig `envPrefix:"SSH_" yaml:"ssh"`
138
139	// Git is the configuration for the Git daemon.
140	Git GitConfig `envPrefix:"GIT_" yaml:"git"`
141
142	// HTTP is the configuration for the HTTP server.
143	HTTP HTTPConfig `envPrefix:"HTTP_" yaml:"http"`
144
145	// Stats is the configuration for the stats server.
146	Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"`
147
148	// Log is the logger configuration.
149	Log LogConfig `envPrefix:"LOG_" yaml:"log"`
150
151	// DB is the database configuration.
152	DB DBConfig `envPrefix:"DB_" yaml:"db"`
153
154	// LFS is the configuration for Git LFS.
155	LFS LFSConfig `envPrefix:"LFS_" yaml:"lfs"`
156
157	// Jobs is the configuration for cron jobs
158	Jobs JobsConfig `envPrefix:"JOBS_" yaml:"jobs"`
159
160	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
161	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
162
163	// DataPath is the path to the directory where Soft Serve will store its data.
164	DataPath string `env:"DATA_PATH" yaml:"-"`
165}
166
167// Environ returns the config as a list of environment variables.
168func (c *Config) Environ() []string {
169	envs := []string{
170		fmt.Sprintf("SOFT_SERVE_BIN_PATH=%s", binPath),
171	}
172	if c == nil {
173		return envs
174	}
175
176	// TODO: do this dynamically
177	envs = append(envs, []string{
178		fmt.Sprintf("SOFT_SERVE_CONFIG_LOCATION=%s", c.ConfigPath()),
179		fmt.Sprintf("SOFT_SERVE_DATA_PATH=%s", c.DataPath),
180		fmt.Sprintf("SOFT_SERVE_NAME=%s", c.Name),
181		fmt.Sprintf("SOFT_SERVE_INITIAL_ADMIN_KEYS=%s", strings.Join(c.InitialAdminKeys, "\n")),
182		fmt.Sprintf("SOFT_SERVE_SSH_ENABLED=%t", c.SSH.Enabled),
183		fmt.Sprintf("SOFT_SERVE_SSH_LISTEN_ADDR=%s", c.SSH.ListenAddr),
184		fmt.Sprintf("SOFT_SERVE_SSH_PUBLIC_URL=%s", c.SSH.PublicURL),
185		fmt.Sprintf("SOFT_SERVE_SSH_KEY_PATH=%s", c.SSH.KeyPath),
186		fmt.Sprintf("SOFT_SERVE_SSH_CLIENT_KEY_PATH=%s", c.SSH.ClientKeyPath),
187		fmt.Sprintf("SOFT_SERVE_SSH_MAX_TIMEOUT=%d", c.SSH.MaxTimeout),
188		fmt.Sprintf("SOFT_SERVE_SSH_IDLE_TIMEOUT=%d", c.SSH.IdleTimeout),
189		fmt.Sprintf("SOFT_SERVE_GIT_ENABLED=%t", c.Git.Enabled),
190		fmt.Sprintf("SOFT_SERVE_GIT_LISTEN_ADDR=%s", c.Git.ListenAddr),
191		fmt.Sprintf("SOFT_SERVE_GIT_PUBLIC_URL=%s", c.Git.PublicURL),
192		fmt.Sprintf("SOFT_SERVE_GIT_MAX_TIMEOUT=%d", c.Git.MaxTimeout),
193		fmt.Sprintf("SOFT_SERVE_GIT_IDLE_TIMEOUT=%d", c.Git.IdleTimeout),
194		fmt.Sprintf("SOFT_SERVE_GIT_MAX_CONNECTIONS=%d", c.Git.MaxConnections),
195		fmt.Sprintf("SOFT_SERVE_HTTP_ENABLED=%t", c.HTTP.Enabled),
196		fmt.Sprintf("SOFT_SERVE_HTTP_LISTEN_ADDR=%s", c.HTTP.ListenAddr),
197		fmt.Sprintf("SOFT_SERVE_HTTP_TLS_KEY_PATH=%s", c.HTTP.TLSKeyPath),
198		fmt.Sprintf("SOFT_SERVE_HTTP_TLS_CERT_PATH=%s", c.HTTP.TLSCertPath),
199		fmt.Sprintf("SOFT_SERVE_HTTP_PUBLIC_URL=%s", c.HTTP.PublicURL),
200		fmt.Sprintf("SOFT_SERVE_STATS_ENABLED=%t", c.Stats.Enabled),
201		fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr),
202		fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format),
203		fmt.Sprintf("SOFT_SERVE_LOG_TIME_FORMAT=%s", c.Log.TimeFormat),
204		fmt.Sprintf("SOFT_SERVE_DB_DRIVER=%s", c.DB.Driver),
205		fmt.Sprintf("SOFT_SERVE_DB_DATA_SOURCE=%s", c.DB.DataSource),
206		fmt.Sprintf("SOFT_SERVE_LFS_ENABLED=%t", c.LFS.Enabled),
207		fmt.Sprintf("SOFT_SERVE_LFS_SSH_ENABLED=%t", c.LFS.SSHEnabled),
208		fmt.Sprintf("SOFT_SERVE_JOBS_MIRROR_PULL=%s", c.Jobs.MirrorPull),
209	}...)
210
211	return envs
212}
213
214// IsDebug returns true if the server is running in debug mode.
215func IsDebug() bool {
216	debug, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_DEBUG"))
217	return debug
218}
219
220// IsVerbose returns true if the server is running in verbose mode.
221// Verbose mode is only enabled if debug mode is enabled.
222func IsVerbose() bool {
223	verbose, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_VERBOSE"))
224	return IsDebug() && verbose
225}
226
227// parseFile parses the given file as a configuration file.
228// The file must be in YAML format.
229func parseFile(cfg *Config, path string) error {
230	f, err := os.Open(path)
231	if err != nil {
232		return err //nolint:wrapcheck
233	}
234
235	defer f.Close() //nolint:errcheck
236	if err := yaml.NewDecoder(f).Decode(cfg); err != nil {
237		return fmt.Errorf("decode config: %w", err)
238	}
239
240	return cfg.Validate()
241}
242
243// ParseFile parses the config from the default file path.
244// This also calls Validate() on the config.
245func (c *Config) ParseFile() error {
246	return parseFile(c, c.ConfigPath())
247}
248
249// parseEnv parses the environment variables as a configuration file.
250func parseEnv(cfg *Config) error {
251	// Merge initial admin keys from both config file and environment variables.
252	initialAdminKeys := append([]string{}, cfg.InitialAdminKeys...)
253
254	// Override with environment variables
255	if err := env.ParseWithOptions(cfg, env.Options{
256		Prefix: "SOFT_SERVE_",
257	}); err != nil {
258		return fmt.Errorf("parse environment variables: %w", err)
259	}
260
261	// Merge initial admin keys from environment variables.
262	if initialAdminKeysEnv := os.Getenv("SOFT_SERVE_INITIAL_ADMIN_KEYS"); initialAdminKeysEnv != "" {
263		cfg.InitialAdminKeys = append(cfg.InitialAdminKeys, initialAdminKeys...)
264	}
265
266	return cfg.Validate()
267}
268
269// ParseEnv parses the config from the environment variables.
270// This also calls Validate() on the config.
271func (c *Config) ParseEnv() error {
272	return parseEnv(c)
273}
274
275// Parse parses the config from the default file path and environment variables.
276// This also calls Validate() on the config.
277func (c *Config) Parse() error {
278	if err := c.ParseFile(); err != nil {
279		return err
280	}
281
282	return c.ParseEnv()
283}
284
285// writeConfig writes the configuration to the given file.
286func writeConfig(cfg *Config, path string) error {
287	if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { //nolint:gosec
288		return err //nolint:wrapcheck
289	}
290	return os.WriteFile(path, []byte(newConfigFile(cfg)), 0o644) //nolint:gosec,wrapcheck
291}
292
293// WriteConfig writes the configuration to the default file.
294func (c *Config) WriteConfig() error {
295	return writeConfig(c, c.ConfigPath())
296}
297
298// DefaultDataPath returns the path to the data directory.
299// It uses the SOFT_SERVE_DATA_PATH environment variable if set, otherwise it
300// uses "data".
301func DefaultDataPath() string {
302	dp := os.Getenv("SOFT_SERVE_DATA_PATH")
303	if dp == "" {
304		dp = "data"
305	}
306
307	return dp
308}
309
310// ConfigPath returns the path to the config file.
311func (c *Config) ConfigPath() string {
312	// If we have a custom config location set, then use that.
313	if path := os.Getenv("SOFT_SERVE_CONFIG_LOCATION"); exist(path) {
314		return path
315	}
316
317	// Otherwise, look in the data path.
318	return filepath.Join(c.DataPath, "config.yaml")
319}
320
321func exist(path string) bool {
322	_, err := os.Stat(path)
323	return err == nil
324}
325
326// Exist returns true if the config file exists.
327func (c *Config) Exist() bool {
328	return exist(c.ConfigPath())
329}
330
331// DefaultConfig returns the default Config. All the path values are relative
332// to the data directory.
333// Use Validate() to validate the config and ensure absolute paths.
334func DefaultConfig() *Config {
335	return &Config{
336		Name:     "Soft Serve",
337		DataPath: DefaultDataPath(),
338		SSH: SSHConfig{
339			Enabled:       true,
340			ListenAddr:    ":23231",
341			PublicURL:     "ssh://localhost:23231",
342			KeyPath:       filepath.Join("ssh", "soft_serve_host_ed25519"),
343			ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
344			MaxTimeout:    0,
345			IdleTimeout:   10 * 60, // 10 minutes
346		},
347		Git: GitConfig{
348			Enabled:        true,
349			ListenAddr:     ":9418",
350			PublicURL:      "git://localhost",
351			MaxTimeout:     0,
352			IdleTimeout:    3,
353			MaxConnections: 32,
354		},
355		HTTP: HTTPConfig{
356			Enabled:    true,
357			ListenAddr: ":23232",
358			PublicURL:  "http://localhost:23232",
359		},
360		Stats: StatsConfig{
361			Enabled:    true,
362			ListenAddr: "localhost:23233",
363		},
364		Log: LogConfig{
365			Format:     "text",
366			TimeFormat: time.DateTime,
367		},
368		DB: DBConfig{
369			Driver: "sqlite",
370			DataSource: "soft-serve.db" +
371				"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)",
372		},
373		LFS: LFSConfig{
374			Enabled:    true,
375			SSHEnabled: false,
376		},
377		Jobs: JobsConfig{
378			MirrorPull: "@every 10m",
379		},
380	}
381}
382
383// Validate validates the configuration.
384// It updates the configuration with absolute paths.
385func (c *Config) Validate() error {
386	// Use absolute paths
387	if !filepath.IsAbs(c.DataPath) {
388		dp, err := filepath.Abs(c.DataPath)
389		if err != nil {
390			return err //nolint:wrapcheck
391		}
392		c.DataPath = dp
393	}
394
395	c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/")
396	c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/")
397
398	if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) {
399		c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
400	}
401
402	if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {
403		c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)
404	}
405
406	if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
407		c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)
408	}
409
410	if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) {
411		c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)
412	}
413
414	if strings.HasPrefix(c.DB.Driver, "sqlite") && !filepath.IsAbs(c.DB.DataSource) {
415		c.DB.DataSource = filepath.Join(c.DataPath, c.DB.DataSource)
416	}
417
418	// Validate keys
419	pks := make([]string, 0)
420	for _, key := range parseAuthKeys(c.InitialAdminKeys) {
421		ak := sshutils.MarshalAuthorizedKey(key)
422		pks = append(pks, ak)
423	}
424
425	c.InitialAdminKeys = pks
426
427	return nil
428}
429
430// parseAuthKeys parses authorized keys from either file paths or string authorized_keys.
431func parseAuthKeys(aks []string) []ssh.PublicKey {
432	exist := make(map[string]struct{}, 0)
433	pks := make([]ssh.PublicKey, 0)
434	for _, key := range aks {
435		if bts, err := os.ReadFile(key); err == nil {
436			// key is a file
437			key = strings.TrimSpace(string(bts))
438		}
439
440		if pk, _, err := sshutils.ParseAuthorizedKey(key); err == nil {
441			if _, ok := exist[key]; !ok {
442				pks = append(pks, pk)
443				exist[key] = struct{}{}
444			}
445		}
446	}
447	return pks
448}
449
450// AdminKeys returns the server admin keys.
451func (c *Config) AdminKeys() []ssh.PublicKey {
452	return parseAuthKeys(c.InitialAdminKeys)
453}
454
455func init() {
456	if ex, err := os.Executable(); err == nil {
457		binPath = filepath.ToSlash(ex)
458	}
459}