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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software: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/acl/acl.go +151

@@ -0,0 +1,151 @@
1 // Package acl implements Vole's role model. One policy function is used by both
2 // the pre-receive hook and the web handlers so the two can never disagree.
3 package acl
4
5 import (
6 "encoding/json"
7 "fmt"
8 "sort"
9 "strings"
10 )
11
12 type Role int
13
14 const (
15 None Role = iota
16 Read
17 Write
18 Admin
19 )
20
21 func ParseRole(s string) (Role, error) {
22 switch strings.ToLower(strings.TrimSpace(s)) {
23 case "", "none":
24 return None, nil
25 case "read":
26 return Read, nil
27 case "write":
28 return Write, nil
29 case "admin", "owner":
30 return Admin, nil
31 }
32 return None, fmt.Errorf("unknown role %q (want none, read, write or admin)", s)
33 }
34
35 func (r Role) String() string {
36 switch r {
37 case Read:
38 return "read"
39 case Write:
40 return "write"
41 case Admin:
42 return "admin"
43 }
44 return "none"
45 }
46
47 func (r Role) AtLeast(need Role) bool { return r >= need }
48
49 // ACL is stored at refs/vole/acl in every repo, so access rules travel with the
50 // repository and survive deleting the instance.
51 type ACL struct {
52 Version int `json:"version"`
53 Default string `json:"default"`
54 Entries map[string]string `json:"entries"`
55 Protected []string `json:"protected,omitempty"`
56 }
57
58 func Empty(owner string) *ACL {
59 a := &ACL{Version: 1, Default: "none", Entries: map[string]string{}}
60 if owner != "" {
61 a.Entries[owner] = "admin"
62 }
63 return a
64 }
65
66 func Parse(data []byte) (*ACL, error) {
67 a := &ACL{}
68 if err := json.Unmarshal(data, a); err != nil {
69 return nil, fmt.Errorf("acl: %w", err)
70 }
71 if a.Version == 0 {
72 a.Version = 1
73 }
74 if a.Entries == nil {
75 a.Entries = map[string]string{}
76 }
77 if _, err := ParseRole(a.Default); err != nil {
78 return nil, err
79 }
80 for k, v := range a.Entries {
81 if _, err := ParseRole(v); err != nil {
82 return nil, fmt.Errorf("acl entry %q: %w", k, err)
83 }
84 }
85 return a, nil
86 }
87
88 func (a *ACL) Bytes() ([]byte, error) {
89 b, err := json.MarshalIndent(a, "", " ")
90 if err != nil {
91 return nil, err
92 }
93 return append(b, '\n'), nil
94 }
95
96 // RoleFor resolves a principal's effective role. Privilege only ever comes from
97 // an explicit entry: the default applies to everyone else, and it may never
98 // exceed read unless an operator explicitly says so.
99 func (a *ACL) RoleFor(user string) Role {
100 if user != "" {
101 if r, ok := a.Entries[user]; ok {
102 if role, err := ParseRole(r); err == nil {
103 return role
104 }
105 }
106 }
107 def, _ := ParseRole(a.Default)
108 return def
109 }
110
111 func (a *ACL) Can(user string, need Role) bool { return a.RoleFor(user).AtLeast(need) }
112
113 // IsProtected reports whether ref matches a protected pattern. Patterns are an
114 // exact ref or a trailing "*" prefix such as "refs/heads/release/*".
115 func (a *ACL) IsProtected(ref string) bool {
116 for _, p := range a.Protected {
117 if strings.HasSuffix(p, "*") {
118 if strings.HasPrefix(ref, strings.TrimSuffix(p, "*")) {
119 return true
120 }
121 continue
122 }
123 if p == ref {
124 return true
125 }
126 }
127 return false
128 }
129
130 // SetEntry updates a principal's role. An empty role deletes the entry.
131 func (a *ACL) SetEntry(user, role string) error {
132 r, err := ParseRole(role)
133 if err != nil {
134 return err
135 }
136 if r == None {
137 delete(a.Entries, user)
138 return nil
139 }
140 a.Entries[user] = r.String()
141 return nil
142 }
143
144 func (a *ACL) Principals() []string {
145 out := make([]string, 0, len(a.Entries))
146 for k := range a.Entries {
147 out = append(out, k)
148 }
149 sort.Strings(out)
150 return out
151 }