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 err
232 }
233
234 defer f.Close() //nolint: errcheck
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 err
288 }
289 return os.WriteFile(path, []byte(newConfigFile(cfg)), 0o644) //nolint: errcheck, gosec
290}
291
292// WriteConfig writes the configuration to the default file.
293func (c *Config) WriteConfig() error {
294 return writeConfig(c, c.ConfigPath())
295}
296
297// DefaultDataPath returns the path to the data directory.
298// It uses the SOFT_SERVE_DATA_PATH environment variable if set, otherwise it
299// uses "data".
300func DefaultDataPath() string {
301 dp := os.Getenv("SOFT_SERVE_DATA_PATH")
302 if dp == "" {
303 dp = "data"
304 }
305
306 return dp
307}
308
309// ConfigPath returns the path to the config file.
310func (c *Config) ConfigPath() string {
311 // If we have a custom config location set, then use that.
312 if path := os.Getenv("SOFT_SERVE_CONFIG_LOCATION"); exist(path) {
313 return path
314 }
315
316 // Otherwise, look in the data path.
317 return filepath.Join(c.DataPath, "config.yaml")
318}
319
320func exist(path string) bool {
321 _, err := os.Stat(path)
322 return err == nil
323}
324
325// Exist returns true if the config file exists.
326func (c *Config) Exist() bool {
327 return exist(c.ConfigPath())
328}
329
330// DefaultConfig returns the default Config. All the path values are relative
331// to the data directory.
332// Use Validate() to validate the config and ensure absolute paths.
333func DefaultConfig() *Config {
334 return &Config{
335 Name: "Soft Serve",
336 DataPath: DefaultDataPath(),
337 SSH: SSHConfig{
338 Enabled: true,
339 ListenAddr: ":23231",
340 PublicURL: "ssh://localhost:23231",
341 KeyPath: filepath.Join("ssh", "soft_serve_host_ed25519"),
342 ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
343 MaxTimeout: 0,
344 IdleTimeout: 10 * 60, // 10 minutes
345 },
346 Git: GitConfig{
347 Enabled: true,
348 ListenAddr: ":9418",
349 PublicURL: "git://localhost",
350 MaxTimeout: 0,
351 IdleTimeout: 3,
352 MaxConnections: 32,
353 },
354 HTTP: HTTPConfig{
355 Enabled: true,
356 ListenAddr: ":23232",
357 PublicURL: "http://localhost:23232",
358 },
359 Stats: StatsConfig{
360 Enabled: true,
361 ListenAddr: "localhost:23233",
362 },
363 Log: LogConfig{
364 Format: "text",
365 TimeFormat: time.DateTime,
366 },
367 DB: DBConfig{
368 Driver: "sqlite",
369 DataSource: "soft-serve.db" +
370 "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)",
371 },
372 LFS: LFSConfig{
373 Enabled: true,
374 SSHEnabled: false,
375 },
376 Jobs: JobsConfig{
377 MirrorPull: "@every 10m",
378 },
379 }
380}
381
382// Validate validates the configuration.
383// It updates the configuration with absolute paths.
384func (c *Config) Validate() error {
385 // Use absolute paths
386 if !filepath.IsAbs(c.DataPath) {
387 dp, err := filepath.Abs(c.DataPath)
388 if err != nil {
389 return err
390 }
391 c.DataPath = dp
392 }
393
394 c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/")
395 c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/")
396
397 if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) {
398 c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
399 }
400
401 if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {
402 c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)
403 }
404
405 if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
406 c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)
407 }
408
409 if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) {
410 c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)
411 }
412
413 if strings.HasPrefix(c.DB.Driver, "sqlite") && !filepath.IsAbs(c.DB.DataSource) {
414 c.DB.DataSource = filepath.Join(c.DataPath, c.DB.DataSource)
415 }
416
417 // Validate keys
418 pks := make([]string, 0)
419 for _, key := range parseAuthKeys(c.InitialAdminKeys) {
420 ak := sshutils.MarshalAuthorizedKey(key)
421 pks = append(pks, ak)
422 }
423
424 c.InitialAdminKeys = pks
425
426 return nil
427}
428
429// parseAuthKeys parses authorized keys from either file paths or string authorized_keys.
430func parseAuthKeys(aks []string) []ssh.PublicKey {
431 exist := make(map[string]struct{}, 0)
432 pks := make([]ssh.PublicKey, 0)
433 for _, key := range aks {
434 if bts, err := os.ReadFile(key); err == nil {
435 // key is a file
436 key = strings.TrimSpace(string(bts))
437 }
438
439 if pk, _, err := sshutils.ParseAuthorizedKey(key); err == nil {
440 if _, ok := exist[key]; !ok {
441 pks = append(pks, pk)
442 exist[key] = struct{}{}
443 }
444 }
445 }
446 return pks
447}
448
449// AdminKeys returns the server admin keys.
450func (c *Config) AdminKeys() []ssh.PublicKey {
451 return parseAuthKeys(c.InitialAdminKeys)
452}
453
454func init() {
455 if ex, err := os.Executable(); err == nil {
456 binPath = filepath.ToSlash(ex)
457 }
458}