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