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/v7"
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// Config is the configuration for Soft Serve.
77type Config struct {
78 // Name is the name of the server.
79 Name string `env:"NAME" yaml:"name"`
80
81 // SSH is the configuration for the SSH server.
82 SSH SSHConfig `envPrefix:"SSH_" yaml:"ssh"`
83
84 // Git is the configuration for the Git daemon.
85 Git GitConfig `envPrefix:"GIT_" yaml:"git"`
86
87 // HTTP is the configuration for the HTTP server.
88 HTTP HTTPConfig `envPrefix:"HTTP_" yaml:"http"`
89
90 // Stats is the configuration for the stats server.
91 Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"`
92
93 // LogFormat is the format of the logs.
94 // Valid values are "json", "logfmt", and "text".
95 LogFormat string `env:"LOG_FORMAT" yaml:"log_format"`
96
97 // Time format for the log `ts` field.
98 // Format must be described in Golang's time format.
99 LogTimeFormat string `env:"LOG_TIME_FORMAT" yaml:"log_time_format"`
100
101 // InitialAdminKeys is a list of public keys that will be added to the list of admins.
102 InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
103
104 // DataPath is the path to the directory where Soft Serve will store its data.
105 DataPath string `env:"DATA_PATH" yaml:"-"`
106
107 // Backend is the Git backend to use.
108 Backend backend.Backend `yaml:"-"`
109}
110
111func parseConfig(path string) (*Config, error) {
112 dataPath := filepath.Dir(path)
113 cfg := &Config{
114 Name: "Soft Serve",
115 LogFormat: "text",
116 LogTimeFormat: time.DateOnly,
117 DataPath: dataPath,
118 SSH: SSHConfig{
119 ListenAddr: ":23231",
120 PublicURL: "ssh://localhost:23231",
121 KeyPath: filepath.Join("ssh", "soft_serve_host_ed25519"),
122 ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
123 MaxTimeout: 0,
124 IdleTimeout: 0,
125 },
126 Git: GitConfig{
127 ListenAddr: ":9418",
128 MaxTimeout: 0,
129 IdleTimeout: 3,
130 MaxConnections: 32,
131 },
132 HTTP: HTTPConfig{
133 ListenAddr: ":23232",
134 PublicURL: "http://localhost:23232",
135 },
136 Stats: StatsConfig{
137 ListenAddr: "localhost:23233",
138 },
139 }
140
141 f, err := os.Open(path)
142 if err == nil {
143 defer f.Close() // nolint: errcheck
144 if err := yaml.NewDecoder(f).Decode(cfg); err != nil {
145 return cfg, fmt.Errorf("decode config: %w", err)
146 }
147 }
148
149 // Merge initial admin keys from both config file and environment variables.
150 initialAdminKeys := append([]string{}, cfg.InitialAdminKeys...)
151
152 // Override with environment variables
153 if err := env.Parse(cfg, env.Options{
154 Prefix: "SOFT_SERVE_",
155 }); err != nil {
156 return cfg, fmt.Errorf("parse environment variables: %w", err)
157 }
158
159 // Merge initial admin keys from environment variables.
160 if initialAdminKeysEnv := os.Getenv("SOFT_SERVE_INITIAL_ADMIN_KEYS"); initialAdminKeysEnv != "" {
161 cfg.InitialAdminKeys = append(cfg.InitialAdminKeys, initialAdminKeys...)
162 }
163
164 // Validate keys
165 pks := make([]string, 0)
166 for _, key := range parseAuthKeys(cfg.InitialAdminKeys) {
167 ak := backend.MarshalAuthorizedKey(key)
168 pks = append(pks, ak)
169 }
170
171 cfg.InitialAdminKeys = pks
172
173 // Reset datapath to config dir.
174 // This is necessary because the environment variable may be set to
175 // a different directory.
176 cfg.DataPath = dataPath
177
178 return cfg, nil
179}
180
181// ParseConfig parses the configuration from the given file.
182func ParseConfig(path string) (*Config, error) {
183 cfg, err := parseConfig(path)
184 if err != nil {
185 return nil, err
186 }
187
188 if err := cfg.validate(); err != nil {
189 return nil, err
190 }
191
192 return cfg, nil
193}
194
195// WriteConfig writes the configuration to the given file.
196func WriteConfig(path string, cfg *Config) error {
197 if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
198 return err
199 }
200 return os.WriteFile(path, []byte(newConfigFile(cfg)), 0o644) // nolint: errcheck
201}
202
203// DefaultConfig returns a Config with the values populated with the defaults
204// or specified environment variables.
205func DefaultConfig() *Config {
206 dataPath := os.Getenv("SOFT_SERVE_DATA_PATH")
207 if dataPath == "" {
208 dataPath = "data"
209 }
210
211 cp := filepath.Join(dataPath, "config.yaml")
212 cfg, err := parseConfig(cp)
213 if err != nil && !errors.Is(err, os.ErrNotExist) {
214 log.Errorf("failed to parse config: %v", err)
215 }
216
217 // Write config if it doesn't exist
218 if _, err := os.Stat(cp); os.IsNotExist(err) {
219 if err := WriteConfig(cp, cfg); err != nil {
220 log.Fatal("failed to write config", "err", err)
221 }
222 }
223
224 if err := cfg.validate(); err != nil {
225 log.Fatal(err)
226 }
227
228 return cfg
229}
230
231// WithBackend sets the backend for the configuration.
232func (c *Config) WithBackend(backend backend.Backend) *Config {
233 c.Backend = backend
234 return c
235}
236
237func (c *Config) validate() error {
238 // Use absolute paths
239 if !filepath.IsAbs(c.DataPath) {
240 dp, err := filepath.Abs(c.DataPath)
241 if err != nil {
242 return err
243 }
244 c.DataPath = dp
245 }
246
247 c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/")
248 c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/")
249
250 if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) {
251 c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
252 }
253
254 if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {
255 c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)
256 }
257
258 if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
259 c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)
260 }
261
262 if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) {
263 c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)
264 }
265
266 return nil
267}
268
269// parseAuthKeys parses authorized keys from either file paths or string authorized_keys.
270func parseAuthKeys(aks []string) []ssh.PublicKey {
271 pks := make([]ssh.PublicKey, 0)
272 for _, key := range aks {
273 if bts, err := os.ReadFile(key); err == nil {
274 // key is a file
275 key = strings.TrimSpace(string(bts))
276 }
277 if pk, _, err := backend.ParseAuthorizedKey(key); err == nil {
278 pks = append(pks, pk)
279 }
280 }
281 return pks
282}
283
284// AdminKeys returns the server admin keys.
285func (c *Config) AdminKeys() []ssh.PublicKey {
286 return parseAuthKeys(c.InitialAdminKeys)
287}
288
289var configCtxKey = struct{ string }{"config"}
290
291// WithContext returns a new context with the configuration attached.
292func WithContext(ctx context.Context, cfg *Config) context.Context {
293 return context.WithValue(ctx, configCtxKey, cfg)
294}
295
296// FromContext returns the configuration from the context.
297func FromContext(ctx context.Context) *Config {
298 if c, ok := ctx.Value(configCtxKey).(*Config); ok {
299 return c
300 }
301
302 return DefaultConfig()
303}