1package config23import (4 "fmt"5 "os"6 "path/filepath"7 "strconv"8 "strings"9 "time"1011 "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)1617var binPath = "soft"1819// SSHConfig is the configuration for the SSH server.20type SSHConfig struct {21 // Enabled toggles the SSH server on/off22 Enabled bool `env:"ENABLED" yaml:"enabled"`2324 // ListenAddr is the address on which the SSH server will listen.25 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`2627 // PublicURL is the public URL of the SSH server.28 PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`2930 // KeyPath is the path to the SSH server's private key.31 KeyPath string `env:"KEY_PATH" yaml:"key_path"`3233 // ClientKeyPath is the path to the server's client private key.34 ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`3536 // MaxTimeout is the maximum number of seconds a connection can take.37 MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`3839 // 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}4243// GitConfig is the Git daemon configuration for the server.44type GitConfig struct {45 // Enabled toggles the Git daemon on/off46 Enabled bool `env:"ENABLED" yaml:"enabled"`4748 // ListenAddr is the address on which the Git daemon will listen.49 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`5051 // PublicURL is the public URL of the Git daemon server.52 PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`5354 // MaxTimeout is the maximum number of seconds a connection can take.55 MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`5657 // IdleTimeout is the number of seconds a connection can be idle before it is closed.58 IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"`5960 // MaxConnections is the maximum number of concurrent connections.61 MaxConnections int `env:"MAX_CONNECTIONS" yaml:"max_connections"`62}6364// CORSConfig is the CORS configuration for the server.65type CORSConfig struct {66 AllowedHeaders []string `env:"ALLOWED_HEADERS" yaml:"allowed_headers"`6768 AllowedOrigins []string `env:"ALLOWED_ORIGINS" yaml:"allowed_origins"`6970 AllowedMethods []string `env:"ALLOWED_METHODS" yaml:"allowed_methods"`71}7273// HTTPConfig is the HTTP configuration for the server.74type HTTPConfig struct {75 // Enabled toggles the HTTP server on/off76 Enabled bool `env:"ENABLED" yaml:"enabled"`7778 // ListenAddr is the address on which the HTTP server will listen.79 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`8081 // TLSKeyPath is the path to the TLS private key.82 TLSKeyPath string `env:"TLS_KEY_PATH" yaml:"tls_key_path"`8384 // TLSCertPath is the path to the TLS certificate.85 TLSCertPath string `env:"TLS_CERT_PATH" yaml:"tls_cert_path"`8687 // PublicURL is the public URL of the HTTP server.88 PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`8990 // CORS is the cross-origin configuration for the HTTP server.91 CORS CORSConfig `envPrefix:"CORS_" yaml:"cors"`92}9394// StatsConfig is the configuration for the stats server.95type StatsConfig struct {96 // Enabled toggles the Stats server on/off97 Enabled bool `env:"ENABLED" yaml:"enabled"`9899 // ListenAddr is the address on which the stats server will listen.100 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`101}102103// 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"`108109 // 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"`112113 // 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}117118// DBConfig is the database connection configuration.119type DBConfig struct {120 // Driver is the driver for the database.121 Driver string `env:"DRIVER" yaml:"driver"`122123 // DataSource is the database data source name.124 DataSource string `env:"DATA_SOURCE" yaml:"data_source"`125}126127// 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"`131132 // 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}136137// 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}142143// 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"`147148 // SSH is the configuration for the SSH server.149 SSH SSHConfig `envPrefix:"SSH_" yaml:"ssh"`150151 // Git is the configuration for the Git daemon.152 Git GitConfig `envPrefix:"GIT_" yaml:"git"`153154 // HTTP is the configuration for the HTTP server.155 HTTP HTTPConfig `envPrefix:"HTTP_" yaml:"http"`156157 // Stats is the configuration for the stats server.158 Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"`159160 // Log is the logger configuration.161 Log LogConfig `envPrefix:"LOG_" yaml:"log"`162163 // DB is the database configuration.164 DB DBConfig `envPrefix:"DB_" yaml:"db"`165166 // LFS is the configuration for Git LFS.167 LFS LFSConfig `envPrefix:"LFS_" yaml:"lfs"`168169 // Jobs is the configuration for cron jobs170 Jobs JobsConfig `envPrefix:"JOBS_" yaml:"jobs"`171172 // 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"`174175 // DataPath is the path to the directory where Soft Serve will store its data.176 DataPath string `env:"DATA_PATH" yaml:"-"`177}178179// 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 envs186 }187188 // TODO: do this dynamically189 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 }...)225226 return envs227}228229// 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 debug233}234235// 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() && verbose240}241242// 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 err248 }249250 defer f.Close() // nolint: errcheck251 if err := yaml.NewDecoder(f).Decode(cfg); err != nil {252 return fmt.Errorf("decode config: %w", err)253 }254255 return cfg.Validate()256}257258// 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}263264// 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...)268269 // Override with environment variables270 if err := env.ParseWithOptions(cfg, env.Options{271 Prefix: "SOFT_SERVE_",272 }); err != nil {273 return fmt.Errorf("parse environment variables: %w", err)274 }275276 // 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 }280281 return cfg.Validate()282}283284// 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}289290// 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 err295 }296297 return c.ParseEnv()298}299300// 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 err304 }305 return os.WriteFile(path, []byte(newConfigFile(cfg)), 0o644) // nolint: errcheck, gosec306}307308// WriteConfig writes the configuration to the default file.309func (c *Config) WriteConfig() error {310 return writeConfig(c, c.ConfigPath())311}312313// DefaultDataPath returns the path to the data directory.314// It uses the SOFT_SERVE_DATA_PATH environment variable if set, otherwise it315// uses "data".316func DefaultDataPath() string {317 dp := os.Getenv("SOFT_SERVE_DATA_PATH")318 if dp == "" {319 dp = "data"320 }321322 return dp323}324325// ConfigPath returns the path to the config file.326func (c *Config) ConfigPath() string { // nolint:revive327 // If we have a custom config location set, then use that.328 if path := os.Getenv("SOFT_SERVE_CONFIG_LOCATION"); exist(path) {329 return path330 }331332 // Otherwise, look in the data path.333 return filepath.Join(c.DataPath, "config.yaml")334}335336func exist(path string) bool {337 _, err := os.Stat(path)338 return err == nil339}340341// Exist returns true if the config file exists.342func (c *Config) Exist() bool {343 return exist(c.ConfigPath())344}345346// DefaultConfig returns the default Config. All the path values are relative347// 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 minutes361 },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}402403// Validate validates the configuration.404// It updates the configuration with absolute paths.405func (c *Config) Validate() error {406 // Use absolute paths407 if !filepath.IsAbs(c.DataPath) {408 dp, err := filepath.Abs(c.DataPath)409 if err != nil {410 return err411 }412 c.DataPath = dp413 }414415 c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/")416 c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/")417418 if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) {419 c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)420 }421422 if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {423 c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)424 }425426 if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {427 c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)428 }429430 if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) {431 c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)432 }433434 if strings.HasPrefix(c.DB.Driver, "sqlite") && !filepath.IsAbs(c.DB.DataSource) {435 c.DB.DataSource = filepath.Join(c.DataPath, c.DB.DataSource)436 }437438 // Validate keys439 pks := make([]string, 0)440 for _, key := range parseAuthKeys(c.InitialAdminKeys) {441 ak := sshutils.MarshalAuthorizedKey(key)442 pks = append(pks, ak)443 }444445 c.InitialAdminKeys = pks446447 c.HTTP.CORS.AllowedOrigins = append([]string{c.HTTP.PublicURL}, c.HTTP.CORS.AllowedOrigins...)448449 return nil450}451452// 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 file459 key = strings.TrimSpace(string(bts))460 }461462 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 pks470}471472// AdminKeys returns the server admin keys.473func (c *Config) AdminKeys() []ssh.PublicKey {474 return parseAuthKeys(c.InitialAdminKeys)475}476477func init() {478 if ex, err := os.Executable(); err == nil {479 binPath = filepath.ToSlash(ex)480 }481}