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