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