// Command gsg serves a modular git browser. // // gsg -root /srv/git -addr :8080 -name "rschanz.org" // gsg -root /srv/git -templates ./templates # hot-reload the theme // gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081 // gsg -root /srv/git -admin-addr 127.0.0.1:8081 # login only, no notices // gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081 -admin-group gsg-admins // // Per-repository settings live in each repo's own git config, cgit-style: // // git --git-dir=/srv/git/foo.git config gsg.title "foo" // git --git-dir=/srv/git/foo.git config gsg.description "a thing" // git --git-dir=/srv/git/foo.git config gsg.hide true // git --git-dir=/srv/git/foo.git config gsg.visible-to "alice,bob" // git --git-dir=/srv/git/foo.git config gsg.gpgkeyring /srv/git/foo-keys.asc package main import ( "embed" "flag" "fmt" "io/fs" "log" "log/slog" "net" "net/http" "os" "time" "gsg/internal/authn" "gsg/internal/core" _ "gsg/internal/modules" // registers the built-in modules "gsg/internal/notices" "gsg/internal/session" "gsg/internal/share" "gsg/internal/site" ) //go:embed templates var templateFS embed.FS //go:embed static var staticFS embed.FS type options struct { root, addr, name, base, tmplDir string noticesPath, adminAddr string adminAllowRemote bool adminGroupHeader, adminGroup string cacheSize int timeout time.Duration } func main() { var o options flag.StringVar(&o.root, "root", "/srv/git", "directory containing bare repositories") flag.StringVar(&o.addr, "addr", ":8080", "listen address") flag.StringVar(&o.name, "name", "git", "site name") flag.StringVar(&o.base, "base", "", "base URL") flag.StringVar(&o.tmplDir, "templates", "", "serve templates from this directory, re-parsed per request") flag.StringVar(&o.noticesPath, "notices", "", "YAML file of site and per-repo notices (empty disables the feature)") flag.StringVar(&o.adminAddr, "admin-addr", "", "listen address for the notices admin UI (empty disables it; use loopback unless behind a container network)") flag.BoolVar(&o.adminAllowRemote, "admin-allow-remote", false, "permit -admin-addr to bind a non-loopback address (required in containers)") flag.StringVar(&o.adminGroup, "admin-group", "", "group required (via -admin-group-header) to reach the notices admin UI; empty allows anyone the reverse proxy lets through") flag.StringVar(&o.adminGroupHeader, "admin-group-header", "X-Authentik-Groups", "header carrying the caller's \"|\"-delimited groups, checked against -admin-group") flag.IntVar(&o.cacheSize, "cache", 4096, "entries per cache (module results and the anonymous response cache each get their own, sized identically)") flag.DurationVar(&o.timeout, "timeout", 10*time.Second, "per-request git budget") flag.Parse() if err := run(o); err != nil { log.Fatalf("gsg: %v", err) } } func run(o options) error { siteCfg := &core.Site{Name: o.name, BaseURL: o.base, Root: o.root} var nstore *notices.Store if o.noticesPath != "" { var err error nstore, err = notices.Open(o.noticesPath) if err != nil { return err } defer nstore.Close() notices.SetDefault(nstore) // The site-level analogue of RepoInfo.GPGKeyring: notices can // change without a commit moving, so the revision has to be in // every module cache key and ETag (see Ctx.contentIdentity). siteCfg.RevFn = nstore.Revision } if err := core.Start(siteCfg); err != nil { return err } // One in-memory secret for the whole process: it signs the gsg_user // login cookie minted on the admin listener and verified on the public // one. A restart invalidates every outstanding session -- acceptable // for a login this lightweight. sessions := session.NewManager() authnHandlers, err := authn.NewHandlers(authn.Config{ Sessions: sessions, UserHeader: "X-Authentik-Username", }) if err != nil { return err } var tfs fs.FS if o.tmplDir != "" { tfs = os.DirFS(o.tmplDir) } else if tfs, err = fs.Sub(templateFS, "templates"); err != nil { return err } srv, err := core.NewServer(siteCfg, site.Routes(), tfs, o.cacheSize) if err != nil { return err } srv.TemplateDir = o.tmplDir srv.Timeout = o.timeout srv.Sessions = sessions static, err := fs.Sub(staticFS, "static") if err != nil { return err } // Content hash of the embedded assets, not a build timestamp, so it's // stable across restarts that don't change any file and only changes // when one actually does -- templates use it (via the `static` funcmap // entry) to mint cache-busting URLs. staticTag, err := core.HashFS(static) if err != nil { return err } srv.StaticTag = staticTag mux := http.NewServeMux() mux.Handle("/static/", cachedStatic(static)) // Logout only clears gsg's own cookie -- it needs no round trip through // Authentik, so it lives on the public listener, not behind // forward_auth like /auth/login. It's hand-mounted here rather than // through site.Routes() because core.Route's Renderer always builds a // body from a Ctx, and a redirect-plus-Set-Cookie doesn't fit that shape. mux.Handle("GET /auth/logout", authnHandlers.Logout) // Share links: minting authorizes off the gsg_user cookie, which only // this (public) listener ever verifies -- the admin listener's whole // reason to exist is trusting X-Authentik-Username, not browser // cookies, so mixing the two would blur that boundary for no benefit. // Redemption is by definition unauthenticated public traffic. shareHandler, err := share.NewHandler(share.HandlerConfig{ Sessions: sessions, VisibleHiddenRepos: func(user string) []share.RepoOption { var out []share.RepoOption for _, h := range siteCfg.RepoList() { if h.Info.AccessPolicy().CanShare(user) { out = append(out, share.RepoOption{ Slug: h.Info.Slug, Title: h.Info.Title, MaxAge: h.Info.ShareMaxAge, }) } } return out }, RevokedAfter: func(slug string) (time.Time, bool) { h, ok := siteCfg.Lookup(slug) if !ok { return time.Time{}, false } return h.Info.ShareRevokedAfter, true }, SiteName: o.name, Logger: slog.Default(), }) if err != nil { return err } mux.Handle("/share/", shareHandler) mux.Handle("/", srv.Handler()) servers := []*http.Server{{ Addr: o.addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, WriteTimeout: o.timeout + 5*time.Second, }} if o.adminAddr != "" { // The public mux stays strictly read-only. Anything that trusts // X-Authentik-Username -- notices editing, login -- lives on this // separate listener so a misconfigured Caddy forward_auth cannot // expose it on the public port. if !o.adminAllowRemote && !isLoopbackAddr(o.adminAddr) { return fmt.Errorf("-admin-addr %q is not loopback; pass -admin-allow-remote to allow it", o.adminAddr) } amux := http.NewServeMux() // Serve /static/ on the admin listener too. Behind Caddy the admin page // is same-origin and its /static/ requests go to the public listener, // but hitting the admin port directly (dev, curl) would otherwise 404 // every stylesheet. Static assets are public either way. amux.Handle("/static/", cachedStatic(static)) // /auth/login: reachable only through the same Caddy forward_auth // block as /admin/*, so by the time a request lands here, // X-Authentik-Username is already set and trustworthy. There is no // separate /auth/callback -- one hop does the whole job. There is // also no link to this URL anywhere in the UI; people who need it // are told out of band. amux.Handle("GET /auth/login", authnHandlers.Login) // Notices editing is a separate opt-in on top of the admin // listener: -admin-addr alone is enough to enable login. if nstore != nil { repos := make([]string, 0) for _, h := range siteCfg.RepoList() { if !h.Info.Hidden { repos = append(repos, h.Info.Slug) } } adminHandler, err := notices.NewHandler(notices.HandlerConfig{ Store: nstore, Repos: repos, SiteName: o.name, UserHeader: "X-Authentik-Username", GroupHeader: o.adminGroupHeader, RequiredGroup: o.adminGroup, Logger: slog.Default(), }) if err != nil { return err } amux.Handle("/admin/", adminHandler) } servers = append(servers, &http.Server{ Addr: o.adminAddr, Handler: amux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, }) slog.Info("gsg admin listening", "addr", o.adminAddr) } slog.Info("gsg listening", "addr", o.addr, "repos", len(siteCfg.RepoList()), "routes", len(site.Routes()), "modules", len(core.Names())) errc := make(chan error, len(servers)) for _, s := range servers { s := s go func() { errc <- s.ListenAndServe() }() } return <-errc } // cachedStatic serves fsys with a Cache-Control that depends on whether the // request carries the `v` cache-busting query param the `static` template // func mints (see internal/core/funcs.go). Requests that carry it are // addressed by content hash, so they're frozen for a year; everything else // -- share/notices/error-page assets, which don't go through that funcmap // and so never get a `v` -- still gets a real improvement over no // Cache-Control at all, just a short one, in case a template edit changes // what a bare URL should serve. func cachedStatic(fsys fs.FS) http.Handler { fileServer := http.FileServer(http.FS(fsys)) h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("v") != "" { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "public, max-age=600") } fileServer.ServeHTTP(w, r) }) return http.StripPrefix("/static/", h) } // isLoopbackAddr is a blunt guard, not a security boundary: it stops the // admin listener from silently binding 0.0.0.0 outside a deliberate opt-in. func isLoopbackAddr(addr string) bool { host, _, err := net.SplitHostPort(addr) if err != nil { host = addr } switch host { case "127.0.0.1", "::1", "localhost": return true default: return false } }