From bb6bc2387d58c737dbf92e73f8544bf39319dbd8 Mon Sep 17 00:00:00 2001 From: Lena Date: Sat, 1 Aug 2026 00:00:00 +0000 Subject: Enter git-forge 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. --- main.go | 733 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 733 insertions(+) create mode 100644 main.go (limited to 'main.go') diff --git a/main.go b/main.go new file mode 100644 index 0000000..931de06 --- /dev/null +++ b/main.go @@ -0,0 +1,733 @@ +// git-forge is a small self-hosted git forge: one binary, one data directory, +// one listener. +package main + +import ( + "bytes" + "context" + "embed" + "errors" + "flag" + "fmt" + "html/template" + "io" + "io/fs" + "log" + "net" + "net/http" + "os" + "os/signal" + "path" + "path/filepath" + "slices" + "strings" + "sync/atomic" + "syscall" + "time" +) + +const defaultDatadir = "/var/lib/git-forge" + +func main() { + // cgit's filters: the blob on stdin, and neither the datadir nor a listener. + if len(os.Args) > 1 { + switch os.Args[1] { + case "md": + os.Exit(runMarkdown()) + case "highlight": + os.Exit(runHighlight(os.Args[2:])) + } + } + + log.SetFlags(log.LstdFlags | log.LUTC) + log.SetOutput(os.Stdout) + + // run rather than log.Fatalf, which exits without running any defer. + if err := run(); err != nil { + log.Fatalf("%v", err) + } +} + +func run() error { + datadir := flag.String("datadir", "", "data directory (default $GIT_FORGE_DATADIR, else "+defaultDatadir+")") + flag.Parse() + if flag.NArg() > 0 { + return fmt.Errorf("unexpected argument %q", flag.Arg(0)) + } + + dir, err := resolveDatadir(*datadir) + if err != nil { + return fmt.Errorf("datadir: %v", err) + } + unlock, err := lockDatadir(dir) + if err != nil { + return err + } + defer unlock() + + store, err := openStore(dir) + if err != nil { + return fmt.Errorf("datadir %s: %v", dir, err) + } + cfg := store.cfg + log.Printf("datadir %s, site %q, base url %s", store.dir, cfg.SiteName, cfg.BaseURL) + if cfg.ClientIPHeader != "" { + log.Printf("client address from the %s header, trusted", cfg.ClientIPHeader) + } + for _, p := range cfg.OIDC { + // Whether the secret arrived, never the secret. + held := "no client secret" + if p.ClientSecret != "" { + held = "client secret held" + } + log.Printf("oidc %s: %s, callback %s/oidc/%s/callback, %s", + p.Key, p.Issuer, cfg.BaseURL, p.Key, held) + } + + forge, err := newForge(store) + if err != nil { + return err + } + defer forge.cgit.close() + if len(store.Users()) == 0 { + token := randomToken(16) + forge.setupToken.Store(&token) + log.Printf("no accounts yet: open %s/setup and give it this token: %s", cfg.BaseURL, token) + } + drain, stopDraining := context.WithCancel(context.Background()) + defer stopDraining() + go forge.mail.Drain(drain) + + return serve(cfg, forge.routes()) +} + +// Forge is the whole server: the config, the datadir, and the pages. +type Forge struct { + cfg *Config + store *Store + cgit *Cgit + mail *Mail + oidc *OIDC + pages map[string]*template.Template + nav *template.Template // the bar, on forge pages and injected into cgit's + site atomic.Pointer[Site] // the name, subtitle, and logo an admin edits + + issueLimit *limiter + commentLimit *limiter + resetLimit *limiter + totpLimit *limiter + loginLimit *limiter + tokenLimit *limiter + + // setupToken lives in memory only: a restart prints a new one and a + // stolen datadir carries none. + setupToken atomic.Pointer[string] +} + +//go:embed templates static +var assets embed.FS + +// layout is parsed into every page. Everything else in templates/ is a page. +var layout = []string{"templates/base.html", "templates/nav.html"} + +func newForge(store *Store) (*Forge, error) { + cfg := store.cfg + // One template set per page, so two pages can both define main. + files, err := fs.Glob(assets, "templates/*.html") + if err != nil { + return nil, err + } + pages := map[string]*template.Template{} + for _, file := range files { + if slices.Contains(layout, file) { + continue + } + t, err := template.ParseFS(assets, append(slices.Clone(layout), file)...) + if err != nil { + return nil, err + } + pages[path.Base(file)] = t + } + nav, err := template.ParseFS(assets, "templates/nav.html") + if err != nil { + return nil, err + } + forgeCSS, err := assets.ReadFile("static/forge.css") + if err != nil { + return nil, err + } + browser, err := newCgit(store, forgeCSS) + if err != nil { + return nil, err + } + site, err := loadSite(store.configDir, cfg.SiteName, cfg.SiteDesc, cfg.Logo, cfg.Icon) + if err != nil { + return nil, err + } + f := &Forge{ + cfg: cfg, store: store, cgit: browser, mail: newMail(cfg, store.mailqDir), oidc: newOIDC(), + pages: pages, nav: nav, + issueLimit: newLimiter(), commentLimit: newLimiter(), resetLimit: newLimiter(), + totpLimit: newLimiter(), loginLimit: newLimiter(), tokenLimit: newLimiter(), + } + f.site.Store(site) + return f, nil +} + +// Site is what the header draws, swapped whole so a reader never sees half a +// change. +type Site struct { + Name string + Desc string + Logo Image // the header image + Icon Image // the favicon +} + +// Image is a configured image: the path in the config, its bytes, and the +// sniffed type. An empty Name is cgit's own. +type Image struct { + Name string + Body []byte + Kind string +} + +// siteName is the name in effect; the config holds only what the process +// started with. +func (f *Forge) siteName() string { return f.site.Load().Name } + +// Served back to browsers, so decided from the bytes. No SVG: it can carry +// script. +var imageKinds = map[string]bool{ + "image/png": true, "image/jpeg": true, "image/gif": true, + "image/webp": true, "image/x-icon": true, +} + +const maxImage = 512 << 10 + +func loadSite(dir, name, desc, logo, icon string) (*Site, error) { + site := &Site{Name: name, Desc: desc} + var err error + if site.Logo, err = loadImage(dir, logo); err != nil { + return nil, err + } + if site.Icon, err = loadImage(dir, icon); err != nil { + return nil, err + } + return site, nil +} + +// A relative path is under the config directory, where an upload lands. +func loadImage(dir, name string) (Image, error) { + if name == "" { + return Image{}, nil + } + path := name + if !filepath.IsAbs(path) { + path = filepath.Join(dir, path) + } + body, err := os.ReadFile(path) + if err != nil { + return Image{}, err + } + if err := checkImage(body); err != nil { + return Image{}, err + } + return Image{Name: name, Body: body, Kind: http.DetectContentType(body)}, nil +} + +func checkImage(body []byte) error { + if len(body) > maxImage { + return userError(fmt.Sprintf("that image is larger than %d KiB", maxImage>>10)) + } + if kind := http.DetectContentType(body); !imageKinds[kind] { + return userError("that is a " + kind + ", and it has to be a png, jpeg, gif, webp, or icon") + } + return nil +} + +func (f *Forge) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", healthz) + mux.HandleFunc("GET /cgit.css", f.handleCgitCSS) + mux.HandleFunc("GET /cgit.png", f.handleCgitLogo) + mux.HandleFunc("GET /favicon.ico", f.handleFavicon) + mux.HandleFunc("GET /logo", f.handleLogo) + mux.HandleFunc("GET /avatar/{name}", f.handleAvatar) + mux.HandleFunc("GET /login", f.handleLogin) + mux.HandleFunc("POST /login", f.handleLoginPost) + mux.HandleFunc("POST /login/totp", f.handleLoginTOTP) + mux.HandleFunc("POST /logout", f.handleLogout) + mux.HandleFunc("GET /settings", f.handleSettings) + mux.HandleFunc("POST /settings/password", f.handlePassword) + mux.HandleFunc("POST /settings/sessions", f.handleSignOut) + mux.HandleFunc("POST /settings/email", f.handleEmail) + mux.HandleFunc("POST /settings/tokens", f.handleTokenMint) + mux.HandleFunc("POST /settings/tokens/revoke", f.handleTokenRevoke) + mux.HandleFunc("GET /settings/totp", f.handleTOTP) + mux.HandleFunc("POST /settings/totp", f.handleTOTPEnroll) + mux.HandleFunc("POST /settings/totp/remove", f.handleTOTPRemove) + mux.HandleFunc("POST /settings/totp/codes", f.handleTOTPCodes) + mux.HandleFunc("GET /confirm-email", f.handleConfirmEmail) + mux.HandleFunc("GET /oidc/{key}", f.handleOIDCStart) + mux.HandleFunc("GET /oidc/{key}/callback", f.handleOIDCCallback) + mux.HandleFunc("GET /reset", f.handleReset) + mux.HandleFunc("POST /reset", f.handleResetPost) + mux.HandleFunc("GET /set-password", f.handleSetPassword) + mux.HandleFunc("POST /set-password", f.handleSetPasswordPost) + mux.HandleFunc("GET /setup", f.handleSetup) + mux.HandleFunc("POST /setup", f.handleSetupPost) + mux.HandleFunc("GET /admin", f.handleAdmin) + mux.HandleFunc("POST /admin/site", f.handleSiteSettings) + mux.HandleFunc("POST /admin/repo/create", f.handleRepoCreate) + mux.HandleFunc("POST /admin/repo/adopt", f.handleRepoAdopt) + mux.HandleFunc("POST /admin/user/create", f.handleUserCreate) + mux.HandleFunc("POST /admin/user/admin", f.handleUserAdmin) + mux.HandleFunc("POST /admin/user/totp", f.handleUserTOTP) + mux.HandleFunc("POST /admin/user/delete", f.handleUserDelete) + mux.HandleFunc("GET /{$}", f.handleHome) + mux.HandleFunc("/{path...}", f.handleRepo) + return f.logRequests(secureHeaders(mux)) +} + +// secureHeaders is the whole header policy, on cgit's pages too. No script +// runs anywhere; images are same origin, so a markdown src cannot tell a third +// party who reads a private page; nothing is cached, because cgit dates its +// own pages ahead. Asset routes override the cache header. +func secureHeaders(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + head := w.Header() + head.Set("Content-Security-Policy", + "script-src 'none'; object-src 'none'; base-uri 'none'; img-src 'self'; frame-ancestors 'none'") + head.Set("X-Content-Type-Options", "nosniff") + head.Set("Cache-Control", "no-store") + h.ServeHTTP(w, r) + }) +} + +func (f *Forge) handleHome(w http.ResponseWriter, r *http.Request) { + f.render(w, "home.html", http.StatusOK, f.withIndex(r, f.page(r))) +} + +func (f *Forge) withIndex(r *http.Request, d pageData) pageData { + v := f.viewer(r) + visible := []RepoEntry{} + for _, entry := range f.store.Repos() { + if v.CanRead(entry.Repo) { + visible = append(visible, entry) + } + } + d.Data = indexPage{Repos: visible} + return d +} + +type indexPage struct{ Repos []RepoEntry } + +// handleRepo owns everything under a repository path: the longest prefix the +// tree holds is the repository, and the rest is the action. +func (f *Forge) handleRepo(w http.ResponseWriter, r *http.Request) { + if !servablePath(r.URL.Path) { + f.fail(w, r, http.StatusNotFound, "no such page") + return + } + // .git paths authorise differently and answer in git's protocol. + if path, kind, rest, ok := splitGit(r.URL.Path); ok { + f.handleGitHTTP(w, r, path, kind, rest) + return + } + path, kind, rest, ok := f.store.splitRepo(r.URL.Path) + if !ok { + f.fail(w, r, http.StatusNotFound, "no such page") + return + } + repo, _ := f.store.Repo(path) + // A repository the viewer may not read does not exist. + if !f.viewer(r).CanRead(repo) { + f.fail(w, r, http.StatusNotFound, "no such page") + return + } + + // {repo}.issues and {repo}.wiki are the siblings' history, cgit only. + if kind != "" { + if (kind == "issues" && repo.Issues == featDisabled) || (kind == "wiki" && repo.Wiki == featDisabled) { + f.fail(w, r, http.StatusNotFound, "no such page") + return + } + f.handleCgit(w, r, path, kind, rest, repo) + return + } + + // The forge's by name, cgit's by default; cgit has no route by these names. + head, _, _ := strings.Cut(rest, "/") + switch head { + case "settings": + f.repoSettingsRoutes(w, r, path, repo, rest) + case "issues": + f.handleIssues(w, r, path, repo, rest) + case "wiki": + f.handleWiki(w, r, path, repo, rest) + default: + f.handleCgit(w, r, path, "", rest, repo) + } +} + +// servablePath is what both CGIs will take: the path goes into an environment +// variable, which cannot hold NUL, and git-http-backend answers a . or .. or +// empty component with a 500. Percent encoded, these survive the mux's +// cleaning. +func servablePath(p string) bool { + if hasControlByte(p) { + return false + } + parts := strings.Split(strings.TrimPrefix(p, "/"), "/") + for i, part := range parts { + // A trailing slash is kept: cgit's own links carry one. + if part == "." || part == ".." || (part == "" && i < len(parts)-1) { + return false + } + } + return true +} + +func hasControlByte(p string) bool { + return strings.ContainsFunc(p, func(c rune) bool { return c < ' ' || c == 0x7f }) +} + +// isGet treats HEAD as GET: net/http drops the body on the way out. +func isGet(r *http.Request) bool { + return r.Method == http.MethodGet || r.Method == http.MethodHead +} + +// childSlot takes one of a bounded number of forked children, or reports +// false once the client has hung up. +func childSlot(budget chan struct{}, r *http.Request) (release func(), ok bool) { + select { + case budget <- struct{}{}: + return func() { <-budget }, true + case <-r.Context().Done(): + return nil, false + } +} + +func (f *Forge) repoSettingsRoutes(w http.ResponseWriter, r *http.Request, path string, repo Repo, rest string) { + switch { + case rest == "settings" && isGet(r): + f.repoSettings(w, r, path, repo) + case rest == "settings" && r.Method == http.MethodPost: + if f.checkPost(w, r) { + f.repoSettingsSave(w, r, path, repo) + } + case rest == "settings/grant" && r.Method == http.MethodPost: + if f.checkPost(w, r) { + f.repoGrant(w, r, path, repo) + } + case rest == "settings/delete" && r.Method == http.MethodPost: + if f.checkPost(w, r) { + f.repoDelete(w, r, path, repo) + } + default: + f.fail(w, r, http.StatusNotFound, "no such page") + } +} + +// viewer is whoever is asking. +func (f *Forge) viewer(r *http.Request) Viewer { + if name, u, ok := f.user(r); ok { + return Viewer{Name: name, Admin: u.adminInEffect()} + } + return Viewer{} +} + +// actor is the identity a change made by this request is recorded under. +func (f *Forge) actor(r *http.Request) Author { + name, u, ok := f.user(r) + if !ok { + return f.store.forgeAuthor() + } + return f.store.author(name, u) +} + +// One stylesheet for both kinds of page. +func (f *Forge) handleCgitCSS(w http.ResponseWriter, r *http.Request) { + if len(f.cgit.css) == 0 { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/css; charset=utf-8") + w.Header().Set("Cache-Control", "max-age=300") + w.Write(f.cgit.css) +} + +func (f *Forge) handleCgitLogo(w http.ResponseWriter, r *http.Request) { + serveAsset(w, r, f.cgit.logo, "image/png") +} + +// Browsers ask for this unprompted, so it falls back to cgit's rather than 404. +func (f *Forge) handleFavicon(w http.ResponseWriter, r *http.Request) { + if icon := f.site.Load().Icon; icon.Name != "" { + serveAsset(w, r, icon.Body, icon.Kind) + return + } + serveAsset(w, r, f.cgit.favicon, "image/vnd.microsoft.icon") +} + +func (f *Forge) handleLogo(w http.ResponseWriter, r *http.Request) { + logo := f.site.Load().Logo + serveAsset(w, r, logo.Body, logo.Kind) +} + +func serveAsset(w http.ResponseWriter, r *http.Request, body []byte, kind string) { + if len(body) == 0 { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", kind) + w.Header().Set("Cache-Control", "max-age=3600") + w.Write(body) +} + +// userError is an error whose text is meant for the person who caused it. +type userError string + +func (e userError) Error() string { return string(e) } + +// problem shows a userError and hides anything else behind the log. +func (f *Forge) problem(w http.ResponseWriter, r *http.Request, status int, err error) { + var mine userError + if errors.As(err, &mine) { + f.fail(w, r, status, mine.Error()) + return + } + log.Printf("%s %q: %v", r.Method, r.URL.Path, err) + f.fail(w, r, http.StatusInternalServerError, "something went wrong here, and the log has the detail") +} + +// pageData is what every template is given. Data is the page's own. +type pageData struct { + Site string + Base string // the base url, for the clone lines on the page + Now string // for the footer, in the format cgit's own footer uses + Desc string + Logo string + // MailOff stops pages offering what only mail can deliver. + MailOff bool + User string // empty when signed out + // MustEnrol is the flag waiting on an authenticator; the admin link is + // where enrolling starts. + Admin bool + MustEnrol bool + CSRF string + Error string + Notice string + Repo *repoBar // set on a repository page, rendered or injected + Data any +} + +// repoBar is the bar every repository page carries. +type repoBar struct { + Path string + Desc string + Issues bool + Wiki bool + Settings bool + Active string // the forge's own tab this page is, empty on cgit's pages +} + +func (f *Forge) repoBar(r *http.Request, path, kind, active string, repo Repo) *repoBar { + desc := repo.Desc + switch kind { + case "issues": + desc = "the issue tracker of " + path + case "wiki": + desc = "the wiki of " + path + } + v := f.viewer(r) + return &repoBar{ + Path: path, + Desc: desc, + Active: active, + Issues: repo.Issues != featDisabled, + Wiki: repo.Wiki != featDisabled, + Settings: v.CanAdmin(repo), + } +} + +type errorPage struct { + Status int + Text string +} + +func (f *Forge) page(r *http.Request) pageData { + d := f.chrome(f.csrf(r)) + if name, u, ok := f.user(r); ok { + d.signedInAs(name, u) + } + return d +} + +// pageFor builds the page for a reissued cookie, which the request does not +// carry yet. +func (f *Forge) pageFor(name string, u User, session string) pageData { + d := f.chrome(f.mac("csrf", session)) + d.signedInAs(name, u) + return d +} + +// chrome is everything on a page that does not depend on who is asking. +func (f *Forge) chrome(csrf string) pageData { + site := f.site.Load() + logo := "/cgit.png" + if site.Logo.Name != "" { + logo = "/logo" + } + return pageData{ + Site: site.Name, Base: f.cfg.BaseURL, Desc: site.Desc, Logo: logo, + Now: time.Now().Format("2006-01-02 15:04:05 -0700"), + MailOff: f.cfg.Mail.Driver == "none", CSRF: csrf, + } +} + +func (d *pageData) signedInAs(name string, u User) { + d.User = name + d.Admin, d.MustEnrol = u.adminInEffect(), u.Admin && u.TOTP == nil +} + +// requireUser answers the request itself when there is no account. +func (f *Forge) requireUser(w http.ResponseWriter, r *http.Request) (string, User, bool) { + name, u, ok := f.user(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + } + return name, u, ok +} + +// render buffers, so a template failing halfway leaves no half page behind a +// 200. +func (f *Forge) render(w http.ResponseWriter, page string, status int, d pageData) { + t, ok := f.pages[page] + if !ok { + log.Printf("template %s: not embedded", page) + http.Error(w, "template missing", http.StatusInternalServerError) + return + } + var buf bytes.Buffer + if err := t.ExecuteTemplate(&buf, "base.html", d); err != nil { + log.Printf("template %s: %v", page, err) + http.Error(w, "template failed", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + w.Write(buf.Bytes()) +} + +func (f *Forge) fail(w http.ResponseWriter, r *http.Request, status int, msg string) { + d := f.page(r) + d.Error = msg + d.Data = errorPage{Status: status, Text: http.StatusText(status)} + f.render(w, "error.html", status, d) +} + +// Fixed before the config is read, so the config cannot move it. +func resolveDatadir(flagValue string) (string, error) { + dir := flagValue + if dir == "" { + dir = os.Getenv("GIT_FORGE_DATADIR") + } + if dir == "" { + dir = defaultDatadir + } + return filepath.Abs(dir) +} + +func serve(cfg *Config, h http.Handler) error { + srv := &http.Server{ + Handler: h, + // No read or write deadline: a push sends an entire packfile in one + // request and a clone streams one back. + ReadHeaderTimeout: 30 * time.Second, + IdleTimeout: 2 * time.Minute, + } + + ln, err := net.Listen("tcp", cfg.Listen) + if err != nil { + return err + } + log.Printf("listening on %s", ln.Addr()) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + errc := make(chan error, 1) + go func() { errc <- srv.Serve(ln) }() + + select { + case err := <-errc: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + case <-ctx.Done(): + stop() // a second signal now kills a drain that will not finish + log.Printf("draining") + sctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return srv.Shutdown(sctx) + } +} + +func healthz(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + io.WriteString(w, "ok\n") +} + +// logRequests writes one line per request to stdout. +func (f *Forge) logRequests(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + lw := &logWriter{ResponseWriter: w} + h.ServeHTTP(lw, r) + if lw.status == 0 { + lw.status = http.StatusOK + } + // No query string: reset links and OIDC codes arrive in one. Quoted, + // so a newline in the path cannot write the next line. + log.Printf("%s %s %q %d %d %dms", f.peer(r), r.Method, r.URL.Path, + lw.status, lw.bytes, time.Since(start).Milliseconds()) + }) +} + +// peer is the address rate limits count against. The header is read only when +// named, and only as one parsed address: anything else would be a key anybody +// could invent. +func (f *Forge) peer(r *http.Request) string { + if name := f.cfg.ClientIPHeader; name != "" { + if ip := net.ParseIP(strings.TrimSpace(r.Header.Get(name))); ip != nil { + return ip.String() + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +type logWriter struct { + http.ResponseWriter + status int + bytes int +} + +func (w *logWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} + +func (w *logWriter) Write(b []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + n, err := w.ResponseWriter.Write(b) + w.bytes += n + return n, err +} -- cgit v1.2.3