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// HTTPConfig is the HTTP configuration for the server.65type HTTPConfig struct {66 // Enabled toggles the HTTP server on/off67 Enabled bool `env:"ENABLED" yaml:"enabled"`6869 // ListenAddr is the address on which the HTTP server will listen.70 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`7172 // TLSKeyPath is the path to the TLS private key.73 TLSKeyPath string `env:"TLS_KEY_PATH" yaml:"tls_key_path"`7475 // TLSCertPath is the path to the TLS certificate.76 TLSCertPath string `env:"TLS_CERT_PATH" yaml:"tls_cert_path"`7778 // PublicURL is the public URL of the HTTP server.79 PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`80}8182// StatsConfig is the configuration for the stats server.83type StatsConfig struct {84 // Enabled toggles the Stats server on/off85 Enabled bool `env:"ENABLED" yaml:"enabled"`8687 // ListenAddr is the address on which the stats server will listen.88 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`89}9091// LogConfig is the logger configuration.92type LogConfig struct {93 // Format is the format of the logs.94 // Valid values are "json", "logfmt", and "text".95 Format string `env:"FORMAT" yaml:"format"`9697 // Time format for the log `ts` field.98 // Format must be described in Golang's time format.99 TimeFormat string `env:"TIME_FORMAT" yaml:"time_format"`100101 // Path to a file to write logs to.102 // If not set, logs will be written to stderr.103 Path string `env:"PATH" yaml:"path"`104}105106// DBConfig is the database connection configuration.107type DBConfig struct {108 // Driver is the driver for the database.109 Driver string `env:"DRIVER" yaml:"driver"`110111 // DataSource is the database data source name.112 DataSource string `env:"DATA_SOURCE" yaml:"data_source"`113}114115// LFSConfig is the configuration for Git LFS.116type LFSConfig struct {117 // Enabled is whether or not Git LFS is enabled.118 Enabled bool `env:"ENABLED" yaml:"enabled"`119120 // SSHEnabled is whether or not Git LFS over SSH is enabled.121 // This is only used if LFS is enabled.122 SSHEnabled bool `env:"SSH_ENABLED" yaml:"ssh_enabled"`123}124125// JobsConfig is the configuration for cron jobs.126type JobsConfig struct {127 MirrorPull string `env:"MIRROR_PULL" yaml:"mirror_pull"`128}129130// Config is the configuration for Soft Serve.131type Config struct {132 // Name is the name of the server.133 Name string `env:"NAME" yaml:"name"`134135 // SSH is the configuration for the SSH server.136 SSH SSHConfig `envPrefix:"SSH_" yaml:"ssh"`137138 // Git is the configuration for the Git daemon.139 Git GitConfig `envPrefix:"GIT_" yaml:"git"`140141 // HTTP is the configuration for the HTTP server.142 HTTP HTTPConfig `envPrefix:"HTTP_" yaml:"http"`143144 // Stats is the configuration for the stats server.145 Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"`146147 // Log is the logger configuration.148 Log LogConfig `envPrefix:"LOG_" yaml:"log"`149150 // DB is the database configuration.151 DB DBConfig `envPrefix:"DB_" yaml:"db"`152153 // LFS is the configuration for Git LFS.154 LFS LFSConfig `envPrefix:"LFS_" yaml:"lfs"`155156 // Jobs is the configuration for cron jobs157 Jobs JobsConfig `envPrefix:"JOBS_" yaml:"jobs"`158159 // InitialAdminKeys is a list of public keys that will be added to the list of admins.160 InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`161162 // DataPath is the path to the directory where Soft Serve will store its data.163 DataPath string `env:"DATA_PATH" yaml:"-"`164}165166// Environ returns the config as a list of environment variables.167func (c *Config) Environ() []string {168 envs := []string{169 fmt.Sprintf("SOFT_SERVE_BIN_PATH=%s", binPath),170 }171 if c == nil {172 return envs173 }174175 // TODO: do this dynamically176 envs = append(envs, []string{177 fmt.Sprintf("SOFT_SERVE_CONFIG_LOCATION=%s", c.ConfigPath()),178 fmt.Sprintf("SOFT_SERVE_DATA_PATH=%s", c.DataPath),179 fmt.Sprintf("SOFT_SERVE_NAME=%s", c.Name),180 fmt.Sprintf("SOFT_SERVE_INITIAL_ADMIN_KEYS=%s", strings.Join(c.InitialAdminKeys, "\n")),181 fmt.Sprintf("SOFT_SERVE_SSH_ENABLED=%t", c.SSH.Enabled),182 fmt.Sprintf("SOFT_SERVE_SSH_LISTEN_ADDR=%s", c.SSH.ListenAddr),183 fmt.Sprintf("SOFT_SERVE_SSH_PUBLIC_URL=%s", c.SSH.PublicURL),184 fmt.Sprintf("SOFT_SERVE_SSH_KEY_PATH=%s", c.SSH.KeyPath),185 fmt.Sprintf("SOFT_SERVE_SSH_CLIENT_KEY_PATH=%s", c.SSH.ClientKeyPath),186 fmt.Sprintf("SOFT_SERVE_SSH_MAX_TIMEOUT=%d", c.SSH.MaxTimeout),187 fmt.Sprintf("SOFT_SERVE_SSH_IDLE_TIMEOUT=%d", c.SSH.IdleTimeout),188 fmt.Sprintf("SOFT_SERVE_GIT_ENABLED=%t", c.Git.Enabled),189 fmt.Sprintf("SOFT_SERVE_GIT_LISTEN_ADDR=%s", c.Git.ListenAddr),190 fmt.Sprintf("SOFT_SERVE_GIT_PUBLIC_URL=%s", c.Git.PublicURL),191 fmt.Sprintf("SOFT_SERVE_GIT_MAX_TIMEOUT=%d", c.Git.MaxTimeout),192 fmt.Sprintf("SOFT_SERVE_GIT_IDLE_TIMEOUT=%d", c.Git.IdleTimeout),193 fmt.Sprintf("SOFT_SERVE_GIT_MAX_CONNECTIONS=%d", c.Git.MaxConnections),194 fmt.Sprintf("SOFT_SERVE_HTTP_ENABLED=%t", c.HTTP.Enabled),195 fmt.Sprintf("SOFT_SERVE_HTTP_LISTEN_ADDR=%s", c.HTTP.ListenAddr),196 fmt.Sprintf("SOFT_SERVE_HTTP_TLS_KEY_PATH=%s", c.HTTP.TLSKeyPath),197 fmt.Sprintf("SOFT_SERVE_HTTP_TLS_CERT_PATH=%s", c.HTTP.TLSCertPath),198 fmt.Sprintf("SOFT_SERVE_HTTP_PUBLIC_URL=%s", c.HTTP.PublicURL),199 fmt.Sprintf("SOFT_SERVE_STATS_ENABLED=%t", c.Stats.Enabled),200 fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr),201 fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format),202 fmt.Sprintf("SOFT_SERVE_LOG_TIME_FORMAT=%s", c.Log.TimeFormat),203 fmt.Sprintf("SOFT_SERVE_DB_DRIVER=%s", c.DB.Driver),204 fmt.Sprintf("SOFT_SERVE_DB_DATA_SOURCE=%s", c.DB.DataSource),205 fmt.Sprintf("SOFT_SERVE_LFS_ENABLED=%t", c.LFS.Enabled),206 fmt.Sprintf("SOFT_SERVE_LFS_SSH_ENABLED=%t", c.LFS.SSHEnabled),207 fmt.Sprintf("SOFT_SERVE_JOBS_MIRROR_PULL=%s", c.Jobs.MirrorPull),208 }...)209210 return envs211}212213// IsDebug returns true if the server is running in debug mode.214func IsDebug() bool {215 debug, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_DEBUG"))216 return debug217}218219// IsVerbose returns true if the server is running in verbose mode.220// Verbose mode is only enabled if debug mode is enabled.221func IsVerbose() bool {222 verbose, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_VERBOSE"))223 return IsDebug() && verbose224}225226// parseFile parses the given file as a configuration file.227// The file must be in YAML format.228func parseFile(cfg *Config, path string) error {229 f, err := os.Open(path)230 if err != nil {231 return err232 }233234 defer f.Close() // nolint: errcheck235 if err := yaml.NewDecoder(f).Decode(cfg); err != nil {236 return fmt.Errorf("decode config: %w", err)237 }238239 return cfg.Validate()240}241242// ParseFile parses the config from the default file path.243// This also calls Validate() on the config.244func (c *Config) ParseFile() error {245 return parseFile(c, c.ConfigPath())246}247248// parseEnv parses the environment variables as a configuration file.249func parseEnv(cfg *Config) error {250 // Merge initial admin keys from both config file and environment variables.251 initialAdminKeys := append([]string{}, cfg.InitialAdminKeys...)252253 // Override with environment variables254 if err := env.ParseWithOptions(cfg, env.Options{255 Prefix: "SOFT_SERVE_",256 }); err != nil {257 return fmt.Errorf("parse environment variables: %w", err)258 }259260 // Merge initial admin keys from environment variables.261 if initialAdminKeysEnv := os.Getenv("SOFT_SERVE_INITIAL_ADMIN_KEYS"); initialAdminKeysEnv != "" {262 cfg.InitialAdminKeys = append(cfg.InitialAdminKeys, initialAdminKeys...)263 }264265 return cfg.Validate()266}267268// ParseEnv parses the config from the environment variables.269// This also calls Validate() on the config.270func (c *Config) ParseEnv() error {271 return parseEnv(c)272}273274// Parse parses the config from the default file path and environment variables.275// This also calls Validate() on the config.276func (c *Config) Parse() error {277 if err := c.ParseFile(); err != nil {278 return err279 }280281 return c.ParseEnv()282}283284// writeConfig writes the configuration to the given file.285func writeConfig(cfg *Config, path string) error {286 if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {287 return err288 }289 return os.WriteFile(path, []byte(newConfigFile(cfg)), 0o644) // nolint: errcheck, gosec290}291292// WriteConfig writes the configuration to the default file.293func (c *Config) WriteConfig() error {294 return writeConfig(c, c.ConfigPath())295}296297// DefaultDataPath returns the path to the data directory.298// It uses the SOFT_SERVE_DATA_PATH environment variable if set, otherwise it299// uses "data".300func DefaultDataPath() string {301 dp := os.Getenv("SOFT_SERVE_DATA_PATH")302 if dp == "" {303 dp = "data"304 }305306 return dp307}308309// ConfigPath returns the path to the config file.310func (c *Config) ConfigPath() string { // nolint:revive311 // If we have a custom config location set, then use that.312 if path := os.Getenv("SOFT_SERVE_CONFIG_LOCATION"); exist(path) {313 return path314 }315316 // Otherwise, look in the data path.317 return filepath.Join(c.DataPath, "config.yaml")318}319320func exist(path string) bool {321 _, err := os.Stat(path)322 return err == nil323}324325// Exist returns true if the config file exists.326func (c *Config) Exist() bool {327 return exist(c.ConfigPath())328}329330// DefaultConfig returns the default Config. All the path values are relative331// to the data directory.332// Use Validate() to validate the config and ensure absolute paths.333func DefaultConfig() *Config {334 return &Config{335 Name: "Soft Serve",336 DataPath: DefaultDataPath(),337 SSH: SSHConfig{338 Enabled: true,339 ListenAddr: ":23231",340 PublicURL: "ssh://localhost:23231",341 KeyPath: filepath.Join("ssh", "soft_serve_host_ed25519"),342 ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),343 MaxTimeout: 0,344 IdleTimeout: 10 * 60, // 10 minutes345 },346 Git: GitConfig{347 Enabled: true,348 ListenAddr: ":9418",349 PublicURL: "git://localhost",350 MaxTimeout: 0,351 IdleTimeout: 3,352 MaxConnections: 32,353 },354 HTTP: HTTPConfig{355 Enabled: true,356 ListenAddr: ":23232",357 PublicURL: "http://localhost:23232",358 },359 Stats: StatsConfig{360 Enabled: true,361 ListenAddr: "localhost:23233",362 },363 Log: LogConfig{364 Format: "text",365 TimeFormat: time.DateTime,366 },367 DB: DBConfig{368 Driver: "sqlite",369 DataSource: "soft-serve.db" +370 "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)",371 },372 LFS: LFSConfig{373 Enabled: true,374 SSHEnabled: false,375 },376 Jobs: JobsConfig{377 MirrorPull: "@every 10m",378 },379 }380}381382// Validate validates the configuration.383// It updates the configuration with absolute paths.384func (c *Config) Validate() error {385 // Use absolute paths386 if !filepath.IsAbs(c.DataPath) {387 dp, err := filepath.Abs(c.DataPath)388 if err != nil {389 return err390 }391 c.DataPath = dp392 }393394 c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/")395 c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/")396397 if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) {398 c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)399 }400401 if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {402 c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)403 }404405 if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {406 c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)407 }408409 if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) {410 c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)411 }412413 if strings.HasPrefix(c.DB.Driver, "sqlite") && !filepath.IsAbs(c.DB.DataSource) {414 c.DB.DataSource = filepath.Join(c.DataPath, c.DB.DataSource)415 }416417 // Validate keys418 pks := make([]string, 0)419 for _, key := range parseAuthKeys(c.InitialAdminKeys) {420 ak := sshutils.MarshalAuthorizedKey(key)421 pks = append(pks, ak)422 }423424 c.InitialAdminKeys = pks425426 return nil427}428429// parseAuthKeys parses authorized keys from either file paths or string authorized_keys.430func parseAuthKeys(aks []string) []ssh.PublicKey {431 exist := make(map[string]struct{}, 0)432 pks := make([]ssh.PublicKey, 0)433 for _, key := range aks {434 if bts, err := os.ReadFile(key); err == nil {435 // key is a file436 key = strings.TrimSpace(string(bts))437 }438439 if pk, _, err := sshutils.ParseAuthorizedKey(key); err == nil {440 if _, ok := exist[key]; !ok {441 pks = append(pks, pk)442 exist[key] = struct{}{}443 }444 }445 }446 return pks447}448449// AdminKeys returns the server admin keys.450func (c *Config) AdminKeys() []ssh.PublicKey {451 return parseAuthKeys(c.InitialAdminKeys)452}453454func init() {455 if ex, err := os.Executable(); err == nil {456 binPath = filepath.ToSlash(ex)457 }458}