1package config
2
3import (
4 "errors"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9
10 "github.com/caarlos0/env/v7"
11 "github.com/charmbracelet/log"
12 "github.com/charmbracelet/soft-serve/server/backend"
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 // MaxTimeout is the maximum number of seconds a connection can take.
29 MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout`
30
31 // IdleTimeout is the number of seconds a connection can be idle before it is closed.
32 IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"`
33}
34
35// GitConfig is the Git daemon configuration for the server.
36type GitConfig struct {
37 // ListenAddr is the address on which the Git daemon will listen.
38 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
39
40 // MaxTimeout is the maximum number of seconds a connection can take.
41 MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
42
43 // IdleTimeout is the number of seconds a connection can be idle before it is closed.
44 IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"`
45
46 // MaxConnections is the maximum number of concurrent connections.
47 MaxConnections int `env:"MAX_CONNECTIONS" yaml:"max_connections"`
48}
49
50// HTTPConfig is the HTTP configuration for the server.
51type HTTPConfig struct {
52 // ListenAddr is the address on which the HTTP server will listen.
53 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
54
55 // TLSKeyPath is the path to the TLS private key.
56 TLSKeyPath string `env:"TLS_KEY_PATH" yaml:"tls_key_path"`
57
58 // TLSCertPath is the path to the TLS certificate.
59 TLSCertPath string `env:"TLS_CERT_PATH" yaml:"tls_cert_path"`
60
61 // PublicURL is the public URL of the HTTP server.
62 PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
63}
64
65// StatsConfig is the configuration for the stats server.
66type StatsConfig struct {
67 // ListenAddr is the address on which the stats server will listen.
68 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
69}
70
71// InternalConfig is the configuration for the internal server.
72// This is used for internal communication between the Soft Serve client and server.
73type InternalConfig struct {
74 // ListenAddr is the address on which the internal server will listen.
75 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
76
77 // KeyPath is the path to the SSH server's host private key.
78 KeyPath string `env:"KEY_PATH" yaml:"key_path"`
79
80 // InternalKeyPath is the path to the server's internal private key.
81 InternalKeyPath string `env:"INTERNAL_KEY_PATH" yaml:"internal_key_path"`
82
83 // ClientKeyPath is the path to the server's client private key.
84 ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`
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 // Internal is the configuration for the internal server.
105 Internal InternalConfig `envPrefix:"INTERNAL_" yaml:"internal"`
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
117func parseConfig(path string) (*Config, error) {
118 dataPath := filepath.Dir(path)
119 cfg := &Config{
120 Name: "Soft Serve",
121 DataPath: dataPath,
122 SSH: SSHConfig{
123 ListenAddr: ":23231",
124 PublicURL: "ssh://localhost:23231",
125 KeyPath: filepath.Join("ssh", "soft_serve_host_ed25519"),
126 MaxTimeout: 0,
127 IdleTimeout: 120,
128 },
129 Git: GitConfig{
130 ListenAddr: ":9418",
131 MaxTimeout: 0,
132 IdleTimeout: 3,
133 MaxConnections: 32,
134 },
135 HTTP: HTTPConfig{
136 ListenAddr: ":23232",
137 PublicURL: "http://localhost:23232",
138 },
139 Stats: StatsConfig{
140 ListenAddr: "localhost:23233",
141 },
142 Internal: InternalConfig{
143 ListenAddr: "localhost:23230",
144 KeyPath: filepath.Join("ssh", "soft_serve_internal_host_ed25519"),
145 InternalKeyPath: filepath.Join("ssh", "soft_serve_internal_ed25519"),
146 ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
147 },
148 }
149
150 f, err := os.Open(path)
151 if err == nil {
152 defer f.Close() // nolint: errcheck
153 if err := yaml.NewDecoder(f).Decode(cfg); err != nil {
154 return cfg, fmt.Errorf("decode config: %w", err)
155 }
156 }
157
158 // Merge initial admin keys from both config file and environment variables.
159 initialAdminKeys := append([]string{}, cfg.InitialAdminKeys...)
160
161 // Override with environment variables
162 if err := env.Parse(cfg, env.Options{
163 Prefix: "SOFT_SERVE_",
164 }); err != nil {
165 return cfg, fmt.Errorf("parse environment variables: %w", err)
166 }
167
168 // Merge initial admin keys from environment variables.
169 if initialAdminKeysEnv := os.Getenv("SOFT_SERVE_INITIAL_ADMIN_KEYS"); initialAdminKeysEnv != "" {
170 cfg.InitialAdminKeys = append(cfg.InitialAdminKeys, initialAdminKeys...)
171 }
172
173 // Validate keys
174 pks := make([]string, 0)
175 for _, key := range parseAuthKeys(cfg.InitialAdminKeys) {
176 ak := backend.MarshalAuthorizedKey(key)
177 pks = append(pks, ak)
178 log.Debugf("found initial admin key: %q", ak)
179 }
180
181 cfg.InitialAdminKeys = pks
182
183 // Reset datapath to config dir.
184 // This is necessary because the environment variable may be set to
185 // a different directory.
186 cfg.DataPath = dataPath
187
188 return cfg, nil
189}
190
191// ParseConfig parses the configuration from the given file.
192func ParseConfig(path string) (*Config, error) {
193 cfg, err := parseConfig(path)
194 if err != nil {
195 return nil, err
196 }
197
198 if err := cfg.validate(); err != nil {
199 return nil, err
200 }
201
202 return cfg, nil
203}
204
205// WriteConfig writes the configuration to the given file.
206func WriteConfig(path string, cfg *Config) error {
207 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
208 return err
209 }
210 return os.WriteFile(path, []byte(newConfigFile(cfg)), 0o600) // nolint: errcheck
211}
212
213// DefaultConfig returns a Config with the values populated with the defaults
214// or specified environment variables.
215func DefaultConfig() *Config {
216 dataPath := os.Getenv("SOFT_SERVE_DATA_PATH")
217 if dataPath == "" {
218 dataPath = "data"
219 }
220
221 cp := filepath.Join(dataPath, "config.yaml")
222 cfg, err := parseConfig(cp)
223 if err != nil && !errors.Is(err, os.ErrNotExist) {
224 log.Errorf("failed to parse config: %v", err)
225 }
226
227 // Write config if it doesn't exist
228 if _, err := os.Stat(cp); os.IsNotExist(err) {
229 if err := WriteConfig(cp, cfg); err != nil {
230 log.Fatal("failed to write config", "err", err)
231 }
232 }
233
234 if err := cfg.validate(); err != nil {
235 log.Fatal(err)
236 }
237
238 return cfg
239}
240
241// WithBackend sets the backend for the configuration.
242func (c *Config) WithBackend(backend backend.Backend) *Config {
243 c.Backend = backend
244 return c
245}
246
247func (c *Config) validate() error {
248 // Use absolute paths
249 if !filepath.IsAbs(c.DataPath) {
250 dp, err := filepath.Abs(c.DataPath)
251 if err != nil {
252 return err
253 }
254 c.DataPath = dp
255 }
256
257 c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/")
258 c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/")
259
260 if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) {
261 c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
262 }
263
264 if c.Internal.KeyPath != "" && !filepath.IsAbs(c.Internal.KeyPath) {
265 c.Internal.KeyPath = filepath.Join(c.DataPath, c.Internal.KeyPath)
266 }
267
268 if c.Internal.ClientKeyPath != "" && !filepath.IsAbs(c.Internal.ClientKeyPath) {
269 c.Internal.ClientKeyPath = filepath.Join(c.DataPath, c.Internal.ClientKeyPath)
270 }
271
272 if c.Internal.InternalKeyPath != "" && !filepath.IsAbs(c.Internal.InternalKeyPath) {
273 c.Internal.InternalKeyPath = filepath.Join(c.DataPath, c.Internal.InternalKeyPath)
274 }
275
276 if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
277 c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)
278 }
279
280 if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) {
281 c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)
282 }
283
284 return nil
285}
286
287// parseAuthKeys parses authorized keys from either file paths or string authorized_keys.
288func parseAuthKeys(aks []string) []ssh.PublicKey {
289 pks := make([]ssh.PublicKey, 0)
290 for _, key := range aks {
291 if bts, err := os.ReadFile(key); err == nil {
292 // key is a file
293 key = strings.TrimSpace(string(bts))
294 }
295 if pk, _, err := backend.ParseAuthorizedKey(key); err == nil {
296 pks = append(pks, pk)
297 }
298 }
299 return pks
300}
301
302// AdminKeys returns the admin keys including the internal api key.
303func (c *Config) AdminKeys() []ssh.PublicKey {
304 return parseAuthKeys(append(c.InitialAdminKeys, c.Internal.InternalKeyPath))
305}