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