aboutsummaryrefslogtreecommitdiff
path: root/main.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 /main.go
downloadgit-forge-master.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 'main.go')
-rw-r--r--main.go733
1 files changed, 733 insertions, 0 deletions
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
+}