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/pkg/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 fmt.Sprintf("SOFT_SERVE_JOBS_MIRROR_PULL=%s", c.Jobs.MirrorPull),
187 }...)
188
189 return envs
190}
191
192// IsDebug returns true if the server is running in debug mode.
193func IsDebug() bool {
194 debug, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_DEBUG"))
195 return debug
196}
197
198// IsVerbose returns true if the server is running in verbose mode.
199// Verbose mode is only enabled if debug mode is enabled.
200func IsVerbose() bool {
201 verbose, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_VERBOSE"))
202 return IsDebug() && verbose
203}
204
205// parseFile parses the given file as a configuration file.
206// The file must be in YAML format.
207func parseFile(cfg *Config, path string) error {
208 f, err := os.Open(path)
209 if err != nil {
210 return err
211 }
212
213 defer f.Close() // nolint: errcheck
214 if err := yaml.NewDecoder(f).Decode(cfg); err != nil {
215 return fmt.Errorf("decode config: %w", err)
216 }
217
218 return cfg.Validate()
219}
220
221// ParseFile parses the config from the default file path.
222// This also calls Validate() on the config.
223func (c *Config) ParseFile() error {
224 return parseFile(c, c.ConfigPath())
225}
226
227// parseEnv parses the environment variables as a configuration file.
228func parseEnv(cfg *Config) error {
229 // Merge initial admin keys from both config file and environment variables.
230 initialAdminKeys := append([]string{}, cfg.InitialAdminKeys...)
231
232 // Override with environment variables
233 if err := env.ParseWithOptions(cfg, env.Options{
234 Prefix: "SOFT_SERVE_",
235 }); err != nil {
236 return fmt.Errorf("parse environment variables: %w", err)
237 }
238
239 // Merge initial admin keys from environment variables.
240 if initialAdminKeysEnv := os.Getenv("SOFT_SERVE_INITIAL_ADMIN_KEYS"); initialAdminKeysEnv != "" {
241 cfg.InitialAdminKeys = append(cfg.InitialAdminKeys, initialAdminKeys...)
242 }
243
244 return cfg.Validate()
245}
246
247// ParseEnv parses the config from the environment variables.
248// This also calls Validate() on the config.
249func (c *Config) ParseEnv() error {
250 return parseEnv(c)
251}
252
253// Parse parses the config from the default file path and environment variables.
254// This also calls Validate() on the config.
255func (c *Config) Parse() error {
256 if err := c.ParseFile(); err != nil {
257 return err
258 }
259
260 return c.ParseEnv()
261}
262
263// writeConfig writes the configuration to the given file.
264func writeConfig(cfg *Config, path string) error {
265 if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
266 return err
267 }
268 return os.WriteFile(path, []byte(newConfigFile(cfg)), 0o644) // nolint: errcheck, gosec
269}
270
271// WriteConfig writes the configuration to the default file.
272func (c *Config) WriteConfig() error {
273 return writeConfig(c, c.ConfigPath())
274}
275
276// DefaultDataPath returns the path to the data directory.
277// It uses the SOFT_SERVE_DATA_PATH environment variable if set, otherwise it
278// uses "data".
279func DefaultDataPath() string {
280 dp := os.Getenv("SOFT_SERVE_DATA_PATH")
281 if dp == "" {
282 dp = "data"
283 }
284
285 return dp
286}
287
288// ConfigPath returns the path to the config file.
289func (c *Config) ConfigPath() string { // nolint:revive
290 return filepath.Join(c.DataPath, "config.yaml")
291}
292
293func exist(path string) bool {
294 _, err := os.Stat(path)
295 return err == nil
296}
297
298// Exist returns true if the config file exists.
299func (c *Config) Exist() bool {
300 return exist(filepath.Join(c.DataPath, "config.yaml"))
301}
302
303// DefaultConfig returns the default Config. All the path values are relative
304// to the data directory.
305// Use Validate() to validate the config and ensure absolute paths.
306func DefaultConfig() *Config {
307 return &Config{
308 Name: "Soft Serve",
309 DataPath: DefaultDataPath(),
310 SSH: SSHConfig{
311 ListenAddr: ":23231",
312 PublicURL: "ssh://localhost:23231",
313 KeyPath: filepath.Join("ssh", "soft_serve_host_ed25519"),
314 ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
315 MaxTimeout: 0,
316 IdleTimeout: 10 * 60, // 10 minutes
317 },
318 Git: GitConfig{
319 ListenAddr: ":9418",
320 PublicURL: "git://localhost",
321 MaxTimeout: 0,
322 IdleTimeout: 3,
323 MaxConnections: 32,
324 },
325 HTTP: HTTPConfig{
326 ListenAddr: ":23232",
327 PublicURL: "http://localhost:23232",
328 },
329 Stats: StatsConfig{
330 ListenAddr: "localhost:23233",
331 },
332 Log: LogConfig{
333 Format: "text",
334 TimeFormat: time.DateTime,
335 },
336 DB: DBConfig{
337 Driver: "sqlite",
338 DataSource: "soft-serve.db" +
339 "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)",
340 },
341 LFS: LFSConfig{
342 Enabled: true,
343 SSHEnabled: false,
344 },
345 Jobs: JobsConfig{
346 MirrorPull: "@every 10m",
347 },
348 }
349}
350
351// Validate validates the configuration.
352// It updates the configuration with absolute paths.
353func (c *Config) Validate() error {
354 // Use absolute paths
355 if !filepath.IsAbs(c.DataPath) {
356 dp, err := filepath.Abs(c.DataPath)
357 if err != nil {
358 return err
359 }
360 c.DataPath = dp
361 }
362
363 c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/")
364 c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/")
365
366 if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) {
367 c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
368 }
369
370 if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {
371 c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)
372 }
373
374 if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
375 c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)
376 }
377
378 if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) {
379 c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)
380 }
381
382 if strings.HasPrefix(c.DB.Driver, "sqlite") && !filepath.IsAbs(c.DB.DataSource) {
383 c.DB.DataSource = filepath.Join(c.DataPath, c.DB.DataSource)
384 }
385
386 // Validate keys
387 pks := make([]string, 0)
388 for _, key := range parseAuthKeys(c.InitialAdminKeys) {
389 ak := sshutils.MarshalAuthorizedKey(key)
390 pks = append(pks, ak)
391 }
392
393 c.InitialAdminKeys = pks
394
395 return nil
396}
397
398// parseAuthKeys parses authorized keys from either file paths or string authorized_keys.
399func parseAuthKeys(aks []string) []ssh.PublicKey {
400 exist := make(map[string]struct{}, 0)
401 pks := make([]ssh.PublicKey, 0)
402 for _, key := range aks {
403 if bts, err := os.ReadFile(key); err == nil {
404 // key is a file
405 key = strings.TrimSpace(string(bts))
406 }
407
408 if pk, _, err := sshutils.ParseAuthorizedKey(key); err == nil {
409 if _, ok := exist[key]; !ok {
410 pks = append(pks, pk)
411 exist[key] = struct{}{}
412 }
413 }
414 }
415 return pks
416}
417
418// AdminKeys returns the server admin keys.
419func (c *Config) AdminKeys() []ssh.PublicKey {
420 return parseAuthKeys(c.InitialAdminKeys)
421}