// 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 }