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