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