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 // ClientKeyPath is the path to the server's client private key.
29 ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`
30
31 // MaxTimeout is the maximum number of seconds a connection can take.
32 MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
33
34 // IdleTimeout is the number of seconds a connection can be idle before it is closed.
35 IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"`
36}
37
38// GitConfig is the Git daemon configuration for the server.
39type GitConfig struct {
40 // ListenAddr is the address on which the Git daemon will listen.
41 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
42
43 // MaxTimeout is the maximum number of seconds a connection can take.
44 MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
45
46 // IdleTimeout is the number of seconds a connection can be idle before it is closed.
47 IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"`
48
49 // MaxConnections is the maximum number of concurrent connections.
50 MaxConnections int `env:"MAX_CONNECTIONS" yaml:"max_connections"`
51}
52
53// HTTPConfig is the HTTP configuration for the server.
54type HTTPConfig struct {
55 // ListenAddr is the address on which the HTTP server will listen.
56 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
57
58 // TLSKeyPath is the path to the TLS private key.
59 TLSKeyPath string `env:"TLS_KEY_PATH" yaml:"tls_key_path"`
60
61 // TLSCertPath is the path to the TLS certificate.
62 TLSCertPath string `env:"TLS_CERT_PATH" yaml:"tls_cert_path"`
63
64 // PublicURL is the public URL of the HTTP server.
65 PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
66}
67
68// StatsConfig is the configuration for the stats server.
69type StatsConfig struct {
70 // ListenAddr is the address on which the stats server will listen.
71 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
72}
73
74// Config is the configuration for Soft Serve.
75type Config struct {
76 // Name is the name of the server.
77 Name string `env:"NAME" yaml:"name"`
78
79 // SSH is the configuration for the SSH server.
80 SSH SSHConfig `envPrefix:"SSH_" yaml:"ssh"`
81
82 // Git is the configuration for the Git daemon.
83 Git GitConfig `envPrefix:"GIT_" yaml:"git"`
84
85 // HTTP is the configuration for the HTTP server.
86 HTTP HTTPConfig `envPrefix:"HTTP_" yaml:"http"`
87
88 // Stats is the configuration for the stats server.
89 Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"`
90
91 // InitialAdminKeys is a list of public keys that will be added to the list of admins.
92 InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
93
94 // DataPath is the path to the directory where Soft Serve will store its data.
95 DataPath string `env:"DATA_PATH" yaml:"-"`
96
97 // Backend is the Git backend to use.
98 Backend backend.Backend `yaml:"-"`
99}
100
101func parseConfig(path string) (*Config, error) {
102 dataPath := filepath.Dir(path)
103 cfg := &Config{
104 Name: "Soft Serve",
105 DataPath: dataPath,
106 SSH: SSHConfig{
107 ListenAddr: ":23231",
108 PublicURL: "ssh://localhost:23231",
109 KeyPath: filepath.Join("ssh", "soft_serve_host_ed25519"),
110 ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
111 MaxTimeout: 0,
112 IdleTimeout: 0,
113 },
114 Git: GitConfig{
115 ListenAddr: ":9418",
116 MaxTimeout: 0,
117 IdleTimeout: 3,
118 MaxConnections: 32,
119 },
120 HTTP: HTTPConfig{
121 ListenAddr: ":23232",
122 PublicURL: "http://localhost:23232",
123 },
124 Stats: StatsConfig{
125 ListenAddr: "localhost:23233",
126 },
127 }
128
129 f, err := os.Open(path)
130 if err == nil {
131 defer f.Close() // nolint: errcheck
132 if err := yaml.NewDecoder(f).Decode(cfg); err != nil {
133 return cfg, fmt.Errorf("decode config: %w", err)
134 }
135 }
136
137 // Merge initial admin keys from both config file and environment variables.
138 initialAdminKeys := append([]string{}, cfg.InitialAdminKeys...)
139
140 // Override with environment variables
141 if err := env.Parse(cfg, env.Options{
142 Prefix: "SOFT_SERVE_",
143 }); err != nil {
144 return cfg, fmt.Errorf("parse environment variables: %w", err)
145 }
146
147 // Merge initial admin keys from environment variables.
148 if initialAdminKeysEnv := os.Getenv("SOFT_SERVE_INITIAL_ADMIN_KEYS"); initialAdminKeysEnv != "" {
149 cfg.InitialAdminKeys = append(cfg.InitialAdminKeys, initialAdminKeys...)
150 }
151
152 // Validate keys
153 pks := make([]string, 0)
154 for _, key := range parseAuthKeys(cfg.InitialAdminKeys) {
155 ak := backend.MarshalAuthorizedKey(key)
156 pks = append(pks, ak)
157 }
158
159 cfg.InitialAdminKeys = pks
160
161 // Reset datapath to config dir.
162 // This is necessary because the environment variable may be set to
163 // a different directory.
164 cfg.DataPath = dataPath
165
166 return cfg, nil
167}
168
169// ParseConfig parses the configuration from the given file.
170func ParseConfig(path string) (*Config, error) {
171 cfg, err := parseConfig(path)
172 if err != nil {
173 return nil, err
174 }
175
176 if err := cfg.validate(); err != nil {
177 return nil, err
178 }
179
180 return cfg, nil
181}
182
183// WriteConfig writes the configuration to the given file.
184func WriteConfig(path string, cfg *Config) error {
185 if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
186 return err
187 }
188 return os.WriteFile(path, []byte(newConfigFile(cfg)), 0o644) // nolint: errcheck
189}
190
191// DefaultConfig returns a Config with the values populated with the defaults
192// or specified environment variables.
193func DefaultConfig() *Config {
194 dataPath := os.Getenv("SOFT_SERVE_DATA_PATH")
195 if dataPath == "" {
196 dataPath = "data"
197 }
198
199 cp := filepath.Join(dataPath, "config.yaml")
200 cfg, err := parseConfig(cp)
201 if err != nil && !errors.Is(err, os.ErrNotExist) {
202 log.Errorf("failed to parse config: %v", err)
203 }
204
205 // Write config if it doesn't exist
206 if _, err := os.Stat(cp); os.IsNotExist(err) {
207 if err := WriteConfig(cp, cfg); err != nil {
208 log.Fatal("failed to write config", "err", err)
209 }
210 }
211
212 if err := cfg.validate(); err != nil {
213 log.Fatal(err)
214 }
215
216 return cfg
217}
218
219// WithBackend sets the backend for the configuration.
220func (c *Config) WithBackend(backend backend.Backend) *Config {
221 c.Backend = backend
222 return c
223}
224
225func (c *Config) validate() error {
226 // Use absolute paths
227 if !filepath.IsAbs(c.DataPath) {
228 dp, err := filepath.Abs(c.DataPath)
229 if err != nil {
230 return err
231 }
232 c.DataPath = dp
233 }
234
235 c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/")
236 c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/")
237
238 if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) {
239 c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
240 }
241
242 if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {
243 c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)
244 }
245
246 if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
247 c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)
248 }
249
250 if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) {
251 c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)
252 }
253
254 return nil
255}
256
257// parseAuthKeys parses authorized keys from either file paths or string authorized_keys.
258func parseAuthKeys(aks []string) []ssh.PublicKey {
259 pks := make([]ssh.PublicKey, 0)
260 for _, key := range aks {
261 if bts, err := os.ReadFile(key); err == nil {
262 // key is a file
263 key = strings.TrimSpace(string(bts))
264 }
265 if pk, _, err := backend.ParseAuthorizedKey(key); err == nil {
266 pks = append(pks, pk)
267 }
268 }
269 return pks
270}
271
272// AdminKeys returns the server admin keys.
273func (c *Config) AdminKeys() []ssh.PublicKey {
274 return parseAuthKeys(c.InitialAdminKeys)
275}