1package config
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9
 10	"github.com/caarlos0/env/v7"
 11	"github.com/charmbracelet/log"
 12	"github.com/charmbracelet/soft-serve/server/backend"
 13	"gopkg.in/yaml.v3"
 14)
 15
 16// SSHConfig is the configuration for the SSH server.
 17type SSHConfig struct {
 18	// ListenAddr is the address on which the SSH server will listen.
 19	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 20
 21	// PublicURL is the public URL of the SSH server.
 22	PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
 23
 24	// KeyPath is the path to the SSH server's private key.
 25	KeyPath string `env:"KEY_PATH" yaml:"key_path"`
 26
 27	// ClientKeyPath is the path to the SSH server's client private key.
 28	ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`
 29
 30	// InternalKeyPath is the path to the SSH server's internal private key.
 31	InternalKeyPath string `env:"INTERNAL_KEY_PATH" yaml:"internal_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	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
 94	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
 95
 96	// DataPath is the path to the directory where Soft Serve will store its data.
 97	DataPath string `env:"DATA_PATH" yaml:"-"`
 98
 99	// Backend is the Git backend to use.
100	Backend backend.Backend `yaml:"-"`
101
102	// InternalPublicKey is the public key of the internal SSH key.
103	InternalPublicKey string `yaml:"-"`
104
105	// ClientPublicKey is the public key of the client SSH key.
106	ClientPublicKey string `yaml:"-"`
107}
108
109func parseConfig(path string) (*Config, error) {
110	dataPath := filepath.Dir(path)
111	cfg := &Config{
112		Name:     "Soft Serve",
113		DataPath: dataPath,
114		SSH: SSHConfig{
115			ListenAddr:      ":23231",
116			PublicURL:       "ssh://localhost:23231",
117			KeyPath:         filepath.Join("ssh", "soft_serve_host_ed25519"),
118			ClientKeyPath:   filepath.Join("ssh", "soft_serve_client_ed25519"),
119			InternalKeyPath: filepath.Join("ssh", "soft_serve_internal_ed25519"),
120			MaxTimeout:      0,
121			IdleTimeout:     120,
122		},
123		Git: GitConfig{
124			ListenAddr:     ":9418",
125			MaxTimeout:     0,
126			IdleTimeout:    3,
127			MaxConnections: 32,
128		},
129		HTTP: HTTPConfig{
130			ListenAddr: ":8080",
131			PublicURL:  "http://localhost:8080",
132		},
133		Stats: StatsConfig{
134			ListenAddr: ":8081",
135		},
136	}
137
138	f, err := os.Open(path)
139	if err == nil {
140		defer f.Close() // nolint: errcheck
141		if err := yaml.NewDecoder(f).Decode(cfg); err != nil {
142			return cfg, fmt.Errorf("decode config: %w", err)
143		}
144	}
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	for _, key := range cfg.InitialAdminKeys {
154		if _, _, err := backend.ParseAuthorizedKey(key); err != nil {
155			log.Error("invalid initial admin key", "err", err)
156		}
157		log.Debugf("found initial admin key: %q", key)
158	}
159
160	// Reset datapath to config dir.
161	// This is necessary because the environment variable may be set to
162	// a different directory.
163	cfg.DataPath = dataPath
164
165	return cfg, nil
166}
167
168// ParseConfig parses the configuration from the given file.
169func ParseConfig(path string) (*Config, error) {
170	cfg, err := parseConfig(path)
171	if err != nil {
172		return nil, err
173	}
174
175	if err := cfg.validate(); err != nil {
176		return nil, err
177	}
178
179	return cfg, nil
180}
181
182// WriteConfig writes the configuration to the given file.
183func WriteConfig(path string, cfg *Config) error {
184	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
185		return err
186	}
187	return os.WriteFile(path, []byte(newConfigFile(cfg)), 0o600) // nolint: errcheck
188}
189
190// DefaultConfig returns a Config with the values populated with the defaults
191// or specified environment variables.
192func DefaultConfig() *Config {
193	dataPath := os.Getenv("SOFT_SERVE_DATA_PATH")
194	if dataPath == "" {
195		dataPath = "data"
196	}
197
198	cp := filepath.Join(dataPath, "config.yaml")
199	cfg, err := parseConfig(cp)
200	if err != nil && !errors.Is(err, os.ErrNotExist) {
201		log.Errorf("failed to parse config: %v", err)
202	}
203
204	// Write config if it doesn't exist
205	if _, err := os.Stat(cp); os.IsNotExist(err) {
206		if err := WriteConfig(cp, cfg); err != nil {
207			log.Fatal("failed to write config", "err", err)
208		}
209	}
210
211	if err := cfg.validate(); err != nil {
212		log.Fatal(err)
213	}
214
215	return cfg
216}
217
218// WithBackend sets the backend for the configuration.
219func (c *Config) WithBackend(backend backend.Backend) *Config {
220	c.Backend = backend
221	return c
222}
223
224func (c *Config) validate() error {
225	// Use absolute paths
226	if !filepath.IsAbs(c.DataPath) {
227		dp, err := filepath.Abs(c.DataPath)
228		if err != nil {
229			return err
230		}
231		c.DataPath = dp
232	}
233
234	c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/")
235	c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/")
236
237	if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) {
238		c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
239	}
240
241	if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {
242		c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)
243	}
244
245	if c.SSH.InternalKeyPath != "" && !filepath.IsAbs(c.SSH.InternalKeyPath) {
246		c.SSH.InternalKeyPath = filepath.Join(c.DataPath, c.SSH.InternalKeyPath)
247	}
248
249	if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
250		c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)
251	}
252
253	if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) {
254		c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)
255	}
256
257	return nil
258}