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

main.go

d658fcc4ea9f3bb562fd00dce8f863934560c56c · 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 }