config.go

  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	"golang.org/x/crypto/ssh"
 14	"gopkg.in/yaml.v3"
 15)
 16
 17// SSHConfig is the configuration for the SSH server.
 18type SSHConfig struct {
 19	// ListenAddr is the address on which the SSH server will listen.
 20	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 21
 22	// PublicURL is the public URL of the SSH server.
 23	PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
 24
 25	// KeyPath is the path to the SSH server's private key.
 26	KeyPath string `env:"KEY_PATH" yaml:"key_path"`
 27
 28	// ClientKeyPath is the path to the server's client private key.
 29	ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`
 30
 31	// MaxTimeout is the maximum number of seconds a connection can take.
 32	MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
 33
 34	// IdleTimeout is the number of seconds a connection can be idle before it is closed.
 35	IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"`
 36}
 37
 38// GitConfig is the Git daemon configuration for the server.
 39type GitConfig struct {
 40	// ListenAddr is the address on which the Git daemon will listen.
 41	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 42
 43	// MaxTimeout is the maximum number of seconds a connection can take.
 44	MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
 45
 46	// IdleTimeout is the number of seconds a connection can be idle before it is closed.
 47	IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"`
 48
 49	// MaxConnections is the maximum number of concurrent connections.
 50	MaxConnections int `env:"MAX_CONNECTIONS" yaml:"max_connections"`
 51}
 52
 53// HTTPConfig is the HTTP configuration for the server.
 54type HTTPConfig struct {
 55	// ListenAddr is the address on which the HTTP server will listen.
 56	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 57
 58	// TLSKeyPath is the path to the TLS private key.
 59	TLSKeyPath string `env:"TLS_KEY_PATH" yaml:"tls_key_path"`
 60
 61	// TLSCertPath is the path to the TLS certificate.
 62	TLSCertPath string `env:"TLS_CERT_PATH" yaml:"tls_cert_path"`
 63
 64	// PublicURL is the public URL of the HTTP server.
 65	PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
 66}
 67
 68// StatsConfig is the configuration for the stats server.
 69type StatsConfig struct {
 70	// ListenAddr is the address on which the stats server will listen.
 71	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 72}
 73
 74// Config is the configuration for Soft Serve.
 75type Config struct {
 76	// Name is the name of the server.
 77	Name string `env:"NAME" yaml:"name"`
 78
 79	// SSH is the configuration for the SSH server.
 80	SSH SSHConfig `envPrefix:"SSH_" yaml:"ssh"`
 81
 82	// Git is the configuration for the Git daemon.
 83	Git GitConfig `envPrefix:"GIT_" yaml:"git"`
 84
 85	// HTTP is the configuration for the HTTP server.
 86	HTTP HTTPConfig `envPrefix:"HTTP_" yaml:"http"`
 87
 88	// Stats is the configuration for the stats server.
 89	Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"`
 90
 91	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
 92	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
 93
 94	// DataPath is the path to the directory where Soft Serve will store its data.
 95	DataPath string `env:"DATA_PATH" yaml:"-"`
 96
 97	// Backend is the Git backend to use.
 98	Backend backend.Backend `yaml:"-"`
 99}
