1package config
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "github.com/caarlos0/env/v8"
13 "github.com/charmbracelet/soft-serve/server/backend"
14 "golang.org/x/crypto/ssh"
15 "gopkg.in/yaml.v3"
16)
17
18// SSHConfig is the configuration for the SSH server.
19type SSHConfig struct {
20 // ListenAddr is the address on which the SSH server will listen.
21 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
22
23 // PublicURL is the public URL of the SSH server.
24 PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
25
26 // KeyPath is the path to the SSH server's private key.
27 KeyPath string `env:"KEY_PATH" yaml:"key_path"`
28
29 // ClientKeyPath is the path to the server's client private key.
30 ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`
31
32 // MaxTimeout is the maximum number of seconds a connection can take.
33 MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
34
35 // IdleTimeout is the number of seconds a connection can be idle before it is closed.
36 IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"`
37}
38
39// GitConfig is the Git daemon configuration for the server.
40type GitConfig struct {
41 // ListenAddr is the address on which the Git daemon will listen.
42 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
43
44 // MaxTimeout is the maximum number of seconds a connection can take.
45 MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
46
47 // IdleTimeout is the number of seconds a connection can be idle before it is closed.
48 IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"`
49
50 // MaxConnections is the maximum number of concurrent connections.
51 MaxConnections int `env:"MAX_CONNECTIONS" yaml:"max_connections"`
52}
53
54// HTTPConfig is the HTTP configuration for the server.
55type HTTPConfig struct {
56 // ListenAddr is the address on which the HTTP server will listen.
57 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
58
59 // TLSKeyPath is the path to the TLS private key.
60 TLSKeyPath string `env:"TLS_KEY_PATH" yaml:"tls_key_path"`
61
62 // TLSCertPath is the path to the TLS certificate.
63 TLSCertPath string `env:"TLS_CERT_PATH" yaml:"tls_cert_path"`
64
65 // PublicURL is the public URL of the HTTP server.
66 PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
67}
68
69// StatsConfig is the configuration for the stats server.
70type StatsConfig struct {
71 // ListenAddr is the address on which the stats server will listen.
72 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
73}
74
75// LogConfig is the logger configuration.
76type LogConfig struct {
77 // Format is the format of the logs.
78 // Valid values are "json", "logfmt", and "text".
79 Format string `env:"FORMAT" yaml:"format"`
80
81 // Time format for the log `ts` field.
82 // Format must be described in Golang's time format.
83 TimeFormat string `env:"TIME_FORMAT" yaml:"time_format"`
84}
85
86// Config is the configuration for Soft Serve.
87type Config struct {
88 // Name is the name of the server.
89 Name string `env:"NAME" yaml:"name"`
90
91 // SSH is the configuration for the SSH server.
92 SSH SSHConfig `envPrefix:"SSH_" yaml:"ssh"`
93
94 // Git is the configuration for the Git daemon.
95 Git GitConfig `envPrefix:"GIT_" yaml:"git"`
96
97 // HTTP is the configuration for the HTTP server.
98 HTTP HTTPConfig `envPrefix:"HTTP_" yaml:"http"`
99
100 // Stats is the configuration for the stats server.
101 Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"`
102
103 // Log is the logger configuration.
104 Log LogConfig `envPrefix:"LOG_" yaml:"log"`
105
106 // Cache is the cache backend to use.
107 Cache string `env:"CACHE" yaml:"cache"`
108
109 // InitialAdminKeys is a list of public keys that will be added to the list of admins.
110 InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
111
112 // DataPath is the path to the directory where Soft Serve will store its data.
113 DataPath string `env:"DATA_PATH" yaml:"-"`
114
115 // Backend is the Git backend to use.
116 Backend backend.Backend `yaml:"-"`
117}
118
119// Environ returns the config as a list of environment variables.
120func (c *Config) Environ() []string {
121 envs := []string{}
122 if c == nil {
123 return envs
124 }
125
126 // TODO: do this dynamically
127 envs = append(envs, []string{
128 fmt.Sprintf("SOFT_SERVE_NAME=%s", c.Name),
129 fmt.Sprintf("SOFT_SERVE_DATA_PATH=%s", c.DataPath),
130 fmt.Sprintf("SOFT_SERVE_INITIAL_ADMIN_KEYS=%s", strings.Join(c.InitialAdminKeys, "\n")),
131 fmt.Sprintf("SOFT_SERVE_SSH_LISTEN_ADDR=%s", c.SSH.ListenAddr),
132 fmt.Sprintf("SOFT_SERVE_SSH_PUBLIC_URL=%s", c.SSH.PublicURL),
133 fmt.Sprintf("SOFT_SERVE_SSH_KEY_PATH=%s", c.SSH.KeyPath),
134 fmt.Sprintf("SOFT_SERVE_SSH_CLIENT_KEY_PATH=%s", c.SSH.ClientKeyPath),
135 fmt.Sprintf("SOFT_SERVE_SSH_MAX_TIMEOUT=%d", c.SSH.MaxTimeout),
136 fmt.Sprintf("SOFT_SERVE_SSH_IDLE_TIMEOUT=%d", c.SSH.IdleTimeout),
137 fmt.Sprintf("SOFT_SERVE_GIT_LISTEN_ADDR=%s", c.Git.ListenAddr),
138 fmt.Sprintf("SOFT_SERVE_GIT_MAX_TIMEOUT=%d", c.Git.MaxTimeout),
139 fmt.Sprintf("SOFT_SERVE_GIT_IDLE_TIMEOUT=%d", c.Git.IdleTimeout),
140 fmt.Sprintf("SOFT_SERVE_GIT_MAX_CONNECTIONS=%d", c.Git.MaxConnections),
141 fmt.Sprintf("SOFT_SERVE_HTTP_LISTEN_ADDR=%s", c.HTTP.ListenAddr),
142 fmt.Sprintf("SOFT_SERVE_HTTP_TLS_KEY_PATH=%s", c.HTTP.TLSKeyPath),
143 fmt.Sprintf("SOFT_SERVE_HTTP_TLS_CERT_PATH=%s", c.HTTP.TLSCertPath),
144 fmt.Sprintf("SOFT_SERVE_HTTP_PUBLIC_URL=%s", c.HTTP.PublicURL),
145 fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr),
146 fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format),
147 fmt.Sprintf("SOFT_SERVE_LOG_TIME_FORMAT=%s", c.Log.TimeFormat),
148 }...)
149
150 return envs
151}
152
153func parseFile(v interface{}, path string) error {
154 f, err := os.Open(path)
155 if err != nil {
156 return fmt.Errorf("open config file: %w", err)
157 }
158
159 defer f.Close() // nolint: errcheck
160 if err := yaml.NewDecoder(f).Decode(v); err != nil {
161 return fmt.Errorf("decode config: %w", err)
162 }
163
164 return nil
165}
166
167func parseEnv(v interface{}) error {
168 // Override with environment variables
169 if err := env.ParseWithOptions(v, env.Options{
170 Prefix: "SOFT_SERVE_",
171 }); err != nil {
172 return fmt.Errorf("parse environment variables: %w", err)
173 }
174
175 return nil
176}
177
178// ParseConfig parses the configuration from environment variables the given
179// file.
180func ParseConfig(v interface{}, path string) error {
181 return errors.Join(parseFile(v, path), parseEnv(v))
182}
183
184// NewConfig retruns a new Config with values populated from environment
185// variables and config file.
186//
187// If the config file does not exist, it will be created with the default
188// values.
189//
190// Environment variables will override values in the config file except for the
191// initial_admin_keys.
192//
193// If path is empty, the default config file path will be used.
194func NewConfig(path string) (*Config, error) {
195 cfg := DefaultConfig()
196 if path != "" {
197 cfg.DataPath = filepath.Dir(path)
198 }
199
200 // Parse file
201 if cfg.Exist() {
202 if err := parseFile(cfg, cfg.FilePath()); err != nil {
203 return cfg, err
204 }
205 }
206
207 // Merge initial admin keys from both config file and environment variables.
208 initialAdminKeys := append([]string{}, cfg.InitialAdminKeys...)
209
210 if err := parseEnv(cfg); err != nil {
211 return cfg, err
212 }
213
214 // Merge initial admin keys from environment variables.
215 if initialAdminKeysEnv := os.Getenv("SOFT_SERVE_INITIAL_ADMIN_KEYS"); initialAdminKeysEnv != "" {
216 cfg.InitialAdminKeys = append(cfg.InitialAdminKeys, initialAdminKeys...)
217 }
218
219 // Validate keys
220 pks := make([]string, 0)
221 for _, key := range parseAuthKeys(cfg.InitialAdminKeys) {
222 ak := backend.MarshalAuthorizedKey(key)
223 pks = append(pks, ak)
224 }
225
226 cfg.InitialAdminKeys = pks
227
228 // Reset datapath to config dir.
229 // This is necessary because the environment variable may be set to
230 // a different directory.
231 // cfg.DataPath = dataPath
232
233 if err := cfg.validate(); err != nil {
234 return cfg, err
235 }
236
237 return cfg, cfg.WriteConfig()
238}
239
240// DefaultConfig returns a Config with the default values.
241func DefaultConfig() *Config {
242 dataPath := os.Getenv("SOFT_SERVE_DATA_PATH")
243 if dataPath == "" {
244 dataPath = "data"
245 }
246
247 cfg := &Config{
248 Name: "Soft Serve",
249 DataPath: dataPath,
250 Cache: "lru",
251 SSH: SSHConfig{
252 ListenAddr: ":23231",
253 PublicURL: "ssh://localhost:23231",
254 KeyPath: filepath.Join("ssh", "soft_serve_host_ed25519"),
255 ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
256 MaxTimeout: 0,
257 IdleTimeout: 0,
258 },
259 Git: GitConfig{
260 ListenAddr: ":9418",
261 MaxTimeout: 0,
262 IdleTimeout: 3,
263 MaxConnections: 32,
264 },
265 HTTP: HTTPConfig{
266 ListenAddr: ":23232",
267 PublicURL: "http://localhost:23232",
268 },
269 Stats: StatsConfig{
270 ListenAddr: "localhost:23233",
271 },
272 Log: LogConfig{
273 Format: "text",
274 TimeFormat: time.DateTime,
275 },
276 }
277
278 return cfg
279}
280
281// FilePath returns the expected config file path.
282func (c *Config) FilePath() string {
283 return filepath.Join(c.DataPath, "config.yaml")
284}
285
286// Exist returns true if the configuration file exists.
287func (c *Config) Exist() bool {
288 _, err := os.Stat(c.FilePath())
289 return err == nil
290}
291
292// WriteConfig writes the configuration in the default path.
293func (c *Config) WriteConfig() error {
294 fp := c.FilePath()
295 if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
296 return err
297 }
298 return os.WriteFile(fp, []byte(newConfigFile(c)), 0o644) // nolint: errcheck
299}
300
301// WithBackend sets the backend for the configuration.
302// TODO: remove in favor of backend.FromContext.
303func (c *Config) WithBackend(backend backend.Backend) *Config {
304 c.Backend = backend
305 return c
306}
307
308func (c *Config) validate() error {
309 // Use absolute paths
310 if !filepath.IsAbs(c.DataPath) {
311 dp, err := filepath.Abs(c.DataPath)
312 if err != nil {
313 return err
314 }
315 c.DataPath = dp
316 }
317
318 c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/")
319 c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/")
320
321 if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) {
322 c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
323 }
324
325 if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {
326 c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)
327 }
328
329 if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
330 c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)
331 }
332
333 if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) {
334 c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)
335 }
336
337 return nil
338}
339
340// parseAuthKeys parses authorized keys from either file paths or string authorized_keys.
341func parseAuthKeys(aks []string) []ssh.PublicKey {
342 pks := make([]ssh.PublicKey, 0)
343 for _, key := range aks {
344 if bts, err := os.ReadFile(key); err == nil {
345 // key is a file
346 key = strings.TrimSpace(string(bts))
347 }
348 if pk, _, err := backend.ParseAuthorizedKey(key); err == nil {
349 pks = append(pks, pk)
350 }
351 }
352 return pks
353}
354
355// AdminKeys returns the server admin keys.
356func (c *Config) AdminKeys() []ssh.PublicKey {
357 return parseAuthKeys(c.InitialAdminKeys)
358}
359
360var configCtxKey = struct{ string }{"config"}
361
362// WithContext returns a new context with the configuration attached.
363func WithContext(ctx context.Context, cfg *Config) context.Context {
364 return context.WithValue(ctx, configCtxKey, cfg)
365}
366
367// FromContext returns the configuration from the context.
368func FromContext(ctx context.Context) *Config {
369 if c, ok := ctx.Value(configCtxKey).(*Config); ok {
370 return c
371 }
372
373 return DefaultConfig()
374}