soft-serve

Fork https://github.com/charmbracelet/soft-serve

git clone git://git.lin.moe/go/soft-serve.git

  1package config
  2
  3import (
  4	"fmt"
  5	"os"
  6	"path/filepath"
  7	"strconv"
  8	"strings"
  9	"time"
 10
 11	"github.com/caarlos0/env/v11"
 12	"github.com/charmbracelet/soft-serve/pkg/sshutils"
 13	"golang.org/x/crypto/ssh"
 14	"gopkg.in/yaml.v3"
 15)
 16
 17var binPath = "soft"
 18
 19// SSHConfig is the configuration for the SSH server.
 20type SSHConfig struct {
 21	// Enabled toggles the SSH server on/off
 22	Enabled bool `env:"ENABLED" yaml:"enabled"`
 23
 24	// ListenAddr is the address on which the SSH server will listen.
 25	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 26
 27	// PublicURL is the public URL of the SSH server.
 28	PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
 29
 30	// KeyPath is the path to the SSH server's private key.
 31	KeyPath string `env:"KEY_PATH" yaml:"key_path"`
 32
 33	// ClientKeyPath is the path to the server's client private key.
 34	ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`
 35
 36	// MaxTimeout is the maximum number of seconds a connection can take.
 37	MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
 38
 39	// IdleTimeout is the number of seconds a connection can be idle before it is closed.
 40	IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"`
 41}
 42
 43// GitConfig is the Git daemon configuration for the server.
 44type GitConfig struct {
 45	// Enabled toggles the Git daemon on/off
 46	Enabled bool `env:"ENABLED" yaml:"enabled"`
 47
 48	// ListenAddr is the address on which the Git daemon will listen.
 49	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 50
 51	// PublicURL is the public URL of the Git daemon server.
 52	PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
 53
 54	// MaxTimeout is the maximum number of seconds a connection can take.
 55	MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
 56
 57	// IdleTimeout is the number of seconds a connection can be idle before it is closed.
 58	IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"`
 59
 60	// MaxConnections is the maximum number of concurrent connections.
 61	MaxConnections int `env:"MAX_CONNECTIONS" yaml:"max_connections"`
 62}
 63
 64// CORSConfig is the CORS configuration for the server.
 65type CORSConfig struct {
 66	AllowedHeaders []string `env:"ALLOWED_HEADERS" yaml:"allowed_headers"`
 67
 68	AllowedOrigins []string `env:"ALLOWED_ORIGINS" yaml:"allowed_origins"`
 69
 70	AllowedMethods []string `env:"ALLOWED_METHODS" yaml:"allowed_methods"`
 71}
 72
 73// HTTPConfig is the HTTP configuration for the server.
 74type HTTPConfig struct {
 75	// Enabled toggles the HTTP server on/off
 76	Enabled bool `env:"ENABLED" yaml:"enabled"`
 77
 78	// ListenAddr is the address on which the HTTP server will listen.
 79	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 80
 81	// TLSKeyPath is the path to the TLS private key.
 82	TLSKeyPath string `env:"TLS_KEY_PATH" yaml:"tls_key_path"`
 83
 84	// TLSCertPath is the path to the TLS certificate.
 85	TLSCertPath string `env:"TLS_CERT_PATH" yaml:"tls_cert_path"`
 86
 87	// PublicURL is the public URL of the HTTP server.
 88	PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
 89
 90	// CORS is the cross-origin configuration for the HTTP server.
 91	CORS CORSConfig `envPrefix:"CORS_" yaml:"cors"`
 92}
 93
 94// StatsConfig is the configuration for the stats server.
 95type StatsConfig struct {
 96	// Enabled toggles the Stats server on/off
 97	Enabled bool `env:"ENABLED" yaml:"enabled"`
 98
 99	// ListenAddr is the address on which the stats server will listen.
100	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
101}
102
103// LogConfig is the logger configuration.
104type LogConfig struct {
105	// Format is the format of the logs.
106	// Valid values are "json", "logfmt", and "text".
107	Format string `env:"FORMAT" yaml:"format"`
108
109	// Time format for the log `ts` field.
110	// Format must be described in Golang's time format.
111	TimeFormat string `env:"TIME_FORMAT" yaml:"time_format"`
112
113	// Path to a file to write logs to.
114	// If not set, logs will be written to stderr.
115	Path string `env:"PATH" yaml:"path"`
116}
117
118// DBConfig is the database connection configuration.
119type DBConfig struct {
120	// Driver is the driver for the database.
121	Driver string `env:"DRIVER" yaml:"driver"`
122
123	// DataSource is the database data source name.
124	DataSource string `env:"DATA_SOURCE" yaml:"data_source"`
125}
126
127// LFSConfig is the configuration for Git LFS.
128type LFSConfig struct {
129	// Enabled is whether or not Git LFS is enabled.
130	Enabled bool `env:"ENABLED" yaml:"enabled"`
131
132	// SSHEnabled is whether or not Git LFS over SSH is enabled.
133	// This is only used if LFS is enabled.
134	SSHEnabled bool `env:"SSH_ENABLED" yaml:"ssh_enabled"`
135}
136
137// JobsConfig is the configuration for cron jobs.
138type JobsConfig struct {
139	MirrorPull string `env:"MIRROR_PULL" yaml:"mirror_pull"`
140	GitGC      string `env:"GIT_GC" yaml:"git_gc"`
141}
142
143// Config is the configuration for Soft Serve.
144type Config struct {
145	// Name is the name of the server.
146	Name string `env:"NAME" yaml:"name"`
147
148	// SSH is the configuration for the SSH server.
149	SSH SSHConfig `envPrefix:"SSH_" yaml:"ssh"`
150
151	// Git is the configuration for the Git daemon.
152	Git GitConfig `envPrefix:"GIT_" yaml:"git"`
153
154	// HTTP is the configuration for the HTTP server.
155	HTTP HTTPConfig `envPrefix:"HTTP_" yaml:"http"`
156
157	// Stats is the configuration for the stats server.
158	Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"`
159
160	// Log is the logger configuration.
161	Log LogConfig `envPrefix:"LOG_" yaml:"log"`
162
163	// DB is the database configuration.
164	DB DBConfig `envPrefix:"DB_" yaml:"db"`
165
166	// LFS is the configuration for Git LFS.
167	LFS LFSConfig `envPrefix:"LFS_" yaml:"lfs"`
168
169	// Jobs is the configuration for cron jobs
170	Jobs JobsConfig `envPrefix:"JOBS_" yaml:"jobs"`
171
172	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
173	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
174
175	// DataPath is the path to the directory where Soft Serve will store its data.
176	DataPath string `env:"DATA_PATH" yaml:"-"`
177}
178
179// Environ returns the config as a list of environment variables.
180func (c *Config) Environ() []string {
181	envs := []string{
182		fmt.Sprintf("SOFT_SERVE_BIN_PATH=%s", binPath),
183	}
184	if c == nil {
185		return envs
186	}
187
188	// TODO: do this dynamically
189	envs = append(envs, []string{
190		fmt.Sprintf("SOFT_SERVE_CONFIG_LOCATION=%s", c.ConfigPath()),
191		fmt.Sprintf("SOFT_SERVE_DATA_PATH=%s", c.DataPath),
192		fmt.Sprintf("SOFT_SERVE_NAME=%s", c.Name),
193		fmt.Sprintf("SOFT_SERVE_INITIAL_ADMIN_KEYS=%s", strings.Join(c.InitialAdminKeys, "\n")),
194		fmt.Sprintf("SOFT_SERVE_SSH_ENABLED=%t", c.SSH.Enabled),
195		fmt.Sprintf("SOFT_SERVE_SSH_LISTEN_ADDR=%s", c.SSH.ListenAddr),
196		fmt.Sprintf("SOFT_SERVE_SSH_PUBLIC_URL=%s", c.SSH.PublicURL),
197		fmt.Sprintf("SOFT_SERVE_SSH_KEY_PATH=%s", c.SSH.KeyPath),
198		fmt.Sprintf("SOFT_SERVE_SSH_CLIENT_KEY_PATH=%s", c.SSH.ClientKeyPath),
199		fmt.Sprintf("SOFT_SERVE_SSH_MAX_TIMEOUT=%d", c.SSH.MaxTimeout),
200		fmt.Sprintf("SOFT_SERVE_SSH_IDLE_TIMEOUT=%d", c.SSH.IdleTimeout),
201		fmt.Sprintf("SOFT_SERVE_GIT_ENABLED=%t", c.Git.Enabled),
202		fmt.Sprintf("SOFT_SERVE_GIT_LISTEN_ADDR=%s", c.Git.ListenAddr),
203		fmt.Sprintf("SOFT_SERVE_GIT_PUBLIC_URL=%s", c.Git.PublicURL),
204		fmt.Sprintf("SOFT_SERVE_GIT_MAX_TIMEOUT=%d", c.Git.MaxTimeout),
205		fmt.Sprintf("SOFT_SERVE_GIT_IDLE_TIMEOUT=%d", c.Git.IdleTimeout),
206		fmt.Sprintf("SOFT_SERVE_GIT_MAX_CONNECTIONS=%d", c.Git.MaxConnections),
207		fmt.Sprintf("SOFT_SERVE_HTTP_ENABLED=%t", c.HTTP.Enabled),
208		fmt.Sprintf("SOFT_SERVE_HTTP_LISTEN_ADDR=%s", c.HTTP.ListenAddr),
209		fmt.Sprintf("SOFT_SERVE_HTTP_TLS_KEY_PATH=%s", c.HTTP.TLSKeyPath),
210		fmt.Sprintf("SOFT_SERVE_HTTP_TLS_CERT_PATH=%s", c.HTTP.TLSCertPath),
211		fmt.Sprintf("SOFT_SERVE_HTTP_PUBLIC_URL=%s", c.HTTP.PublicURL),
212		fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS=%s", strings.Join(c.HTTP.CORS.AllowedHeaders, ",")),
213		fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS=%s", strings.Join(c.HTTP.CORS.AllowedOrigins, ",")),
214		fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS=%s", strings.Join(c.HTTP.CORS.AllowedMethods, ",")),
215		fmt.Sprintf("SOFT_SERVE_STATS_ENABLED=%t", c.Stats.Enabled),
216		fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr),
217		fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format),
218		fmt.Sprintf("SOFT_SERVE_LOG_TIME_FORMAT=%s", c.Log.TimeFormat),
219		fmt.Sprintf("SOFT_SERVE_DB_DRIVER=%s", c.DB.Driver),
220		fmt.Sprintf("SOFT_SERVE_DB_DATA_SOURCE=%s", c.DB.DataSource),
221		fmt.Sprintf("SOFT_SERVE_LFS_ENABLED=%t", c.LFS.Enabled),
222		fmt.Sprintf("SOFT_SERVE_LFS_SSH_ENABLED=%t", c.LFS.SSHEnabled),
223		fmt.Sprintf("SOFT_SERVE_JOBS_MIRROR_PULL=%s", c.Jobs.MirrorPull),
224	}...)
225
226	return envs
227}
228
229// IsDebug returns true if the server is running in debug mode.
230func IsDebug() bool {
231	debug, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_DEBUG"))
232	return debug
233}
234
235// IsVerbose returns true if the server is running in verbose mode.
236// Verbose mode is only enabled if debug mode is enabled.
237func IsVerbose() bool {
238	verbose, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_VERBOSE"))
239	return IsDebug() && verbose
240}
241
242// parseFile parses the given file as a configuration file.
243// The file must be in YAML format.
244func parseFile(cfg *Config, path string) error {
245	f, err := os.Open(path)
246	if err != nil {
247		return err
248	}
249
250	defer f.Close() // nolint: errcheck
251	if err := yaml.NewDecoder(f).Decode(cfg); err != nil {
252		return fmt.Errorf("decode config: %w", err)
253	}
254
255	return cfg.Validate()
256}
257
258// ParseFile parses the config from the default file path.
259// This also calls Validate() on the config.
260func (c *Config) ParseFile() error {
261	return parseFile(c, c.ConfigPath())
262}
263
264// parseEnv parses the environment variables as a configuration file.
265func parseEnv(cfg *Config) error {
266	// Merge initial admin keys from both config file and environment variables.
267	initialAdminKeys := append([]string{}, cfg.InitialAdminKeys...)
268
269	// Override with environment variables
270	if err := env.ParseWithOptions(cfg, env.Options{
271		Prefix: "SOFT_SERVE_",
272	}); err != nil {
273		return fmt.Errorf("parse environment variables: %w", err)
274	}
275
276	// Merge initial admin keys from environment variables.
277	if initialAdminKeysEnv := os.Getenv("SOFT_SERVE_INITIAL_ADMIN_KEYS"); initialAdminKeysEnv != "" {
278		cfg.InitialAdminKeys = append(cfg.InitialAdminKeys, initialAdminKeys...)
279	}
280
281	return cfg.Validate()
282}
283
284// ParseEnv parses the config from the environment variables.
285// This also calls Validate() on the config.
286func (c *Config) ParseEnv() error {
287	return parseEnv(c)
288}
289
290// Parse parses the config from the default file path and environment variables.
291// This also calls Validate() on the config.
292func (c *Config) Parse() error {
293	if err := c.ParseFile(); err != nil {
294		return err
295	}
296
297	return c.ParseEnv()
298}
299
300// writeConfig writes the configuration to the given file.
301func writeConfig(cfg *Config, path string) error {
302	if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
303		return err
304	}
305	return os.WriteFile(path, []byte(newConfigFile(cfg)), 0o644) // nolint: errcheck, gosec
306}
307
308// WriteConfig writes the configuration to the default file.
309func (c *Config) WriteConfig() error {
310	return writeConfig(c, c.ConfigPath())
311}
312
313// DefaultDataPath returns the path to the data directory.
314// It uses the SOFT_SERVE_DATA_PATH environment variable if set, otherwise it
315// uses "data".
316func DefaultDataPath() string {
317	dp := os.Getenv("SOFT_SERVE_DATA_PATH")
318	if dp == "" {
319		dp = "data"
320	}
321
322	return dp
323}
324
325// ConfigPath returns the path to the config file.
326func (c *Config) ConfigPath() string { // nolint:revive
327	// If we have a custom config location set, then use that.
328	if path := os.Getenv("SOFT_SERVE_CONFIG_LOCATION"); exist(path) {
329		return path
330	}
331
332	// Otherwise, look in the data path.
333	return filepath.Join(c.DataPath, "config.yaml")
334}
335
336func exist(path string) bool {
337	_, err := os.Stat(path)
338	return err == nil
339}
340
341// Exist returns true if the config file exists.
342func (c *Config) Exist() bool {
343	return exist(c.ConfigPath())
344}
345
346// DefaultConfig returns the default Config. All the path values are relative
347// to the data directory.
348// Use Validate() to validate the config and ensure absolute paths.
349func DefaultConfig() *Config {
350	return &Config{
351		Name:     "Soft Serve",
352		DataPath: DefaultDataPath(),
353		SSH: SSHConfig{
354			Enabled:       true,
355			ListenAddr:    ":23231",
356			PublicURL:     "ssh://localhost:23231",
357			KeyPath:       filepath.Join("ssh", "soft_serve_host_ed25519"),
358			ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
359			MaxTimeout:    0,
360			IdleTimeout:   10 * 60, // 10 minutes
361		},
362		Git: GitConfig{
363			Enabled:        true,
364			ListenAddr:     ":9418",
365			PublicURL:      "git://localhost",
366			MaxTimeout:     0,
367			IdleTimeout:    3,
368			MaxConnections: 32,
369		},
370		HTTP: HTTPConfig{
371			Enabled:    true,
372			ListenAddr: ":23232",
373			PublicURL:  "http://localhost:23232",
374			CORS: CORSConfig{
375				AllowedHeaders: []string{"Accept", "Accept-Language", "Content-Language", "Content-Type", "Origin", "X-Requested-With", "User-Agent", "Authorization", "Access-Control-Request-Method", "Access-Control-Allow-Origin"},
376				AllowedMethods: []string{"GET", "HEAD", "POST", "PUT", "OPTIONS"},
377				AllowedOrigins: []string{"http://localhost:23232"},
378			},
379		},
380		Stats: StatsConfig{
381			Enabled:    true,
382			ListenAddr: "localhost:23233",
383		},
384		Log: LogConfig{
385			Format:     "text",
386			TimeFormat: time.DateTime,
387		},
388		DB: DBConfig{
389			Driver: "sqlite",
390			DataSource: "soft-serve.db" +
391				"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)",
392		},
393		LFS: LFSConfig{
394			Enabled:    true,
395			SSHEnabled: false,
396		},
397		Jobs: JobsConfig{
398			MirrorPull: "@every 10m",
399		},
400	}
401}
402
403// Validate validates the configuration.
404// It updates the configuration with absolute paths.
405func (c *Config) Validate() error {
406	// Use absolute paths
407	if !filepath.IsAbs(c.DataPath) {
408		dp, err := filepath.Abs(c.DataPath)
409		if err != nil {
410			return err
411		}
412		c.DataPath = dp
413	}
414
415	c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/")
416	c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/")
417
418	if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) {
419		c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
420	}
421
422	if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {
423		c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)
424	}
425
426	if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
427		c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)
428	}
429
430	if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) {
431		c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)
432	}
433
434	if strings.HasPrefix(c.DB.Driver, "sqlite") && !filepath.IsAbs(c.DB.DataSource) {
435		c.DB.DataSource = filepath.Join(c.DataPath, c.DB.DataSource)
436	}
437
438	// Validate keys
439	pks := make([]string, 0)
440	for _, key := range parseAuthKeys(c.InitialAdminKeys) {
441		ak := sshutils.MarshalAuthorizedKey(key)
442		pks = append(pks, ak)
443	}
444
445	c.InitialAdminKeys = pks
446
447	c.HTTP.CORS.AllowedOrigins = append([]string{c.HTTP.PublicURL}, c.HTTP.CORS.AllowedOrigins...)
448
449	return nil
450}
451
452// parseAuthKeys parses authorized keys from either file paths or string authorized_keys.
453func parseAuthKeys(aks []string) []ssh.PublicKey {
454	exist := make(map[string]struct{}, 0)
455	pks := make([]ssh.PublicKey, 0)
456	for _, key := range aks {
457		if bts, err := os.ReadFile(key); err == nil {
458			// key is a file
459			key = strings.TrimSpace(string(bts))
460		}
461
462		if pk, _, err := sshutils.ParseAuthorizedKey(key); err == nil {
463			if _, ok := exist[key]; !ok {
464				pks = append(pks, pk)
465				exist[key] = struct{}{}
466			}
467		}
468	}
469	return pks
470}
471
472// AdminKeys returns the server admin keys.
473func (c *Config) AdminKeys() []ssh.PublicKey {
474	return parseAuthKeys(c.InitialAdminKeys)
475}
476
477func init() {
478	if ex, err := os.Executable(); err == nil {
479		binPath = filepath.ToSlash(ex)
480	}
481}