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/hook/hook.go +145

@@ -0,0 +1,145 @@
1 // Package hook enforces policy on the server side. It runs as pre-receive, so a
2 // rejected push moves no refs at all: the decision is per-push, never per-ref.
3 package hook
4
5 import (
6 "bufio"
7 "fmt"
8 "io"
9 "path/filepath"
10 "strings"
11
12 "github.com/rodent-software/vole/internal/acl"
13 "github.com/rodent-software/vole/internal/gitx"
14 "github.com/rodent-software/vole/internal/repo"
15 )
16
17 type Config struct {
18 RepoDir string
19 Actor string
20 MaxBlobBytes int64
21 Out io.Writer
22 In io.Reader
23 }
24
25 type change struct {
26 old, new, ref string
27 }
28
29 // PreReceive returns a process exit code: 0 allows the push, 1 rejects it.
30 func PreReceive(cfg Config) int {
31 if cfg.Out == nil {
32 cfg.Out = io.Discard
33 }
34 if cfg.In == nil {
35 cfg.In = strings.NewReader("")
36 }
37 m := &repo.Meta{Name: filepath.Base(cfg.RepoDir), Dir: cfg.RepoDir}
38 a, err := m.ACL()
39 if err != nil {
40 reject(cfg.Out, "cannot read repository access rules: %v", err)
41 return 1
42 }
43
44 actor := strings.TrimSpace(cfg.Actor)
45 if actor == "" {
46 reject(cfg.Out, "pushes must be authenticated; no user was supplied")
47 return 1
48 }
49 role := a.RoleFor(actor)
50 if !role.AtLeast(acl.Write) {
51 reject(cfg.Out, "%s does not have write access to %s (role: %s)", actor, m.Name, role)
52 return 1
53 }
54
55 changes, err := readChanges(cfg.In)
56 if err != nil {
57 reject(cfg.Out, "cannot read ref updates: %v", err)
58 return 1
59 }
60 if len(changes) == 0 {
61 return 0
62 }
63
64 g := gitx.Open(cfg.RepoDir)
65 var problems []string
66 for _, c := range changes {
67 // Metadata refs belong to the server. Letting a client write refs/vole/*
68 // would let them grant themselves access by pushing a new acl.json.
69 if strings.HasPrefix(c.ref, "refs/vole/") {
70 problems = append(problems, fmt.Sprintf(
71 "%s is server-owned and cannot be pushed to", c.ref))
72 continue
73 }
74 if !strings.HasPrefix(c.ref, "refs/heads/") && !strings.HasPrefix(c.ref, "refs/tags/") {
75 problems = append(problems, fmt.Sprintf(
76 "%s: only refs/heads/* and refs/tags/* may be pushed", c.ref))
77 continue
78 }
79 if a.IsProtected(c.ref) && !role.AtLeast(acl.Admin) {
80 problems = append(problems, fmt.Sprintf(
81 "%s is protected: %s may not push it directly (role: %s). Open a change request instead.",
82 c.ref, actor, role))
83 continue
84 }
85 if cfg.MaxBlobBytes > 0 && c.new != gitx.ZeroSHA {
86 overs, err := oversizedBlobs(g, c, cfg.MaxBlobBytes)
87 if err != nil {
88 problems = append(problems, fmt.Sprintf("%s: cannot check object sizes: %v", c.ref, err))
89 continue
90 }
91 problems = append(problems, overs...)
92 }
93 }
94
95 if len(problems) == 0 {
96 return 0
97 }
98 reject(cfg.Out, "push rejected (%d problem(s)):", len(problems))
99 for _, p := range problems {
100 fmt.Fprintf(cfg.Out, "vole: %s\n", p)
101 }
102 return 1
103 }
104
105 func oversizedBlobs(g *gitx.Repo, c change, max int64) ([]string, error) {
106 sizes, err := g.NewBlobSizes(c.old, c.new)
107 if err != nil {
108 return nil, err
109 }
110 var out []string
111 for sha, n := range sizes {
112 if n <= max {
113 continue
114 }
115 out = append(out, fmt.Sprintf(
116 "object %s is %d bytes, over the %d byte limit (consider git-lfs)", sha[:12], n, max))
117 if len(out) >= 5 {
118 out = append(out, "further oversized objects not listed")
119 break
120 }
121 }
122 return out, nil
123 }
124
125 func readChanges(r io.Reader) ([]change, error) {
126 var out []change
127 sc := bufio.NewScanner(r)
128 sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
129 for sc.Scan() {
130 line := strings.TrimSpace(sc.Text())
131 if line == "" {
132 continue
133 }
134 f := strings.Fields(line)
135 if len(f) != 3 {
136 return nil, fmt.Errorf("malformed ref update %q", line)
137 }
138 out = append(out, change{old: f[0], new: f[1], ref: f[2]})
139 }
140 return out, sc.Err()
141 }
142
143 func reject(w io.Writer, format string, args ...any) {
144 fmt.Fprintf(w, "vole: "+format+"\n", args...)
145 }