package main import ( "encoding/json" "errors" "fmt" "net/url" "os" "regexp" "strings" ) // Config is {datadir}/config/forge.json. It has no field for a secret: every // secret is environment only. SiteName, SiteDesc, Logo, and Icon are read once // at startup, into Site. type Config struct { Listen string `json:"listen"` SiteName string `json:"site_name"` SiteDesc string `json:"site_desc"` Logo string `json:"logo"` Icon string `json:"icon"` BaseURL string `json:"base_url"` OIDC []OIDCProvider `json:"oidc"` Mail MailConfig `json:"mail"` // ClientIPHeader names the header the client address arrives in, behind a // proxy. Empty means the connection is the address. ClientIPHeader string `json:"client_ip_header"` host string // BaseURL's host without the port, for generated addresses } type OIDCProvider struct { Key string `json:"key"` Label string `json:"label"` Issuer string `json:"issuer"` ClientID string `json:"client_id"` Allow []string `json:"allow,omitempty"` ClientSecret string `json:"-"` // GIT_FORGE_OIDC__CLIENT_SECRET } // An entry beginning with @ is a whole domain, anything else is one address, // and an empty list admits anyone. Checked at every login, so removing an // entry revokes. func (p OIDCProvider) allows(email string) bool { if len(p.Allow) == 0 { return true } email = strings.ToLower(email) _, domain, split := strings.Cut(email, "@") for _, entry := range p.Allow { entry = strings.ToLower(entry) if want, isDomain := strings.CutPrefix(entry, "@"); isDomain { // Whole label, not suffix. if split && domain == want { return true } continue } if email == entry { return true } } return false } type MailConfig struct { Driver string `json:"driver"` From string `json:"from"` Mailgun MailgunConfig `json:"mailgun"` } type MailgunConfig struct { Domain string `json:"domain"` BaseURL string `json:"base_url"` APIKey string `json:"-"` // GIT_FORGE_MAIL_MAILGUN_API_KEY } // An OIDC key prefixes the accounts it mints, as in sso:carol, and names the // client secret's environment variable. var oidcKeyRE = regexp.MustCompile(`^[a-z][a-z0-9]{0,15}$`) // A header name. One with a colon in it never matches, which looks like the // proxy failing to set it. var headerNameRE = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) // The name reaches the realm git is prompted with, where a quote or backslash // ends it early. func validSiteName(name string) bool { return name != "" && !strings.ContainsAny(name, `"\`) && !hasControlByte(name) } var errSiteName = userError("the site needs a name, without a quote, a backslash, or a control byte in it") func defaultConfig() *Config { return &Config{ Listen: ":8080", SiteName: "git-forge", SiteDesc: "a small self-hosted git forge", BaseURL: "http://localhost:8080", OIDC: []OIDCProvider{}, Mail: MailConfig{ Driver: "none", From: "git-forge ", Mailgun: MailgunConfig{BaseURL: "https://api.mailgun.net/v3"}, }, } } // readConfig is the file alone. An unknown field is an error, so a typo cannot // silently do nothing. func readConfig(path string) (*Config, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() cfg := &Config{} dec := json.NewDecoder(f) dec.DisallowUnknownFields() if err := dec.Decode(cfg); err != nil { return nil, err } return cfg, nil } // loadConfig is the file with the environment on top, validated. func loadConfig(path string) (*Config, error) { cfg, err := readConfig(path) if err != nil { return nil, err } cfg.applyEnv() if err := cfg.validate(); err != nil { return nil, err } return cfg, nil } func (c *Config) applyEnv() { fromEnv(&c.Listen, "GIT_FORGE_LISTEN") fromEnv(&c.SiteName, "GIT_FORGE_SITE_NAME") fromEnv(&c.SiteDesc, "GIT_FORGE_SITE_DESC") fromEnv(&c.BaseURL, "GIT_FORGE_BASE_URL") fromEnv(&c.ClientIPHeader, "GIT_FORGE_CLIENT_IP_HEADER") c.Mail.Mailgun.APIKey = os.Getenv("GIT_FORGE_MAIL_MAILGUN_API_KEY") for i := range c.OIDC { key := strings.ToUpper(c.OIDC[i].Key) c.OIDC[i].ClientSecret = os.Getenv("GIT_FORGE_OIDC_" + key + "_CLIENT_SECRET") } } func fromEnv(dst *string, name string) { if v, ok := os.LookupEnv(name); ok { *dst = v } } // validate reports everything wrong at once. func (c *Config) validate() error { var errs []error fail := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) } if c.Listen == "" { fail("listen: empty") } if !validSiteName(c.SiteName) { fail("site_name: empty, or holds a quote, a backslash, or a control byte") } if c.ClientIPHeader != "" && !headerNameRE.MatchString(c.ClientIPHeader) { fail("client_ip_header: %q is not a header name", c.ClientIPHeader) } c.BaseURL = strings.TrimSuffix(c.BaseURL, "/") u, err := url.Parse(c.BaseURL) switch { case err != nil: fail("base_url: %v", err) case u.Scheme != "http" && u.Scheme != "https": fail("base_url: scheme must be http or https, not %q", u.Scheme) case u.Host == "": fail("base_url: no host") case u.Path != "" || u.RawQuery != "" || u.Fragment != "": fail("base_url: scheme and host only, git-forge serves the root") default: c.host = u.Hostname() // no port: this ends up in mail addresses } seen := map[string]bool{} for i, p := range c.OIDC { at := fmt.Sprintf("oidc[%d]", i) switch { case !oidcKeyRE.MatchString(p.Key): fail("%s: key %q must match %s, it prefixes account names", at, p.Key, oidcKeyRE) case seen[p.Key]: fail("%s: duplicate key %q", at, p.Key) default: seen[p.Key] = true } if p.Label == "" { fail("%s: label: empty", at) } if p.ClientID == "" { fail("%s: client_id: empty", at) } iss, err := url.Parse(p.Issuer) switch { case err != nil: fail("%s: issuer: %v", at, err) case iss.Scheme != "https": fail("%s: issuer: must be https", at) case iss.Host == "": fail("%s: issuer: no host", at) case strings.HasSuffix(p.Issuer, "/"): fail("%s: issuer: no trailing slash, it is compared to the iss claim", at) } // An entry that can never match is a lockout, refused here rather than // at login. for j, entry := range p.Allow { switch { case entry == "" || strings.TrimSpace(entry) != entry: fail("%s: allow[%d]: empty or padded with spaces", at, j) case entry == "@": fail("%s: allow[%d]: a domain entry needs a domain after the @", at, j) case !strings.Contains(entry, "@"): fail("%s: allow[%d]: %q is neither an address nor an @domain", at, j, entry) } } } if c.Mail.From == "" { fail("mail.from: empty") } switch c.Mail.Driver { case "none", "log": case "mailgun": if c.Mail.Mailgun.Domain == "" { fail("mail.mailgun.domain: empty") } if c.Mail.Mailgun.APIKey == "" { fail("mail.mailgun: GIT_FORGE_MAIL_MAILGUN_API_KEY is not set") } base, err := url.Parse(c.Mail.Mailgun.BaseURL) switch { case err != nil: fail("mail.mailgun.base_url: %v", err) case base.Scheme != "https" || base.Host == "": fail("mail.mailgun.base_url: must be an https url") case strings.HasSuffix(c.Mail.Mailgun.BaseURL, "/"): fail("mail.mailgun.base_url: no trailing slash") } default: fail("mail.driver: %q is not none, log, or mailgun", c.Mail.Driver) } return errors.Join(errs...) }