config.go

  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/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// LogConfig is the logger configuration.
 76type LogConfig struct {
 77	// Format is the format of the logs.
 78	// Valid values are "json", "logfmt", and "text".
 79	Format string `env:"FORMAT" yaml:"format"`
 80
 81	// Time format for the log `ts` field.
 82	// Format must be described in Golang's time format.
 83	TimeFormat string `env:"TIME_FORMAT" yaml:"time_format"`
 84}
 85
 86// Config is the configuration for Soft Serve.
 87type Config struct {
 88	// Name is the name of the server.
 89	Name string `env:"NAME" yaml:"name"`
 90
 91	// SSH is the configuration for the SSH server.
 92	SSH SSHConfig `envPrefix:"SSH_" yaml:"ssh"`
 93
 94	// Git is the configuration for the Git daemon.
 95	Git GitConfig `envPrefix:"GIT_" yaml:"git"`
 96
 97	// HTTP is the configuration for the HTTP server.
 98	HTTP HTTPConfig `envPrefix:"HTTP_" yaml:"http"`
 99
100	// Stats is the configuration for the stats server.
101	Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"`
102
103	// Log is the logger configuration.
104	Log LogConfig `envPrefix:"LOG_" yaml:"log"`
105
106	// Cache is the cache backend to use.
107	Cache string `env:"CACHE" yaml:"cache"`
108
109	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
110	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
111
112	// DataPath is the path to the directory where Soft Serve will store its data.
113	DataPath string `env:"DATA_PATH" yaml:"-"`
114
115	// Backend is the Git backend to use.
116	Backend backend.Backend `yaml:"-"`
117}
118
119// Environ returns the config as a list of environment variables.
120func (c *Config) Environ() []string {
121	envs := []string{}
122	if c == nil {
123		return envs
124	}
125
126	// TODO: do this dynamically
127	envs = append(envs, []string{
128		fmt.Sprintf("SOFT_SERVE_NAME=%s", c.Name),
129		fmt.Sprintf("SOFT_SERVE_DATA_PATH=%s", c.DataPath),
130		fmt.Sprintf("SOFT_SERVE_INITIAL_ADMIN_KEYS=%s", strings.Join(c.InitialAdminKeys, "\n")),
131		fmt.Sprintf("SOFT_SERVE_SSH_LISTEN_ADDR=%s", c.SSH.ListenAddr),
132		fmt.Sprintf("SOFT_SERVE_SSH_PUBLIC_URL=%s", c.SSH.PublicURL),
133		fmt.Sprintf("SOFT_SERVE_SSH_KEY_PATH=%s", c.SSH.KeyPath),
134		fmt.Sprintf("SOFT_SERVE_SSH_CLIENT_KEY_PATH=%s", c.SSH.ClientKeyPath),
135		fmt.Sprintf("SOFT_SERVE_SSH_MAX_TIMEOUT=%d", c.SSH.MaxTimeout),
136		fmt.Sprintf("SOFT_SERVE_SSH_IDLE_TIMEOUT=%d", c.SSH.IdleTimeout),
137		fmt.Sprintf("SOFT_SERVE_GIT_LISTEN_ADDR=%s", c.Git.ListenAddr),
138		fmt.Sprintf("SOFT_SERVE_GIT_MAX_TIMEOUT=%d", c.Git.MaxTimeout),
139		fmt.Sprintf("SOFT_SERVE_GIT_IDLE_TIMEOUT=%d", c.Git.IdleTimeout),
140		fmt.Sprintf("SOFT_SERVE_GIT_MAX_CONNECTIONS=%d", c.Git.MaxConnections),
141		fmt.Sprintf("SOFT_SERVE_HTTP_LISTEN_ADDR=%s", c.HTTP.ListenAddr),
142		fmt.Sprintf("SOFT_SERVE_HTTP_TLS_KEY_PATH=%s", c.HTTP.TLSKeyPath),
143		fmt.Sprintf("SOFT_SERVE_HTTP_TLS_CERT_PATH=%s", c.HTTP.TLSCertPath),
144		fmt.Sprintf("SOFT_SERVE_HTTP_PUBLIC_URL=%s", c.HTTP.PublicURL),
145		fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr),
146		fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format),
147		fmt.Sprintf("SOFT_SERVE_LOG_TIME_FORMAT=%s", c.Log.TimeFormat),
148	}...)
149
150	return envs
151}
152
153func parseFile(v interface{}, path string) error {
154	f, err := os.Open(path)
155	if err != nil {
156		return fmt.Errorf("open config file: %w", err)
157	}
158
159	defer f.Close() // nolint: errcheck
160	if err := yaml.NewDecoder(f).Decode(v); err != nil {
161		return fmt.Errorf("decode config: %w", err)
162	}
163
164	return nil
165}
166
167func parseEnv(v interface{}) error {
168	// Override with environment variables
169	if err := env.ParseWithOptions(v, env.Options{
170		Prefix: "SOFT_SERVE_",
171	}); err != nil {
172		return fmt.Errorf("parse environment variables: %w", err)
173	}
174
175	return nil
176}
177
178// ParseConfig parses the configuration from environment variables the given
179// file.
180func ParseConfig(v interface{}, path string) error {
181	return errors.Join(parseFile(v, path), parseEnv(v))
182}
183
184// NewConfig retruns a new Config with values populated from environment
185// variables and config file.
186//
187// If the config file does not exist, it will be created with the default
188// values.
189//
190// Environment variables will override values in the config file except for the
191// initial_admin_keys.
192//
193// If path is empty, the default config file path will be used.
194func NewConfig(path string) (*Config, error) {
195	cfg := DefaultConfig()
196	if path != "" {
197		cfg.DataPath = filepath.Dir(path)
198	}
199
200	// Parse file
201	if cfg.Exist() {
202		if err := parseFile(cfg, cfg.FilePath()); err != nil {
203			return cfg, err
204		}
205	}
206
207	// Merge initial admin keys from both config file and environment variables.
208	initialAdminKeys := append([]string{}, cfg.InitialAdminKeys...)
209
210	if err := parseEnv(cfg); err != nil {
211		return cfg, err
212	}
213
214	// Merge initial admin keys from environment variables.
215	if initialAdminKeysEnv := os.Getenv("SOFT_SERVE_INITIAL_ADMIN_KEYS"); initialAdminKeysEnv != "" {
216		cfg.InitialAdminKeys = append(cfg.InitialAdminKeys, initialAdminKeys...)
217	}
218
219	// Validate keys
220	pks := make([]string, 0)
221	for _, key := range parseAuthKeys(cfg.InitialAdminKeys) {
222		ak := backend.MarshalAuthorizedKey(key)
223		pks = append(pks, ak)
224	}
225
226	cfg.InitialAdminKeys = pks
227
228	// Reset datapath to config dir.
229	// This is necessary because the environment variable may be set to
230	// a different directory.
231	// cfg.DataPath = dataPath
232
233	if err := cfg.validate(); err != nil {
234		return cfg, err
235	}
236
237	return cfg, cfg.WriteConfig()
238}
239
240// DefaultConfig returns a Config with the default values.
241func DefaultConfig() *Config {
242	dataPath := os.Getenv("SOFT_SERVE_DATA_PATH")
243	if dataPath == "" {
244		dataPath = "data"
245	}
246
247	cfg := &Config{
248		Name:     "Soft Serve",
249		DataPath: dataPath,
250		Cache:    "lru",
251		SSH: SSHConfig{
252			ListenAddr:    ":23231",
253			PublicURL:     "ssh://localhost:23231",
254			KeyPath:       filepath.Join("ssh", "soft_serve_host_ed25519"),
255			ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
256			MaxTimeout:    0,
257			IdleTimeout:   0,
258		},
259		Git: GitConfig{
260			ListenAddr:     ":9418",
261			MaxTimeout:     0,
262			IdleTimeout:    3,
263			MaxConnections: 32,
264		},
265		HTTP: HTTPConfig{
266			ListenAddr: ":23232",
267			PublicURL:  "http://localhost:23232",
268		},
269		Stats: StatsConfig{
270			ListenAddr: "localhost:23233",
271		},
272		Log: LogConfig{
273			Format:     "text",
274			TimeFormat: time.DateTime,
275		},
276	}
277
278	return cfg
279}
280
281// FilePath returns the expected config file path.
282func (c *Config) FilePath() string {
283	return filepath.Join(c.DataPath, "config.yaml")
284}
285
286// Exist returns true if the configuration file exists.
287func (c *Config) Exist() bool {
288	_, err := os.Stat(c.FilePath())
289	return err == nil
290}
291
292// WriteConfig writes the configuration in the default path.
293func (c *Config) WriteConfig() error {
294	fp := c.FilePath()
295	if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
296		return err
297	}
298	return os.WriteFile(fp, []byte(newConfigFile(c)), 0o644) // nolint: errcheck
299}
300
301// WithBackend sets the backend for the configuration.
302// TODO: remove in favor of backend.FromContext.
303func (c *Config) WithBackend(backend backend.Backend) *Config {
304	c.Backend = backend
305	return c
306}
307
308func (c *Config) validate() error {
309	// Use absolute paths
310	if !filepath.IsAbs(c.DataPath) {
311		dp, err := filepath.Abs(c.DataPath)
312		if err != nil {
313			return err
314		}
315		c.DataPath = dp
316	}
317
318	c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/")
319	c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/")
320
321	if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) {
322		c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
323	}
324
325	if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {
326		c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)
327	}
328
329	if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
330		c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)
331	}
332
333	if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) {
334		c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)
335	}
336
337	return nil
338}
339
340// parseAuthKeys parses authorized keys from either file paths or string authorized_keys.
341func parseAuthKeys(aks []string) []ssh.PublicKey {
342	pks := make([]ssh.PublicKey, 0)
343	for _, key := range aks {
344		if bts, err := os.ReadFile(key); err == nil {
345			// key is a file
346			key = strings.TrimSpace(string(bts))
347		}
348		if pk, _, err := backend.ParseAuthorizedKey(key); err == nil {
349			pks = append(pks, pk)
350		}
351	}
352	return pks
353}
354
355// AdminKeys returns the server admin keys.
356func (c *Config) AdminKeys() []ssh.PublicKey {
357	return parseAuthKeys(c.InitialAdminKeys)
358}
359
360var configCtxKey = struct{ string }{"config"}
361
362// WithContext returns a new context with the configuration attached.
363func WithContext(ctx context.Context, cfg *Config) context.Context {
364	return context.WithValue(ctx, configCtxKey, cfg)
365}
366
367// FromContext returns the configuration from the context.
368func FromContext(ctx context.Context) *Config {
369	if c, ok := ctx.Value(configCtxKey).(*Config); ok {
370		return c
371	}
372
373	return DefaultConfig()
374}