internal/config/config.go

a831d1ab09705002f965fb91989e86d1638c0ed8
gitbay/internal/config/config.go history · blame · raw

181 lines · 5244 bytes

  1// Package config loads and validates the forged server configuration.
  2package config
  3
  4import (
  5	"errors"
  6	"fmt"
  7	"net"
  8	"os"
  9	"strconv"
 10
 11	"github.com/BurntSushi/toml"
 12)
 13
 14type Config struct {
 15	Server       Server       `toml:"server"`
 16	SSH          SSH          `toml:"ssh"`
 17	HTTP         HTTP         `toml:"http"`
 18	GitDaemon    GitDaemon    `toml:"git_daemon"`
 19	Web          Web          `toml:"web"`
 20	Registration Registration `toml:"registration"`
 21	Limits       Limits       `toml:"limits"`
 22	Mail         Mail         `toml:"mail"`
 23}
 24
 25type Server struct {
 26	Root    string `toml:"root"`
 27	SiteURL string `toml:"site_url"`
 28}
 29
 30type SSH struct {
 31	Mode     string   `toml:"mode"` // embedded | system
 32	Port     int      `toml:"port"`
 33	HostKeys []string `toml:"host_keys"`
 34}
 35
 36type HTTP struct {
 37	Addr     string `toml:"addr"`
 38	TLS      string `toml:"tls"` // acme | files | off
 39	CertFile string `toml:"cert_file"`
 40	KeyFile  string `toml:"key_file"`
 41}
 42
 43type GitDaemon struct {
 44	Enabled bool `toml:"enabled"`
 45	Port    int  `toml:"port"`
 46}
 47
 48type Web struct {
 49	Mode         string `toml:"mode"` // view_only | accounts
 50	PasswordAuth bool   `toml:"password_auth"`
 51}
 52
 53type Registration struct {
 54	Mode string `toml:"mode"` // closed | invite | open
 55}
 56
 57type Limits struct {
 58	MaxPackBytes    int64 `toml:"max_pack_bytes"`
 59	MaxBlobBytes    int64 `toml:"max_blob_bytes"`
 60	CloneTimeoutSec int   `toml:"clone_timeout"`
 61	SSHAuthRate     int   `toml:"ssh_auth_rate"`
 62}
 63
 64type Mail struct {
 65	SMTPHost string `toml:"smtp_host"`
 66	From     string `toml:"from"`
 67}
 68
 69// Default returns the configuration used when a key is absent from the file.
 70func Default() Config {
 71	return Config{
 72		Server: Server{Root: "/var/lib/forge"},
 73		SSH:    SSH{Mode: "embedded", Port: 22},
 74		HTTP:   HTTP{Addr: ":443", TLS: "acme"},
 75		Web:    Web{Mode: "view_only"},
 76		Registration: Registration{
 77			Mode: "closed",
 78		},
 79		GitDaemon: GitDaemon{Port: 9418},
 80		Limits: Limits{
 81			MaxPackBytes:    2 << 30, // 2 GiB
 82			MaxBlobBytes:    100 << 20,
 83			CloneTimeoutSec: 3600,
 84			SSHAuthRate:     10,
 85		},
 86	}
 87}
 88
 89// Load reads path, applies defaults, and validates. It does not probe the
 90// host (see CheckHost) so it is safe in tests and on non-target machines.
 91func Load(path string) (Config, error) {
 92	cfg := Default()
 93	md, err := toml.DecodeFile(path, &cfg)
 94	if err != nil {
 95		return cfg, err
 96	}
 97	if u := md.Undecoded(); len(u) > 0 {
 98		return cfg, fmt.Errorf("unknown config key %q", u[0].String())
 99	}
100	return cfg, cfg.Validate()
101}
102
103func oneOf(field, val string, allowed ...string) error {
104	for _, a := range allowed {
105		if val == a {
106			return nil
107		}
108	}
109	return fmt.Errorf("%s must be one of %v, got %q", field, allowed, val)
110}
111
112// Validate applies the static contradiction checks from the plan.
113func (c Config) Validate() error {
114	var errs []error
115
116	if c.Server.Root == "" {
117		errs = append(errs, errors.New("server.root is required"))
118	}
119	if c.Server.SiteURL == "" {
120		errs = append(errs, errors.New("server.site_url is required"))
121	}
122	if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil {
123		errs = append(errs, err)
124	}
125	if c.SSH.Port < 1 || c.SSH.Port > 65535 {
126		errs = append(errs, fmt.Errorf("ssh.port %d out of range", c.SSH.Port))
127	}
128	if err := oneOf("http.tls", c.HTTP.TLS, "acme", "files", "off"); err != nil {
129		errs = append(errs, err)
130	}
131	if c.HTTP.TLS == "files" && (c.HTTP.CertFile == "" || c.HTTP.KeyFile == "") {
132		errs = append(errs, errors.New("http.tls = \"files\" requires cert_file and key_file"))
133	}
134	if err := oneOf("web.mode", c.Web.Mode, "view_only", "accounts"); err != nil {
135		errs = append(errs, err)
136	}
137	if err := oneOf("registration.mode", c.Registration.Mode, "closed", "invite", "open"); err != nil {
138		errs = append(errs, err)
139	}
140
141	// Contradictions.
142	if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" {
143		errs = append(errs, fmt.Errorf(
144			"registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP",
145			c.Registration.Mode))
146	}
147	if c.SSH.Mode == "system" && c.Registration.Mode != "closed" {
148		errs = append(errs, fmt.Errorf(
149			"ssh.mode = \"system\" requires registration.mode = \"closed\": host sshd rejects unknown keys before the dispatcher runs, so registration by unknown key is impossible"))
150	}
151	if c.Web.PasswordAuth && c.Web.Mode == "view_only" {
152		errs = append(errs, errors.New(
153			"web.password_auth = true is meaningless with web.mode = \"view_only\": no login route exists"))
154	}
155
156	return errors.Join(errs...)
157}
158
159// CheckHost performs environment probes that only make sense on the target
160// machine: port availability for the embedded listener and root existence.
161func (c Config) CheckHost() error {
162	var errs []error
163
164	if st, err := os.Stat(c.Server.Root); err != nil {
165		errs = append(errs, fmt.Errorf("server.root: %w", err))
166	} else if !st.IsDir() {
167		errs = append(errs, fmt.Errorf("server.root %q is not a directory", c.Server.Root))
168	}
169
170	if c.SSH.Mode == "embedded" {
171		addr := net.JoinHostPort("", strconv.Itoa(c.SSH.Port))
172		ln, err := net.Listen("tcp", addr)
173		if err != nil {
174			errs = append(errs, fmt.Errorf("ssh.port %d is not bindable (already in use by another daemon?): %w", c.SSH.Port, err))
175		} else {
176			ln.Close()
177		}
178	}
179
180	return errors.Join(errs...)
181}