100
101func parseConfig(path string) (*Config, error) {
102	dataPath := filepath.Dir(path)
103	cfg := &Config{
104		Name:     "Soft Serve",
105		DataPath: dataPath,
106		SSH: SSHConfig{
107			ListenAddr:    ":23231",
108			PublicURL:     "ssh://localhost:23231",
109			KeyPath:       filepath.Join("ssh", "soft_serve_host_ed25519"),
110			ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
111			MaxTimeout:    0,
112			IdleTimeout:   0,
113		},
114		Git: GitConfig{
115			ListenAddr:     ":9418",
116			MaxTimeout:     0,
117			IdleTimeout:    3,
118			MaxConnections: 32,
119		},
120		HTTP: HTTPConfig{
121			ListenAddr: ":23232",
122			PublicURL:  "http://localhost:23232",
123		},
124		Stats: StatsConfig{
125			ListenAddr: "localhost:23233",
126		},
127	}
128
129	f, err := os.Open(path)
130	if err == nil {
131		defer f.Close() // nolint: errcheck
132		if err := yaml.NewDecoder(f).Decode(cfg); err != nil {
133			return cfg, fmt.Errorf("decode config: %w", err)
134		}
135	}
136
137	// Merge initial admin keys from both config file and environment variables.
138	initialAdminKeys := append([]string{}, cfg.InitialAdminKeys...)
139
140	// Override with environment variables
141	if err := env.Parse(cfg, env.Options{
142		Prefix: "SOFT_SERVE_",
143	}); err != nil {
144		return cfg, fmt.Errorf("parse environment variables: %w", err)
145	}
146
147	// Merge initial admin keys from environment variables.
148	if initialAdminKeysEnv := os.Getenv("SOFT_SERVE_INITIAL_ADMIN_KEYS"); initialAdminKeysEnv != "" {
149		cfg.InitialAdminKeys = append(cfg.InitialAdminKeys, initialAdminKeys...)
150	}
151
152	// Validate keys
153	pks := make([]string, 0)
154	for _, key := range parseAuthKeys(cfg.InitialAdminKeys) {
155		ak := backend.MarshalAuthorizedKey(key)
156		pks = append(pks, ak)
157	}
158
159	cfg.InitialAdminKeys = pks
160
161	// Reset datapath to config dir.
162	// This is necessary because the environment variable may be set to
163	// a different directory.
164	cfg.DataPath = dataPath
165
166	return cfg, nil
167}
168
169// ParseConfig parses the configuration from the given file.
170func ParseConfig(path string) (*Config, error) {
171	cfg, err := parseConfig(path)
172	if err != nil {
173		return nil, err
174	}
175
176	if err := cfg.validate(); err != nil {
177		return nil, err
178	}
179
180	return cfg, nil
181}
182
183// WriteConfig writes the configuration to the given file.
184func WriteConfig(path string, cfg *Config) error {
185	if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
186		return err
187	}
188	return os.WriteFile(path, []byte(newConfigFile(cfg)), 0o644) // nolint: errcheck
189}
190
191// DefaultConfig returns a Config with the values populated with the defaults
192// or specified environment variables.
193func DefaultConfig() *Config {
194	dataPath := os.Getenv("SOFT_SERVE_DATA_PATH")
195	if dataPath == "" {
196		dataPath = "data"
197	}
198
199	cp := filepath.Join(dataPath, "config.yaml")
200	cfg, err := parseConfig(cp)
201	if err != nil && !errors.Is(err, os.ErrNotExist) {
202		log.Errorf("failed to parse config: %v", err)
203	}
204
205	// Write config if it doesn't exist
206	if _, err := os.Stat(cp); os.IsNotExist(err) {
207		if err := WriteConfig(cp, cfg); err != nil {
208			log.Fatal("failed to write config", "err", err)
209		}
210	}
211
212	if err := cfg.validate(); err != nil {
213		log.Fatal(err)
214	}
215
216	return cfg
217}
218
219// WithBackend sets the backend for the configuration.
220func (c *Config) WithBackend(backend backend.Backend) *Config {
221	c.Backend = backend
222	return c
223}
224
225func (c *Config) validate() error {
226	// Use absolute paths
227	if !filepath.IsAbs(c.DataPath) {
228		dp, err := filepath.Abs(c.DataPath)
229		if err != nil {
230			return err
231		}
232		c.DataPath = dp
233	}
234
235	c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/")
236	c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/")
237
238	if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) {
239		c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
240	}
241
242	if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {
243		c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)
244	}
245
246	if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
247		c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)
248	}
249
250	if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) {
251		c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)
252	}
253
254	return nil
255}
256
257// parseAuthKeys parses authorized keys from either file paths or string authorized_keys.
258func parseAuthKeys(aks []string) []ssh.PublicKey {
259	pks := make([]ssh.PublicKey, 0)
260	for _, key := range aks {
261		if bts, err := os.ReadFile(key); err == nil {
262			// key is a file
263			key = strings.TrimSpace(string(bts))
264		}
265		if pk, _, err := backend.ParseAuthorizedKey(key); err == nil {
266			pks = append(pks, pk)
267		}
268	}
269	return pks
270}
271
272// AdminKeys returns the server admin keys.
273func (c *Config) AdminKeys() []ssh.PublicKey {
274	return parseAuthKeys(c.InitialAdminKeys)
275}