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