diff options
| author | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
| commit | bb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch) | |
| tree | 6d5eaf5dac213074cd832da50f64260fce728fed /config.go | |
| download | git-forge-bb6bc2387d58c737dbf92e73f8544bf39319dbd8.tar.gz | |
A small self-hosted git forge: HTTP push and pull, cgit browsing,
local and OIDC accounts, TOTP, scoped push tokens, per-repo
permissions, issues, a wiki, and outbound mail.
It does not reimplement git. git-http-backend and cgit run as CGI
behind its own listener, which authorises every request before
exec'ing either. One binary, one data directory, one listener: no
database, no JavaScript, and state is JSON in a git repo with a
working tree.
Diffstat (limited to 'config.go')
| -rw-r--r-- | config.go | 263 |
1 files changed, 263 insertions, 0 deletions
diff --git a/config.go b/config.go new file mode 100644 index 0000000..0739de5 --- /dev/null +++ b/config.go @@ -0,0 +1,263 @@ +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_<key>_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 <git-forge@localhost>", + 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...) +} |