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

main.go

cb5a7c7c656de5a381e0234208c552c0856e43e3 · 8.8 KB · 264 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, "module cache entries")
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
129 mux := http.NewServeMux()
130 mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
131 // Logout only clears gsg's own cookie -- it needs no round trip through
132 // Authentik, so it lives on the public listener, not behind
133 // forward_auth like /auth/login. It's hand-mounted here rather than
134 // through site.Routes() because core.Route's Renderer always builds a
135 // body from a Ctx, and a redirect-plus-Set-Cookie doesn't fit that shape.
136 mux.Handle("GET /auth/logout", authnHandlers.Logout)
137
138 // Share links: minting authorizes off the gsg_user cookie, which only
139 // this (public) listener ever verifies -- the admin listener's whole
140 // reason to exist is trusting X-Authentik-Username, not browser
141 // cookies, so mixing the two would blur that boundary for no benefit.
142 // Redemption is by definition unauthenticated public traffic.
143 shareHandler, err := share.NewHandler(share.HandlerConfig{
144 Sessions: sessions,
145 VisibleHiddenRepos: func(user string) []share.RepoOption {
146 var out []share.RepoOption
147 for _, h := range siteCfg.RepoList() {
148 if h.Info.AccessPolicy().CanShare(user) {
149 out = append(out, share.RepoOption{
150 Slug: h.Info.Slug,
151 Title: h.Info.Title,
152 MaxAge: h.Info.ShareMaxAge,
153 })
154 }
155 }
156 return out
157 },
158 RevokedAfter: func(slug string) (time.Time, bool) {
159 h, ok := siteCfg.Lookup(slug)
160 if !ok {
161 return time.Time{}, false
162 }
163 return h.Info.ShareRevokedAfter, true
164 },
165 SiteName: o.name,
166 Logger: slog.Default(),
167 })
168 if err != nil {
169 return err
170 }
171 mux.Handle("/share/", shareHandler)
172
173 mux.Handle("/", srv.Handler())
174
175 servers := []*http.Server{{
176 Addr: o.addr,
177 Handler: mux,
178 ReadHeaderTimeout: 5 * time.Second,
179 WriteTimeout: o.timeout + 5*time.Second,
180 }}
181
182 if o.adminAddr != "" {
183 // The public mux stays strictly read-only. Anything that trusts
184 // X-Authentik-Username -- notices editing, login -- lives on this
185 // separate listener so a misconfigured Caddy forward_auth cannot
186 // expose it on the public port.
187 if !o.adminAllowRemote && !isLoopbackAddr(o.adminAddr) {
188 return fmt.Errorf("-admin-addr %q is not loopback; pass -admin-allow-remote to allow it", o.adminAddr)
189 }
190 amux := http.NewServeMux()
191 // Serve /static/ on the admin listener too. Behind Caddy the admin page
192 // is same-origin and its /static/ requests go to the public listener,
193 // but hitting the admin port directly (dev, curl) would otherwise 404
194 // every stylesheet. Static assets are public either way.
195 amux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
196
197 // /auth/login: reachable only through the same Caddy forward_auth
198 // block as /admin/*, so by the time a request lands here,
199 // X-Authentik-Username is already set and trustworthy. There is no
200 // separate /auth/callback -- one hop does the whole job. There is
201 // also no link to this URL anywhere in the UI; people who need it
202 // are told out of band.
203 amux.Handle("GET /auth/login", authnHandlers.Login)
204
205 // Notices editing is a separate opt-in on top of the admin
206 // listener: -admin-addr alone is enough to enable login.
207 if nstore != nil {
208 repos := make([]string, 0)
209 for _, h := range siteCfg.RepoList() {
210 if !h.Info.Hidden {
211 repos = append(repos, h.Info.Slug)
212 }
213 }
214 adminHandler, err := notices.NewHandler(notices.HandlerConfig{
215 Store: nstore,
216 Repos: repos,
217 SiteName: o.name,
218 UserHeader: "X-Authentik-Username",
219 GroupHeader: o.adminGroupHeader,
220 RequiredGroup: o.adminGroup,
221 Logger: slog.Default(),
222 })
223 if err != nil {
224 return err
225 }
226 amux.Handle("/admin/", adminHandler)
227 }
228
229 servers = append(servers, &http.Server{
230 Addr: o.adminAddr,
231 Handler: amux,
232 ReadHeaderTimeout: 5 * time.Second,
233 ReadTimeout: 15 * time.Second,
234 WriteTimeout: 15 * time.Second,
235 })
236 slog.Info("gsg admin listening", "addr", o.adminAddr)
237 }
238
239 slog.Info("gsg listening",
240 "addr", o.addr, "repos", len(siteCfg.RepoList()),
241 "routes", len(site.Routes()), "modules", len(core.Names()))
242
243 errc := make(chan error, len(servers))
244 for _, s := range servers {
245 s := s
246 go func() { errc <- s.ListenAndServe() }()
247 }
248 return <-errc
249 }
250
251 // isLoopbackAddr is a blunt guard, not a security boundary: it stops the
252 // admin listener from silently binding 0.0.0.0 outside a deliberate opt-in.
253 func isLoopbackAddr(addr string) bool {
254 host, _, err := net.SplitHostPort(addr)
255 if err != nil {
256 host = addr
257 }
258 switch host {
259 case "127.0.0.1", "::1", "localhost":
260 return true
261 default:
262 return false
263 }
264 }