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