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/web/templates.go +280

@@ -0,0 +1,280 @@
1 package web
2
3 const templates = `
4 {{define "header"}}<!doctype html>
5 <html lang="en">
6 <head>
7 <meta charset="utf-8">
8 <meta name="viewport" content="width=device-width,initial-scale=1">
9 <title>{{.Title}} · Vole</title>
10 <style>
11 :root{
12 --bg:#0f1115; --fg:#e6e6e6; --muted:#8b93a1; --line:#242a35;
13 --accent:#6ea8fe; --card:#161a21; --ok:#3fb950; --bad:#f85149;
14 }
15 *{box-sizing:border-box}
16 body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
17 a{color:var(--accent);text-decoration:none}
18 a:hover{text-decoration:underline}
19 header{display:flex;align-items:center;gap:1rem;padding:.9rem 1.25rem;border-bottom:1px solid var(--line);background:var(--card)}
20 .brand{font-weight:700;letter-spacing:.02em;color:var(--fg)}
21 header nav{margin-left:auto;display:flex;gap:.85rem;align-items:center;color:var(--muted)}
22 main{max-width:62rem;margin:0 auto;padding:1.5rem 1.25rem 4rem}
23 h1{font-size:1.45rem;margin:.2rem 0 1rem}
24 h2{font-size:1.05rem;margin:1.75rem 0 .6rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
25 .muted{color:var(--muted)}
26 .flash,.error{border-radius:6px;padding:.6rem .8rem;margin:0 0 1rem;font-size:.92rem}
27 .flash{background:rgba(63,185,80,.12);border:1px solid rgba(63,185,80,.35)}
28 .error{background:rgba(248,81,73,.12);border:1px solid rgba(248,81,73,.35)}
29 table{width:100%;border-collapse:collapse;font-size:.93rem}
30 th,td{text-align:left;padding:.45rem .5rem;border-bottom:1px solid var(--line);vertical-align:top}
31 th{color:var(--muted);font-weight:500;font-size:.82rem;text-transform:uppercase;letter-spacing:.04em}
32 code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
33 code{background:var(--card);border:1px solid var(--line);border-radius:4px;padding:.1rem .35rem;font-size:.88em}
34 pre{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:1rem;overflow:auto;font-size:.86rem;line-height:1.5}
35 form.inline{display:inline}
36 button{background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:.35rem .75rem;font:inherit;font-size:.88rem;cursor:pointer}
37 button:hover{border-color:var(--accent)}
38 button.danger{border-color:rgba(248,81,73,.5);color:var(--bad)}
39 input,select,textarea{background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:.4rem .55rem;font:inherit;font-size:.9rem}
40 input[type=password],input[type=text]{min-width:16rem}
41 textarea{width:100%;min-height:5rem;font-family:ui-monospace,monospace}
42 label{display:block;margin:.55rem 0 .2rem;color:var(--muted);font-size:.85rem}
43 .card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:1rem;margin:1rem 0}
44 .row{display:flex;gap:1.5rem;flex-wrap:wrap}
45 .row>div{flex:1 1 18rem}
46 .crumbs{color:var(--muted);font-size:.9rem;margin-bottom:.75rem}
47 .dir{color:var(--accent)}
48 .sha{font-family:ui-monospace,monospace;font-size:.85rem;color:var(--muted)}
49 </style>
50 </head>
51 <body>
52 <header>
53 <a class="brand" href="/">vole</a>
54 <nav>
55 {{with .User}}
56 <span class="muted">{{.Username}}</span>
57 <form method="post" action="/logout" class="inline"><button>sign out</button></form>
58 {{end}}
59 </nav>
60 </header>
61 <main>
62 {{if .Flash}}<p class="flash">{{.Flash}}</p>{{end}}
63 {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
64 {{end}}
65
66 {{define "footer"}}
67 </main>
68 </body>
69 </html>
70 {{end}}
71
72 {{define "login"}}
73 {{template "header" .}}
74 <h1>Sign in</h1>
75 <form method="post" action="/login" class="card">
76 <input type="hidden" name="next" value="{{.Next}}">
77 <label for="u">Username</label>
78 <input id="u" name="username" type="text" autocomplete="username" autofocus>
79 <label for="p">Password</label>
80 <input id="p" name="password" type="password" autocomplete="current-password">
81 <p><button type="submit">Sign in</button></p>
82 </form>
83 {{template "footer" .}}
84 {{end}}
85
86 {{define "index"}}
87 {{template "header" .}}
88 <h1>Repositories</h1>
89 {{if .Repos}}
90 <table>
91 <tr><th>Name</th><th>Description</th><th>Access</th><th>Created</th></tr>
92 {{range .Repos}}
93 <tr>
94 <td><a href="/r/{{.Name}}">{{.Name}}</a></td>
95 <td class="muted">{{.Description}}</td>
96 <td class="muted">{{.ACLDefault}} default · {{.ACLPrincipals}} principal(s)</td>
97 <td class="muted">{{since .Created.Unix}}</td>
98 </tr>
99 {{end}}
100 </table>
101 {{else}}
102 <p class="muted">No repositories yet.</p>
103 {{end}}
104
105 {{if .Admin}}
106 <h2>Create a repository</h2>
107 <form method="post" action="/r/new" class="card">
108 <label for="n">Name</label>
109 <input id="n" name="name" type="text" placeholder="lowercase-with-dashes" required>
110 <label for="d">Description</label>
111 <input id="d" name="description" type="text">
112 <p><button type="submit">Create</button></p>
113 </form>
114 {{end}}
115 {{template "footer" .}}
116 {{end}}
117
118 {{define "repo"}}
119 {{template "header" .}}
120 <h1>{{.Name}}</h1>
121 {{if .Description}}<p class="muted">{{.Description}}</p>{{end}}
122
123 <div class="row">
124 <div>
125 <h2>Clone</h2>
126 <p><code>{{.CloneURL}}</code></p>
127 <p class="muted">Your password is your personal access token.</p>
128 </div>
129 <div>
130 <h2>Branch</h2>
131 <form method="get" action="/r/{{.Name}}">
132 <select name="ref" onchange="this.form.submit()">
133 {{range .Branches}}<option value="{{.}}"{{if eq . $.Ref}} selected{{end}}>{{.}}</option>{{end}}
134 </select>
135 </form>
136 <p class="muted">{{.Count}} commit(s) · head <span class="sha">{{short .Commit}}</span></p>
137 </div>
138 </div>
139
140 {{if .RepoAdmin}}<p><a href="/r/{{.Name}}/settings">Repository settings</a></p>{{end}}
141
142 <h2>Files</h2>
143 {{if .Entries}}
144 <table>
145 {{range .Entries}}
146 <tr>
147 {{if eq .Type "tree"}}
148 <td><a class="dir" href="/r/{{$.Name}}/tree/{{esc $.Ref}}/{{.Name}}">{{.Name}}/</a></td>
149 {{else}}
150 <td><a href="/r/{{$.Name}}/blob/{{esc $.Ref}}/{{.Name}}">{{.Name}}</a></td>
151 {{end}}
152 <td class="muted">{{.Mode}}</td>
153 </tr>
154 {{end}}
155 </table>
156 {{else}}
157 <p class="muted">This repository is empty. Push something.</p>
158 {{end}}
159
160 <h2>Recent commits</h2>
161 {{template "commitlist" .}}
162 {{template "footer" .}}
163 {{end}}
164
165 {{define "commitlist"}}
166 {{if .Commits}}
167 <table>
168 {{range .Commits}}
169 <tr>
170 <td><span class="sha">{{short .SHA}}</span></td>
171 <td>{{.Subject}}</td>
172 <td class="muted">{{.Author}}</td>
173 <td class="muted">{{since .When}}</td>
174 </tr>
175 {{end}}
176 </table>
177 {{else}}
178 <p class="muted">No commits yet.</p>
179 {{end}}
180 {{end}}
181
182 {{define "tree"}}
183 {{template "header" .}}
184 <h1><a href="/r/{{.Name}}">{{.Name}}</a></h1>
185 <p class="crumbs">
186 <a href="/r/{{.Name}}/tree/{{esc .Ref}}">{{.Ref}}</a>
187 {{range .Crumbs}} / <span>{{.Name}}</span>{{end}}
188 </p>
189 <table>
190 {{range .Entries}}
191 <tr>
192 {{if eq .Type "tree"}}
193 <td><a class="dir" href="/r/{{$.Name}}/tree/{{esc $.Ref}}/{{.Name}}">{{.Name}}/</a></td>
194 {{else}}
195 <td><a href="/r/{{$.Name}}/blob/{{esc $.Ref}}/{{.Name}}">{{.Name}}</a></td>
196 {{end}}
197 <td class="muted">{{.Mode}}</td>
198 </tr>
199 {{end}}
200 </table>
201 {{template "footer" .}}
202 {{end}}
203
204 {{define "blob"}}
205 {{template "header" .}}
206 <h1><a href="/r/{{.Name}}">{{.Name}}</a></h1>
207 <p class="crumbs">
208 <a href="/r/{{.Name}}/blob/{{esc .Ref}}">{{.Path}}</a> ·
209 <a href="/r/{{.Name}}/raw/{{esc .Ref}}/{{.Path}}">raw</a> ·
210 <span class="muted">{{.Size}} bytes</span>
211 </p>
212 {{if .Binary}}
213 <p class="muted">Binary file, {{.Size}} bytes. <a href="/r/{{.Name}}/raw/{{esc .Ref}}/{{.Path}}">Download it</a>.</p>
214 {{else}}
215 {{if .Truncated}}<p class="muted">Showing the first 1 MiB.</p>{{end}}
216 <pre>{{.Content}}</pre>
217 {{end}}
218 {{template "footer" .}}
219 {{end}}
220
221 {{define "commits"}}
222 {{template "header" .}}
223 <h1><a href="/r/{{.Name}}">{{.Name}}</a> · history</h1>
224 <p class="crumbs"><span class="muted">{{.Ref}}</span></p>
225 {{template "commitlist" .}}
226 {{template "footer" .}}
227 {{end}}
228
229 {{define "settings"}}
230 {{template "header" .}}
231 <h1><a href="/r/{{.Name}}">{{.Name}}</a> · settings</h1>
232
233 <h2>Access</h2>
234 <form method="post" action="/r/{{.Name}}/acl" class="card">
235 <table>
236 <tr><th>Principal</th><th>Role</th></tr>
237 <tr>
238 <td><em>default</em></td>
239 <td>
240 <select name="default">
241 {{range $r := list "none" "read" "write" "admin"}}
242 <option value="{{$r}}"{{if eq $r $.ACL.Default}} selected{{end}}>{{$r}}</option>
243 {{end}}
244 </select>
245 </td>
246 </tr>
247 {{range .Users}}
248 <tr>
249 <td>{{.}}</td>
250 <td>
251 <select name="role_{{.}}">
252 {{range $r := list "none" "read" "write" "admin"}}
253 <option value="{{$r}}"{{if eq $r (index $.ACL.Entries .)}} selected{{end}}>{{$r}}</option>
254 {{end}}
255 </select>
256 </td>
257 </tr>
258 {{end}}
259 </table>
260 <label for="prot">Protected refs (one per line, trailing * allowed)</label>
261 <textarea id="prot" name="protected" placeholder="refs/heads/main">{{join .ACL.Protected}}</textarea>
262 <p><button type="submit">Save access rules</button></p>
263 </form>
264
265 <h2>Rename</h2>
266 <form method="post" action="/r/{{.Name}}/rename" class="card">
267 <label for="rn">New name</label>
268 <input id="rn" name="name" type="text" value="{{.Name}}">
269 <p><button type="submit">Rename</button></p>
270 </form>
271
272 <h2>Delete</h2>
273 <form method="post" action="/r/{{.Name}}/delete" class="card">
274 <label for="cf">Type <code>{{.Name}}</code> to confirm</label>
275 <input id="cf" name="confirm" type="text" autocomplete="off">
276 <p><button class="danger" type="submit">Delete repository</button></p>
277 </form>
278 {{template "footer" .}}
279 {{end}}
280 `