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