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