Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 1: a working git remote with access control

653e0cfc227c992e5b26ea10057aa0b424b6ce4f · Keenan Nemetz · 18d ago · parent c5626f1b

Phase 1: a working git remote with access control

Vole is now a functioning forge foundation: one CGO-free binary that serves
real git over smart HTTP and enforces per-repo policy in pre-receive.

What is in:

- internal/transport drives `git http-backend` as a CGI subprocess. Chunked
  uploads are spooled to learn CONTENT_LENGTH (git's default client config
  produces them), spool files are cleaned on error and abort, and a janitor
  sweeps anything a crash left behind. Pushes are capped by size and
  serialised behind a semaphore, because Phase 0 measured 1.13 GB of git RSS
  for a single 1 GiB push.
- internal/acl stores roles on refs/vole/acl inside each repository, so
  access rules travel with a clone. One evaluation function is shared by the
  hook and the web layer.
- internal/hook enforces policy in pre-receive: authentication, write role,
  protected refs, per-blob size limits, and a refusal to accept pushes to
  refs/vole/* - without that last check a writer could push their own
  acl.json and grant themselves admin.
- internal/auth holds users, personal access tokens and web sessions in
  SQLite. Passwords use PBKDF2-SHA256; tokens are 256-bit random values and
  are stored as SHA-256 hashes, since there is no low-entropy secret to grind.
- internal/index is the derived read model, rebuildable from disk and refs.
- internal/repo owns the bare-repo store and the metadata ref CAS helper.
- internal/web is the browse surface: repo list, tree, blob, raw, history,
  login, and per-repo settings for access rules, rename and delete.
- internal/signing creates the instance ed25519 key at first boot for the
  Phase 7 work.

Every Phase 1 exit criterion has a test that would fail if it regressed:
clone/push against a real listener, read-only and anonymous rejection with the
ref proven unmoved, rebuild-from-refs compared against the actual acl ref, a
spool file proven absent after an aborted chunked upload, and two pushes
completing through a single-slot semaphore. scripts/ci.sh runs gofmt, vet,
build, a markdown link check and the race-enabled suite.

Verified by hand as well: a clean `vole init` -> user -> token -> push ->
clone cycle, web login and browse, and a 256 MiB chunked push killed
mid-flight leaving no ref, no spool file and no orphaned git processes.

internal/server/server.go +179

@@ -0,0 +1,179 @@
1 // Package server wires the components together and owns the HTTP surface.
2 package server
3
4 import (
5 "fmt"
6 "log/slog"
7 "net/http"
8 "os"
9
10 "github.com/rodent-software/vole/internal/auth"
11 "github.com/rodent-software/vole/internal/config"
12 "github.com/rodent-software/vole/internal/index"
13 "github.com/rodent-software/vole/internal/repo"
14 "github.com/rodent-software/vole/internal/signing"
15 "github.com/rodent-software/vole/internal/transport"
16 "github.com/rodent-software/vole/internal/web"
17 )
18
19 type Server struct {
20 Cfg *config.Config
21 Repos *repo.Store
22 Auth *auth.Store
23 Index *index.Store
24 Proxy *transport.Proxy
25 Key *signing.Key
26 Web *web.Web
27 Log *slog.Logger
28 mux *http.ServeMux
29 }
30
31 func New(cfg *config.Config, binPath string, log *slog.Logger) (*Server, error) {
32 if log == nil {
33 log = slog.Default()
34 }
35 for _, d := range []string{cfg.ReposDir(), cfg.SpoolDir()} {
36 if err := os.MkdirAll(d, 0o755); err != nil {
37 return nil, err
38 }
39 }
40 as, err := auth.Open(cfg.AuthDB())
41 if err != nil {
42 return nil, fmt.Errorf("open auth db: %w", err)
43 }
44 if err := as.PruneSessions(); err != nil {
45 log.Warn("prune sessions", "err", err)
46 }
47 ix, err := index.Open(cfg.IndexDB())
48 if err != nil {
49 as.Close()
50 return nil, fmt.Errorf("open index: %w", err)
51 }
52 rs := repo.New(cfg.ReposDir(), binPath)
53 key, err := signing.LoadOrCreate(cfg.InstanceKey())
54 if err != nil {
55 return nil, fmt.Errorf("instance key: %w", err)
56 }
57 if n, err := rs.InstallAllHooks(); err != nil {
58 log.Warn("install hooks", "err", err)
59 } else {
60 log.Info("hooks installed", "repos", n)
61 }
62
63 proxy := transport.New(transport.Config{
64 Root: cfg.ReposDir(),
65 Open: rs.Open,
66 Actor: actorResolver(as),
67 MaxPushBytes: cfg.MaxPushBytes,
68 MaxBlobBytes: cfg.MaxBlobBytes,
69 SpoolDir: cfg.SpoolDir(),
70 PushConcurrency: cfg.PushConcurrency,
71 QueueTimeout: cfg.QueueTimeoutDur,
72 Log: log,
73 })
74
75 wp, err := web.New(rs, as, ix, cfg)
76 if err != nil {
77 return nil, err
78 }
79 wp.Logger = func(format string, args ...any) { log.Warn(fmt.Sprintf(format, args...)) }
80
81 s := &Server{Cfg: cfg, Repos: rs, Auth: as, Index: ix, Proxy: proxy, Key: key, Web: wp, Log: log}
82 s.mux = s.routes()
83 return s, nil
84 }
85
86 // actorResolver turns a Basic-auth pair into a username. A token may arrive as
87 // the password (the usual case) or as the username with an empty password,
88 // which some clients do.
89 func actorResolver(as *auth.Store) func(user, pass string) (string, error) {
90 return func(user, pass string) (string, error) {
91 switch {
92 case len(user) > 5 && user[:5] == "vole_" && pass == "":
93 u, _, err := as.AuthenticateToken(user)
94 if err != nil {
95 return "", err
96 }
97 return u.Username, nil
98 case len(pass) > 5 && pass[:5] == "vole_":
99 u, _, err := as.AuthenticateToken(pass)
100 if err != nil {
101 return "", err
102 }
103 return u.Username, nil
104 default:
105 u, err := as.Authenticate(user, pass)
106 if err != nil {
107 return "", err
108 }
109 return u.Username, nil
110 }
111 }
112 }
113
114 func (s *Server) routes() *http.ServeMux {
115 mux := http.NewServeMux()
116 w := s.Web
117
118 mux.HandleFunc("GET /{$}", w.IndexPage)
119 mux.HandleFunc("GET /login", w.LoginPage)
120 mux.HandleFunc("POST /login", w.Login)
121 mux.HandleFunc("POST /logout", w.Logout)
122
123 mux.HandleFunc("POST /r/new", w.NewRepo)
124 mux.HandleFunc("GET /r/{name}", w.Repo)
125 mux.HandleFunc("GET /r/{name}/tree/{ref}/{path...}", w.Tree)
126 mux.HandleFunc("GET /r/{name}/blob/{ref}/{path...}", w.Blob)
127 mux.HandleFunc("GET /r/{name}/raw/{ref}/{path...}", w.Raw)
128 mux.HandleFunc("GET /r/{name}/commits/{ref}", w.Commits)
129 mux.HandleFunc("GET /r/{name}/settings", w.Settings)
130 mux.HandleFunc("POST /r/{name}/acl", w.UpdateACL)
131 mux.HandleFunc("POST /r/{name}/rename", w.RenameRepo)
132 mux.HandleFunc("POST /r/{name}/delete", w.DeleteRepo)
133
134 mux.HandleFunc("GET /healthz", func(rw http.ResponseWriter, r *http.Request) {
135 rw.Header().Set("Content-Type", "text/plain")
136 fmt.Fprintf(rw, "ok\nkey=%s\n", s.Key.Fingerprint())
137 })
138
139 // Git transport. Registered without a method so git's POSTs reach it.
140 mux.Handle("/git/", s.Proxy)
141
142 return mux
143 }
144
145 func (s *Server) Handler() http.Handler {
146 return logging(s.Log, s.mux)
147 }
148
149 func (s *Server) Close() error {
150 var first error
151 if s.Auth != nil {
152 if err := s.Auth.Close(); err != nil {
153 first = err
154 }
155 }
156 if s.Index != nil {
157 if err := s.Index.Close(); err != nil && first == nil {
158 first = err
159 }
160 }
161 return first
162 }
163
164 // RebuildIndex reconstructs the derived index from the repo store.
165 func (s *Server) RebuildIndex() (int, error) { return s.Index.Rebuild(s.Repos) }
166
167 func logging(log *slog.Logger, next http.Handler) http.Handler {
168 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
169 // Git is chatty; only log the interesting requests.
170 if !isGitNoise(r) {
171 log.Debug("request", "method", r.Method, "path", r.URL.Path)
172 }
173 next.ServeHTTP(w, r)
174 })
175 }
176
177 func isGitNoise(r *http.Request) bool {
178 return len(r.URL.Path) > 5 && r.URL.Path[:5] == "/git/"
179 }