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