Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

main.go

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