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

main.go

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