aboutsummaryrefslogtreecommitdiff
path: root/config.go
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commitbb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch)
tree6d5eaf5dac213074cd832da50f64260fce728fed /config.go
downloadgit-forge-bb6bc2387d58c737dbf92e73f8544bf39319dbd8.tar.gz
Enter git-forgeHEADmaster
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.go263
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...)
+}