Migration
This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs.
Contact me
main.go
main
· 10.1 KB · 294 lines
raw
| 1 | // Command gsg serves a modular git browser. |
| 2 | // |
| 3 | // gsg -root /srv/git -addr :8080 -name "rschanz.org" |
| 4 | // gsg -root /srv/git -templates ./templates # hot-reload the theme |
| 5 | // gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081 |
| 6 | // gsg -root /srv/git -admin-addr 127.0.0.1:8081 # login only, no notices |
| 7 | // gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081 -admin-group gsg-admins |
| 8 | // |
| 9 | // Per-repository settings live in each repo's own git config, cgit-style: |
| 10 | // |
| 11 | // git --git-dir=/srv/git/foo.git config gsg.title "foo" |
| 12 | // git --git-dir=/srv/git/foo.git config gsg.description "a thing" |
| 13 | // git --git-dir=/srv/git/foo.git config gsg.hide true |
| 14 | // git --git-dir=/srv/git/foo.git config gsg.visible-to "alice,bob" |
| 15 | // git --git-dir=/srv/git/foo.git config gsg.gpgkeyring /srv/git/foo-keys.asc |
| 16 | package main |
| 17 | |
| 18 | import ( |
| 19 | "embed" |
| 20 | "flag" |
| 21 | "fmt" |
| 22 | "io/fs" |
| 23 | "log" |
| 24 | "log/slog" |
| 25 | "net" |
| 26 | "net/http" |
| 27 | "os" |
| 28 | "time" |
| 29 | |
| 30 | "gsg/internal/authn" |
| 31 | "gsg/internal/core" |
| 32 | _ "gsg/internal/modules" // registers the built-in modules |
| 33 | "gsg/internal/notices" |
| 34 | "gsg/internal/session" |
| 35 | "gsg/internal/share" |
| 36 | "gsg/internal/site" |
| 37 | ) |
| 38 | |
| 39 | //go:embed templates |
| 40 | var templateFS embed.FS |
| 41 | |
| 42 | //go:embed static |
| 43 | var staticFS embed.FS |
| 44 | |
| 45 | type options struct { |
| 46 | root, addr, name, base, tmplDir string |
| 47 | noticesPath, adminAddr string |
| 48 | adminAllowRemote bool |
| 49 | adminGroupHeader, adminGroup string |
| 50 | cacheSize int |
| 51 | timeout time.Duration |
| 52 | } |
| 53 | |
| 54 | func main() { |
| 55 | var o options |
| 56 | flag.StringVar(&o.root, "root", "/srv/git", "directory containing bare repositories") |
| 57 | flag.StringVar(&o.addr, "addr", ":8080", "listen address") |
| 58 | flag.StringVar(&o.name, "name", "git", "site name") |
| 59 | flag.StringVar(&o.base, "base", "", "base URL") |
| 60 | flag.StringVar(&o.tmplDir, "templates", "", "serve templates from this directory, re-parsed per request") |
| 61 | flag.StringVar(&o.noticesPath, "notices", "", "YAML file of site and per-repo notices (empty disables the feature)") |
| 62 | flag.StringVar(&o.adminAddr, "admin-addr", "", "listen address for the notices admin UI (empty disables it; use loopback unless behind a container network)") |
| 63 | flag.BoolVar(&o.adminAllowRemote, "admin-allow-remote", false, "permit -admin-addr to bind a non-loopback address (required in containers)") |
| 64 | 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") |
| 65 | flag.StringVar(&o.adminGroupHeader, "admin-group-header", "X-Authentik-Groups", "header carrying the caller's \"|\"-delimited groups, checked against -admin-group") |
| 66 | flag.IntVar(&o.cacheSize, "cache", 4096, "entries per cache (module results and the anonymous response cache each get their own, sized identically)") |
| 67 | flag.DurationVar(&o.timeout, "timeout", 10*time.Second, "per-request git budget") |
| 68 | flag.Parse() |
| 69 | |
| 70 | if err := run(o); err != nil { |
| 71 | log.Fatalf("gsg: %v", err) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | func run(o options) error { |
| 76 | siteCfg := &core.Site{Name: o.name, BaseURL: o.base, Root: o.root} |
| 77 | |
| 78 | var nstore *notices.Store |
| 79 | if o.noticesPath != "" { |
| 80 | var err error |
| 81 | nstore, err = notices.Open(o.noticesPath) |
| 82 | if err != nil { |
| 83 | return err |
| 84 | } |
| 85 | defer nstore.Close() |
| 86 | notices.SetDefault(nstore) |
| 87 | // The site-level analogue of RepoInfo.GPGKeyring: notices can |
| 88 | // change without a commit moving, so the revision has to be in |
| 89 | // every module cache key and ETag (see Ctx.contentIdentity). |
| 90 | siteCfg.RevFn = nstore.Revision |
| 91 | } |
| 92 | if err := core.Start(siteCfg); err != nil { |
| 93 | return err |
| 94 | } |
| 95 | |
| 96 | // One in-memory secret for the whole process: it signs the gsg_user |
| 97 | // login cookie minted on the admin listener and verified on the public |
| 98 | // one. A restart invalidates every outstanding session -- acceptable |
| 99 | // for a login this lightweight. |
| 100 | sessions := session.NewManager() |
| 101 | authnHandlers, err := authn.NewHandlers(authn.Config{ |
| 102 | Sessions: sessions, |
| 103 | UserHeader: "X-Authentik-Username", |
| 104 | }) |
| 105 | if err != nil { |
| 106 | return err |
| 107 | } |
| 108 | |
| 109 | var tfs fs.FS |
| 110 | if o.tmplDir != "" { |
| 111 | tfs = os.DirFS(o.tmplDir) |
| 112 | } else if tfs, err = fs.Sub(templateFS, "templates"); err != nil { |
| 113 | return err |
| 114 | } |
| 115 | |
| 116 | srv, err := core.NewServer(siteCfg, site.Routes(), tfs, o.cacheSize) |
| 117 | if err != nil { |
| 118 | return err |
| 119 | } |
| 120 | srv.TemplateDir = o.tmplDir |
| 121 | srv.Timeout = o.timeout |
| 122 | srv.Sessions = sessions |
| 123 | |
| 124 | static, err := fs.Sub(staticFS, "static") |
| 125 | if err != nil { |
| 126 | return err |
| 127 | } |
| 128 | // Content hash of the embedded assets, not a build timestamp, so it's |
| 129 | // stable across restarts that don't change any file and only changes |
| 130 | // when one actually does -- templates use it (via the `static` funcmap |
| 131 | // entry) to mint cache-busting URLs. |
| 132 | staticTag, err := core.HashFS(static) |
| 133 | if err != nil { |
| 134 | return err |
| 135 | } |
| 136 | srv.StaticTag = staticTag |
| 137 | |
| 138 | mux := http.NewServeMux() |
| 139 | mux.Handle("/static/", cachedStatic(static)) |
| 140 | // Logout only clears gsg's own cookie -- it needs no round trip through |
| 141 | // Authentik, so it lives on the public listener, not behind |
| 142 | // forward_auth like /auth/login. It's hand-mounted here rather than |
| 143 | // through site.Routes() because core.Route's Renderer always builds a |
| 144 | // body from a Ctx, and a redirect-plus-Set-Cookie doesn't fit that shape. |
| 145 | mux.Handle("GET /auth/logout", authnHandlers.Logout) |
| 146 | |
| 147 | // Share links: minting authorizes off the gsg_user cookie, which only |
| 148 | // this (public) listener ever verifies -- the admin listener's whole |
| 149 | // reason to exist is trusting X-Authentik-Username, not browser |
| 150 | // cookies, so mixing the two would blur that boundary for no benefit. |
| 151 | // Redemption is by definition unauthenticated public traffic. |
| 152 | shareHandler, err := share.NewHandler(share.HandlerConfig{ |
| 153 | Sessions: sessions, |
| 154 | VisibleHiddenRepos: func(user string) []share.RepoOption { |
| 155 | var out []share.RepoOption |
| 156 | for _, h := range siteCfg.RepoList() { |
| 157 | if h.Info.AccessPolicy().CanShare(user) { |
| 158 | out = append(out, share.RepoOption{ |
| 159 | Slug: h.Info.Slug, |
| 160 | Title: h.Info.Title, |
| 161 | MaxAge: h.Info.ShareMaxAge, |
| 162 | }) |
| 163 | } |
| 164 | } |
| 165 | return out |
| 166 | }, |
| 167 | RevokedAfter: func(slug string) (time.Time, bool) { |
| 168 | h, ok := siteCfg.Lookup(slug) |
| 169 | if !ok { |
| 170 | return time.Time{}, false |
| 171 | } |
| 172 | return h.Info.ShareRevokedAfter, true |
| 173 | }, |
| 174 | SiteName: o.name, |
| 175 | Logger: slog.Default(), |
| 176 | }) |
| 177 | if err != nil { |
| 178 | return err |
| 179 | } |
| 180 | mux.Handle("/share/", shareHandler) |
| 181 | |
| 182 | mux.Handle("/", srv.Handler()) |
| 183 | |
| 184 | servers := []*http.Server{{ |
| 185 | Addr: o.addr, |
| 186 | Handler: mux, |
| 187 | ReadHeaderTimeout: 5 * time.Second, |
| 188 | WriteTimeout: o.timeout + 5*time.Second, |
| 189 | }} |
| 190 | |
| 191 | if o.adminAddr != "" { |
| 192 | // The public mux stays strictly read-only. Anything that trusts |
| 193 | // X-Authentik-Username -- notices editing, login -- lives on this |
| 194 | // separate listener so a misconfigured Caddy forward_auth cannot |
| 195 | // expose it on the public port. |
| 196 | if !o.adminAllowRemote && !isLoopbackAddr(o.adminAddr) { |
| 197 | return fmt.Errorf("-admin-addr %q is not loopback; pass -admin-allow-remote to allow it", o.adminAddr) |
| 198 | } |
| 199 | amux := http.NewServeMux() |
| 200 | // Serve /static/ on the admin listener too. Behind Caddy the admin page |
| 201 | // is same-origin and its /static/ requests go to the public listener, |
| 202 | // but hitting the admin port directly (dev, curl) would otherwise 404 |
| 203 | // every stylesheet. Static assets are public either way. |
| 204 | amux.Handle("/static/", cachedStatic(static)) |
| 205 | |
| 206 | // /auth/login: reachable only through the same Caddy forward_auth |
| 207 | // block as /admin/*, so by the time a request lands here, |
| 208 | // X-Authentik-Username is already set and trustworthy. There is no |
| 209 | // separate /auth/callback -- one hop does the whole job. There is |
| 210 | // also no link to this URL anywhere in the UI; people who need it |
| 211 | // are told out of band. |
| 212 | amux.Handle("GET /auth/login", authnHandlers.Login) |
| 213 | |
| 214 | // Notices editing is a separate opt-in on top of the admin |
| 215 | // listener: -admin-addr alone is enough to enable login. |
| 216 | if nstore != nil { |
| 217 | repos := make([]string, 0) |
| 218 | for _, h := range siteCfg.RepoList() { |
| 219 | if !h.Info.Hidden { |
| 220 | repos = append(repos, h.Info.Slug) |
| 221 | } |
| 222 | } |
| 223 | adminHandler, err := notices.NewHandler(notices.HandlerConfig{ |
| 224 | Store: nstore, |
| 225 | Repos: repos, |
| 226 | SiteName: o.name, |
| 227 | UserHeader: "X-Authentik-Username", |
| 228 | GroupHeader: o.adminGroupHeader, |
| 229 | RequiredGroup: o.adminGroup, |
| 230 | Logger: slog.Default(), |
| 231 | }) |
| 232 | if err != nil { |
| 233 | return err |
| 234 | } |
| 235 | amux.Handle("/admin/", adminHandler) |
| 236 | } |
| 237 | |
| 238 | servers = append(servers, &http.Server{ |
| 239 | Addr: o.adminAddr, |
| 240 | Handler: amux, |
| 241 | ReadHeaderTimeout: 5 * time.Second, |
| 242 | ReadTimeout: 15 * time.Second, |
| 243 | WriteTimeout: 15 * time.Second, |
| 244 | }) |
| 245 | slog.Info("gsg admin listening", "addr", o.adminAddr) |
| 246 | } |
| 247 | |
| 248 | slog.Info("gsg listening", |
| 249 | "addr", o.addr, "repos", len(siteCfg.RepoList()), |
| 250 | "routes", len(site.Routes()), "modules", len(core.Names())) |
| 251 | |
| 252 | errc := make(chan error, len(servers)) |
| 253 | for _, s := range servers { |
| 254 | s := s |
| 255 | go func() { errc <- s.ListenAndServe() }() |
| 256 | } |
| 257 | return <-errc |
| 258 | } |
| 259 | |
| 260 | // cachedStatic serves fsys with a Cache-Control that depends on whether the |
| 261 | // request carries the `v` cache-busting query param the `static` template |
| 262 | // func mints (see internal/core/funcs.go). Requests that carry it are |
| 263 | // addressed by content hash, so they're frozen for a year; everything else |
| 264 | // -- share/notices/error-page assets, which don't go through that funcmap |
| 265 | // and so never get a `v` -- still gets a real improvement over no |
| 266 | // Cache-Control at all, just a short one, in case a template edit changes |
| 267 | // what a bare URL should serve. |
| 268 | func cachedStatic(fsys fs.FS) http.Handler { |
| 269 | fileServer := http.FileServer(http.FS(fsys)) |
| 270 | h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 271 | if r.URL.Query().Get("v") != "" { |
| 272 | w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
| 273 | } else { |
| 274 | w.Header().Set("Cache-Control", "public, max-age=600") |
| 275 | } |
| 276 | fileServer.ServeHTTP(w, r) |
| 277 | }) |
| 278 | return http.StripPrefix("/static/", h) |
| 279 | } |
| 280 | |
| 281 | // isLoopbackAddr is a blunt guard, not a security boundary: it stops the |
| 282 | // admin listener from silently binding 0.0.0.0 outside a deliberate opt-in. |
| 283 | func isLoopbackAddr(addr string) bool { |
| 284 | host, _, err := net.SplitHostPort(addr) |
| 285 | if err != nil { |
| 286 | host = addr |
| 287 | } |
| 288 | switch host { |
| 289 | case "127.0.0.1", "::1", "localhost": |
| 290 | return true |
| 291 | default: |
| 292 | return false |
| 293 | } |
| 294 | } |