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