Parent directory

config.go

17784 bytes
  1package config
  2
  3import (
  4	"fmt"
  5	"os"
  6	"path/filepath"
  7	"strconv"
  8	"strings"
  9	"time"
 10
 11	"github.com/caarlos0/env/v11"
 12	"github.com/charmbracelet/soft-serve/pkg/access"
 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// CORSConfig is the CORS configuration for the server.
 66type CORSConfig struct {
 67	AllowedHeaders []string `env:"ALLOWED_HEADERS" yaml:"allowed_headers"`
 68
 69	AllowedOrigins []string `env:"ALLOWED_ORIGINS" yaml:"allowed_origins"`
 70
 71	AllowedMethods []string `env:"ALLOWED_METHODS" yaml:"allowed_methods"`
 72}
 73
 74// WebUIConfig is the configuration for the public web UI.
 75type WebUIConfig struct {
 76	Enabled        bool   `env:"ENABLED" yaml:"enabled"`
 77	BuildStatusURL string `env:"BUILD_STATUS_URL" yaml:"build_status_url"`
 78}
 79
 80// HTTPConfig is the HTTP configuration for the server.
 81type HTTPConfig struct {
 82	// Enabled toggles the HTTP server on/off
 83	Enabled bool `env:"ENABLED" yaml:"enabled"`
 84
 85	// ListenAddr is the address on which the HTTP server will listen.
 86	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 87
 88	// TLSKeyPath is the path to the TLS private key.
 89	TLSKeyPath string `env:"TLS_KEY_PATH" yaml:"tls_key_path"`
 90
 91	// TLSCertPath is the path to the TLS certificate.
 92	TLSCertPath string `env:"TLS_CERT_PATH" yaml:"tls_cert_path"`
 93
 94	// PublicURL is the public URL of the HTTP server.
 95	PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
 96
 97	// CORS is the cross-origin configuration for the HTTP server.
 98	CORS CORSConfig `envPrefix:"CORS_" yaml:"cors"`
 99
100	// WebUI is the configuration for the public web UI.
101	WebUI WebUIConfig `envPrefix:"WEB_UI_" yaml:"web_ui"`
102}
103
104// StatsConfig is the configuration for the stats server.
105type StatsConfig struct {
106	// Enabled toggles the Stats server on/off
107	Enabled bool `env:"ENABLED" yaml:"enabled"`
108
109	// ListenAddr is the address on which the stats server will listen.
110	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
111}
112
113// LogConfig is the logger configuration.
114type LogConfig struct {
115	// Format is the format of the logs.
116	// Valid values are "json", "logfmt", and "text".
117	Format string `env:"FORMAT" yaml:"format"`
118
119	// Time format for the log `ts` field.
120	// Format must be described in Golang's time format.
121	TimeFormat string `env:"TIME_FORMAT" yaml:"time_format"`
122
123	// Path to a file to write logs to.
124	// If not set, logs will be written to stderr.
125	Path string `env:"PATH" yaml:"path"`
126}
127
128// DBConfig is the database connection configuration.
129type DBConfig struct {
130	// Driver is the driver for the database.
131	Driver string `env:"DRIVER" yaml:"driver"`
132
133	// DataSource is the database data source name.
134	DataSource string `env:"DATA_SOURCE" yaml:"data_source"`
135}
136
137// LFSConfig is the configuration for Git LFS.
138type LFSConfig struct {
139	// Enabled is whether or not Git LFS is enabled.
140	Enabled bool `env:"ENABLED" yaml:"enabled"`
141
142	// SSHEnabled is whether or not Git LFS over SSH is enabled.
143	// This is only used if LFS is enabled.
144	SSHEnabled bool `env:"SSH_ENABLED" yaml:"ssh_enabled"`
145}
146
147// JobsConfig is the configuration for cron jobs.
148type JobsConfig struct {
149	MirrorPull string `env:"MIRROR_PULL" yaml:"mirror_pull"`
150}
151
152// Config is the configuration for Soft Serve.
153type Config struct {
154	// Name is the name of the server.
155	Name string `env:"NAME" yaml:"name"`
156
157	// SSH is the configuration for the SSH server.
158	SSH SSHConfig `envPrefix:"SSH_" yaml:"ssh"`
159
160	// Git is the configuration for the Git daemon.
161	Git GitConfig `envPrefix:"GIT_" yaml:"git"`
162
163	// HTTP is the configuration for the HTTP server.
164	HTTP HTTPConfig `envPrefix:"HTTP_" yaml:"http"`
165
166	// Stats is the configuration for the stats server.
167	Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"`
168
169	// Log is the logger configuration.
170	Log LogConfig `envPrefix:"LOG_" yaml:"log"`
171
172	// DB is the database configuration.
173	DB DBConfig `envPrefix:"DB_" yaml:"db"`
174
175	// LFS is the configuration for Git LFS.
176	LFS LFSConfig `envPrefix:"LFS_" yaml:"lfs"`
177
178	// Jobs is the configuration for cron jobs
179	Jobs JobsConfig `envPrefix:"JOBS_" yaml:"jobs"`
180
181	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
182	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
183
184	// AnonAccess overrides the access level for anonymous users.
185	//
186	// If set (non-nil), this takes precedence over the "anon-access" value
187	// stored in the database (see the `settings` command) on every read, for
188	// as long as it remains set. It is not a one-time default: it behaves
189	// like InitialAdminKeys, not like a seed value. Invalid values are
190	// rejected at parse time by AccessLevel's TextUnmarshaler.
191	//
192	// This is intended for scripted, non-production bootstrapping only.
193	AnonAccess *access.AccessLevel `env:"ANON_ACCESS" yaml:"anon_access"`
194
195	// AllowKeyless overrides whether keyless (no public key) connections
196	// are allowed.
197	//
198	// If set (non-nil), this takes precedence over the "allow-keyless"
199	// value stored in the database on every read, same as AnonAccess above.
200	// A nil value means "no override": fall back to the database.
201	AllowKeyless *bool `env:"ALLOW_KEYLESS" yaml:"allow_keyless"`
202
203	// DefaultRepo is a repository name to create on boot if it does not
204	// already exist. Leave empty to disable.
205	DefaultRepo string `env:"DEFAULT_REPO" yaml:"default_repo"`
206
207	// DataPath is the path to the directory where Soft Serve will store its data.
208	DataPath string `env:"DATA_PATH" yaml:"-"`
209}
210
211// Environ returns the config as a list of environment variables.
212func (c *Config) Environ() []string {
213	envs := []string{
214		fmt.Sprintf("SOFT_SERVE_BIN_PATH=%s", binPath),
215	}
216	if c == nil {
217		return envs
218	}
219
220	// TODO: do this dynamically
221	envs = append(envs, []string{
222		fmt.Sprintf("SOFT_SERVE_CONFIG_LOCATION=%s", c.ConfigPath()),
223		fmt.Sprintf("SOFT_SERVE_DATA_PATH=%s", c.DataPath),
224		fmt.Sprintf("SOFT_SERVE_NAME=%s", c.Name),
225		fmt.Sprintf("SOFT_SERVE_INITIAL_ADMIN_KEYS=%s", strings.Join(c.InitialAdminKeys, "\n")),
226		fmt.Sprintf("SOFT_SERVE_DEFAULT_REPO=%s", c.DefaultRepo),
227		fmt.Sprintf("SOFT_SERVE_SSH_ENABLED=%t", c.SSH.Enabled),
228		fmt.Sprintf("SOFT_SERVE_SSH_LISTEN_ADDR=%s", c.SSH.ListenAddr),
229		fmt.Sprintf("SOFT_SERVE_SSH_PUBLIC_URL=%s", c.SSH.PublicURL),
230		fmt.Sprintf("SOFT_SERVE_SSH_KEY_PATH=%s", c.SSH.KeyPath),
231		fmt.Sprintf("SOFT_SERVE_SSH_CLIENT_KEY_PATH=%s", c.SSH.ClientKeyPath),
232		fmt.Sprintf("SOFT_SERVE_SSH_MAX_TIMEOUT=%d", c.SSH.MaxTimeout),
233		fmt.Sprintf("SOFT_SERVE_SSH_IDLE_TIMEOUT=%d", c.SSH.IdleTimeout),
234		fmt.Sprintf("SOFT_SERVE_GIT_ENABLED=%t", c.Git.Enabled),
235		fmt.Sprintf("SOFT_SERVE_GIT_LISTEN_ADDR=%s", c.Git.ListenAddr),
236		fmt.Sprintf("SOFT_SERVE_GIT_PUBLIC_URL=%s", c.Git.PublicURL),
237		fmt.Sprintf("SOFT_SERVE_GIT_MAX_TIMEOUT=%d", c.Git.MaxTimeout),
238		fmt.Sprintf("SOFT_SERVE_GIT_IDLE_TIMEOUT=%d", c.Git.IdleTimeout),
239		fmt.Sprintf("SOFT_SERVE_GIT_MAX_CONNECTIONS=%d", c.Git.MaxConnections),
240		fmt.Sprintf("SOFT_SERVE_HTTP_ENABLED=%t", c.HTTP.Enabled),
241		fmt.Sprintf("SOFT_SERVE_HTTP_LISTEN_ADDR=%s", c.HTTP.ListenAddr),
242		fmt.Sprintf("SOFT_SERVE_HTTP_TLS_KEY_PATH=%s", c.HTTP.TLSKeyPath),
243		fmt.Sprintf("SOFT_SERVE_HTTP_TLS_CERT_PATH=%s", c.HTTP.TLSCertPath),
244		fmt.Sprintf("SOFT_SERVE_HTTP_PUBLIC_URL=%s", c.HTTP.PublicURL),
245		fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS=%s", strings.Join(c.HTTP.CORS.AllowedHeaders, ",")),
246		fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS=%s", strings.Join(c.HTTP.CORS.AllowedOrigins, ",")),
247		fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS=%s", strings.Join(c.HTTP.CORS.AllowedMethods, ",")),
248		fmt.Sprintf("SOFT_SERVE_HTTP_WEB_UI_ENABLED=%t", c.HTTP.WebUI.Enabled),
249		fmt.Sprintf("SOFT_SERVE_HTTP_WEB_UI_BUILD_STATUS_URL=%s", c.HTTP.WebUI.BuildStatusURL),
250		fmt.Sprintf("SOFT_SERVE_STATS_ENABLED=%t", c.Stats.Enabled),
251		fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr),
252		fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format),
253		fmt.Sprintf("SOFT_SERVE_LOG_TIME_FORMAT=%s", c.Log.TimeFormat),
254		fmt.Sprintf("SOFT_SERVE_DB_DRIVER=%s", c.DB.Driver),
255		fmt.Sprintf("SOFT_SERVE_DB_DATA_SOURCE=%s", c.DB.DataSource),
256		fmt.Sprintf("SOFT_SERVE_LFS_ENABLED=%t", c.LFS.Enabled),
257		fmt.Sprintf("SOFT_SERVE_LFS_SSH_ENABLED=%t", c.LFS.SSHEnabled),
258		fmt.Sprintf("SOFT_SERVE_JOBS_MIRROR_PULL=%s", c.Jobs.MirrorPull),
259	}...)
260
261	// AnonAccess and AllowKeyless are tri-state overrides: only emit them
262	// when explicitly set, so a subprocess parsing these envs sees the same
263	// "unset" state (rather than an empty string coercing to a zero value).
264	if c.AnonAccess != nil {
265		envs = append(envs, fmt.Sprintf("SOFT_SERVE_ANON_ACCESS=%s", c.AnonAccess.String()))
266	}
267
268	if c.AllowKeyless != nil {
269		envs = append(envs, fmt.Sprintf("SOFT_SERVE_ALLOW_KEYLESS=%t", *c.AllowKeyless))
270	}
271
272	return envs
273}
274
275// IsDebug returns true if the server is running in debug mode.
276func IsDebug() bool {
277	debug, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_DEBUG"))
278	return debug
279}
280
281// IsVerbose returns true if the server is running in verbose mode.
282// Verbose mode is only enabled if debug mode is enabled.
283func IsVerbose() bool {
284	verbose, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_VERBOSE"))
285	return IsDebug() && verbose
286}
287
288// parseFile parses the given file as a configuration file.
289// The file must be in YAML format.
290func parseFile(cfg *Config, path string) error {
291	f, err := os.Open(path)
292	if err != nil {
293		return err
294	}
295
296	defer f.Close() //nolint: errcheck
297	if err := yaml.NewDecoder(f).Decode(cfg); err != nil {
298		return fmt.Errorf("decode config: %w", err)
299	}
300
301	return cfg.Validate()
302}
303
304// ParseFile parses the config from the default file path.
305// This also calls Validate() on the config.
306func (c *Config) ParseFile() error {
307	return parseFile(c, c.ConfigPath())
308}
309
310// parseEnv parses the environment variables as a configuration file.
311func parseEnv(cfg *Config) error {
312	// Merge initial admin keys from both config file and environment variables.
313	initialAdminKeys := append([]string{}, cfg.InitialAdminKeys...)
314
315	// Override with environment variables
316	if err := env.ParseWithOptions(cfg, env.Options{
317		Prefix: "SOFT_SERVE_",
318	}); err != nil {
319		return fmt.Errorf("parse environment variables: %w", err)
320	}
321
322	// Merge initial admin keys from environment variables.
323	if initialAdminKeysEnv := os.Getenv("SOFT_SERVE_INITIAL_ADMIN_KEYS"); initialAdminKeysEnv != "" {
324		cfg.InitialAdminKeys = append(cfg.InitialAdminKeys, initialAdminKeys...)
325	}
326
327	return cfg.Validate()
328}
329
330// ParseEnv parses the config from the environment variables.
331// This also calls Validate() on the config.
332func (c *Config) ParseEnv() error {
333	return parseEnv(c)
334}
335
336// Parse parses the config from the default file path and environment variables.
337// This also calls Validate() on the config.
338func (c *Config) Parse() error {
339	if err := c.ParseFile(); err != nil {
340		return err
341	}
342
343	return c.ParseEnv()
344}
345
346// writeConfig writes the configuration to the given file.
347func writeConfig(cfg *Config, path string) error {
348	if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
349		return err
350	}
351	return os.WriteFile(path, []byte(newConfigFile(cfg)), 0o644) //nolint: errcheck, gosec
352}
353
354// WriteConfig writes the configuration to the default file.
355func (c *Config) WriteConfig() error {
356	return writeConfig(c, c.ConfigPath())
357}
358
359// DefaultDataPath returns the path to the data directory.
360// It uses the SOFT_SERVE_DATA_PATH environment variable if set, otherwise it
361// uses "data".
362func DefaultDataPath() string {
363	dp := os.Getenv("SOFT_SERVE_DATA_PATH")
364	if dp == "" {
365		dp = "data"
366	}
367
368	return dp
369}
370
371// ConfigPath returns the path to the config file.
372func (c *Config) ConfigPath() string { //nolint:revive
373	// If we have a custom config location set, then use that.
374	if path := os.Getenv("SOFT_SERVE_CONFIG_LOCATION"); exist(path) {
375		return path
376	}
377
378	// Otherwise, look in the data path.
379	return filepath.Join(c.DataPath, "config.yaml")
380}
381
382func exist(path string) bool {
383	_, err := os.Stat(path)
384	return err == nil
385}
386
387// Exist returns true if the config file exists.
388func (c *Config) Exist() bool {
389	return exist(c.ConfigPath())
390}
391
392// DefaultConfig returns the default Config. All the path values are relative
393// to the data directory.
394// Use Validate() to validate the config and ensure absolute paths.
395func DefaultConfig() *Config {
396	return &Config{
397		Name: "Soft Serve",
398		// DefaultRepo: "",
399		DataPath: DefaultDataPath(),
400		SSH: SSHConfig{
401			Enabled:       true,
402			ListenAddr:    ":23231",
403			PublicURL:     "ssh://localhost:23231",
404			KeyPath:       filepath.Join("ssh", "soft_serve_host_ed25519"),
405			ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
406			MaxTimeout:    0,
407			IdleTimeout:   10 * 60, // 10 minutes
408		},
409		Git: GitConfig{
410			Enabled:        true,
411			ListenAddr:     ":9418",
412			PublicURL:      "git://localhost",
413			MaxTimeout:     0,
414			IdleTimeout:    3,
415			MaxConnections: 32,
416		},
417		HTTP: HTTPConfig{
418			Enabled:    true,
419			ListenAddr: ":23232",
420			PublicURL:  "http://localhost:23232",
421			WebUI:      WebUIConfig{Enabled: false, BuildStatusURL: ""},
422			CORS: CORSConfig{
423				AllowedHeaders: []string{"Accept", "Accept-Language", "Content-Language", "Content-Type", "Origin", "X-Requested-With", "User-Agent", "Authorization", "Access-Control-Request-Method", "Access-Control-Allow-Origin"},
424				AllowedMethods: []string{"GET", "HEAD", "POST", "PUT", "OPTIONS"},
425				AllowedOrigins: []string{"http://localhost:23232"},
426			},
427		},
428		Stats: StatsConfig{
429			Enabled:    true,
430			ListenAddr: "localhost:23233",
431		},
432		Log: LogConfig{
433			Format:     "text",
434			TimeFormat: time.DateTime,
435		},
436		DB: DBConfig{
437			Driver: "sqlite",
438			DataSource: "soft-serve.db" +
439				"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)",
440		},
441		LFS: LFSConfig{
442			Enabled:    true,
443			SSHEnabled: false,
444		},
445		Jobs: JobsConfig{
446			MirrorPull: "@every 10m",
447		},
448	}
449}
450
451// Validate validates the configuration.
452// It updates the configuration with absolute paths.
453func (c *Config) Validate() error {
454	// Use absolute paths
455	if !filepath.IsAbs(c.DataPath) {
456		dp, err := filepath.Abs(c.DataPath)
457		if err != nil {
458			return err
459		}
460		c.DataPath = dp
461	}
462
463	c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/")
464	c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/")
465
466	if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) {
467		c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
468	}
469
470	if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {
471		c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)
472	}
473
474	if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
475		c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)
476	}
477
478	if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) {
479		c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)
480	}
481
482	if strings.HasPrefix(c.DB.Driver, "sqlite") && !filepath.IsAbs(c.DB.DataSource) {
483		c.DB.DataSource = filepath.Join(c.DataPath, c.DB.DataSource)
484	}
485
486	// Validate keys
487	pks := make([]string, 0)
488	for _, key := range parseAuthKeys(c.InitialAdminKeys) {
489		ak := sshutils.MarshalAuthorizedKey(key)
490		pks = append(pks, ak)
491	}
492
493	c.InitialAdminKeys = pks
494
495	c.HTTP.CORS.AllowedOrigins = append([]string{c.HTTP.PublicURL}, c.HTTP.CORS.AllowedOrigins...)
496
497	return nil
498}
499
500// parseAuthKeys parses authorized keys from either file paths or string authorized_keys.
501func parseAuthKeys(aks []string) []ssh.PublicKey {
502	exist := make(map[string]struct{}, 0)
503	pks := make([]ssh.PublicKey, 0)
504	for _, key := range aks {
505		if bts, err := os.ReadFile(key); err == nil {
506			// key is a file
507			key = strings.TrimSpace(string(bts))
508		}
509
510		if pk, _, err := sshutils.ParseAuthorizedKey(key); err == nil {
511			if _, ok := exist[key]; !ok {
512				pks = append(pks, pk)
513				exist[key] = struct{}{}
514			}
515		}
516	}
517	return pks
518}
519
520// AdminKeys returns the server admin keys.
521func (c *Config) AdminKeys() []ssh.PublicKey {
522	return parseAuthKeys(c.InitialAdminKeys)
523}
524
525func init() {
526	if ex, err := os.Executable(); err == nil {
527		binPath = filepath.ToSlash(ex)
528	}
529}