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.

Move writes to SSH, drop web authentication, keep identity in git

a5f19b3c19861e080953096f934df25dc38beaf7 · Keenan Nemetz · 18d ago · parent 653e0cfc

Move writes to SSH, drop web authentication, keep identity in git

The authenticated-HTTP design is gone rather than extended. Writes now require
an SSH key, the web is read-only, and the user roster lives in git.

What replaced what:

- internal/sshserver is the only write transport. An embedded SSH server
  (golang.org/x/crypto/ssh) authenticates by public key and hands the principal
  to pre-receive through the environment, so the hook's policy code is unchanged.
  The exec surface is whitelisted to git-upload-pack and git-receive-pack, the
  repository argument is resolved through the store rather than concatenated, and
  everything else -- including `sh -c` -- is refused and asserted refused in a
  test.
- internal/identity stores users on refs/heads/main in data/system.git. The
  directory listing is the table: users/<name>/keys/<sha>.pub. Writes are CAS
  commits, reads come from an in-memory snapshot so the SSH auth callback is a
  map lookup. Nothing stored is a secret, so a clone of system.git is a complete
  backup and git log on it is the audit trail.
- internal/transport is now anonymous read-only: upload-pack behind a visibility
  check, 404 for private repositories, and no push path at all.
- internal/web has no login, no sessions and no mutations.

Deleted outright: internal/auth, the Basic-auth path, login/logout, web
settings/rename/delete, chunked body spooling, the spool janitor, the push
semaphore and the hand-rolled byte cap. The cap is now git's receive.maxInputSize
per repo, which enforces it on every transport without buffering.

Three things worth recording:

- The CAS helper read the tree outside the retry loop and compared against a
  freshly-read old value, so a concurrent writer's commit could be silently
  discarded while the CAS "succeeded". A test with two stores adding eight keys
  at once found it losing half of them; read, mutate and compare now all happen
  inside the loop.
- The roster moved from refs/vole/users to refs/heads/main because `git clone`
  only mirrors refs/heads/*: the documented backup command produced an instance
  with zero users. Storing on a branch also means git itself refuses to point the
  ref at a non-commit.
- Exec refusals are answered on the channel rather than by rejecting the request.
  Rejecting made the client print "exec request failed on channel 0", so the
  reason was unreadable.

Verified by hand as well as by test: init -> user -> add-key -> repo -> serve,
push over SSH, anonymous HTTP clone, web browse, a private repo answering 404 on
both anonymous surfaces while still cloning over SSH, a refused `sh -c` that
prints why and executes nothing, and a plain clone of system.git yielding the
whole roster with no side database present.

internal/web/templates.go +9−104

@@ -10,7 +10,7 @@ const templates = `
10 10 <style>
11 11 :root{
12 12 --bg:#0f1115; --fg:#e6e6e6; --muted:#8b93a1; --line:#242a35;
13 --accent:#6ea8fe; --card:#161a21; --ok:#3fb950; --bad:#f85149;
13 --accent:#6ea8fe; --card:#161a21; --bad:#f85149;
14 14 }
15 15 *{box-sizing:border-box}
16 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}
@@ -18,28 +18,18 @@ a{color:var(--accent);text-decoration:none}
18 18 a:hover{text-decoration:underline}
19 19 header{display:flex;align-items:center;gap:1rem;padding:.9rem 1.25rem;border-bottom:1px solid var(--line);background:var(--card)}
20 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)}
21 header nav{margin-left:auto;color:var(--muted);font-size:.85rem}
22 22 main{max-width:62rem;margin:0 auto;padding:1.5rem 1.25rem 4rem}
23 23 h1{font-size:1.45rem;margin:.2rem 0 1rem}
24 24 h2{font-size:1.05rem;margin:1.75rem 0 .6rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
25 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 26 table{width:100%;border-collapse:collapse;font-size:.93rem}
30 27 th,td{text-align:left;padding:.45rem .5rem;border-bottom:1px solid var(--line);vertical-align:top}
31 28 th{color:var(--muted);font-weight:500;font-size:.82rem;text-transform:uppercase;letter-spacing:.04em}
32 29 code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
33 30 code{background:var(--card);border:1px solid var(--line);border-radius:4px;padding:.1rem .35rem;font-size:.88em}
34 31 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}
32 select{background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:.35rem .55rem;font:inherit;font-size:.9rem}
43 33 .card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:1rem;margin:1rem 0}
44 34 .row{display:flex;gap:1.5rem;flex-wrap:wrap}
45 35 .row>div{flex:1 1 18rem}
@@ -51,15 +41,9 @@ label{display:block;margin:.55rem 0 .2rem;color:var(--muted);font-size:.85rem}
51 41 <body>
52 42 <header>
53 43 <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>
44 <nav>read-only · push over ssh</nav>
60 45 </header>
61 46 <main>
62 {{if .Flash}}<p class="flash">{{.Flash}}</p>{{end}}
63 47 {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
64 48 {{end}}
65 49
@@ -69,48 +53,22 @@ label{display:block;margin:.55rem 0 .2rem;color:var(--muted);font-size:.85rem}
69 53 </html>
70 54 {{end}}
71 55
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 56 {{define "index"}}
87 57 {{template "header" .}}
88 58 <h1>Repositories</h1>
89 59 {{if .Repos}}
90 60 <table>
91 <tr><th>Name</th><th>Description</th><th>Access</th><th>Created</th></tr>
61 <tr><th>Name</th><th>Description</th><th>Created</th></tr>
92 62 {{range .Repos}}
93 63 <tr>
94 64 <td><a href="/r/{{.Name}}">{{.Name}}</a></td>
95 65 <td class="muted">{{.Description}}</td>
96 <td class="muted">{{.ACLDefault}} default · {{.ACLPrincipals}} principal(s)</td>
97 66 <td class="muted">{{since .Created.Unix}}</td>
98 67 </tr>
99 68 {{end}}
100 69 </table>
101 70 {{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>
71 <p class="muted">No public repositories.</p>
114 72 {{end}}
115 73 {{template "footer" .}}
116 74 {{end}}
@@ -124,7 +82,8 @@ label{display:block;margin:.55rem 0 .2rem;color:var(--muted);font-size:.85rem}
124 82 <div>
125 83 <h2>Clone</h2>
126 84 <p><code>{{.CloneURL}}</code></p>
127 <p class="muted">Your password is your personal access token.</p>
85 <p class="muted">Writing needs SSH and a registered key:</p>
86 <p><code>git clone {{.SSHURL}}</code></p>
128 87 </div>
129 88 <div>
130 89 <h2>Branch</h2>
@@ -137,8 +96,6 @@ label{display:block;margin:.55rem 0 .2rem;color:var(--muted);font-size:.85rem}
137 96 </div>
138 97 </div>
139 98
140 {{if .RepoAdmin}}<p><a href="/r/{{.Name}}/settings">Repository settings</a></p>{{end}}
141
142 99 <h2>Files</h2>
143 100 {{if .Entries}}
144 101 <table>
@@ -154,7 +111,7 @@ label{display:block;margin:.55rem 0 .2rem;color:var(--muted);font-size:.85rem}
154 111 {{end}}
155 112 </table>
156 113 {{else}}
157 <p class="muted">This repository is empty. Push something.</p>
114 <p class="muted">This repository is empty.</p>
158 115 {{end}}
159 116
160 117 <h2>Recent commits</h2>
@@ -225,56 +182,4 @@ label{display:block;margin:.55rem 0 .2rem;color:var(--muted);font-size:.85rem}
225 182 {{template "commitlist" .}}
226 183 {{template "footer" .}}
227 184 {{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 185 `