config.go

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