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