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