// 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 // // 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.gpgkeyring /srv/git/foo-keys.asc package main import ( "embed" "flag" "fmt" "io/fs" "log" "log/slog" "net" "net/http" "os" "time" "gsg/internal/core" _ "gsg/internal/modules" // registers the built-in modules "gsg/internal/notices" "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 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.IntVar(&o.cacheSize, "cache", 4096, "module cache entries") 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 o.adminAddr != "" && nstore == nil { return fmt.Errorf("-admin-addr requires -notices") } if err := core.Start(siteCfg); err != nil { return err } var tfs fs.FS var err error 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 static, err := fs.Sub(staticFS, "static") if err != nil { return err } mux := http.NewServeMux() mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static)))) 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. The admin routes live on // their own listener so a misconfigured Caddy forward_auth cannot // expose a write path 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) } 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", Logger: slog.Default(), }) if err != nil { return err } 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/", http.StripPrefix("/static/", http.FileServer(http.FS(static)))) 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 } // 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 } }