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.

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/sshserver/sshserver_test.go +426

@@ -0,0 +1,426 @@
1 package sshserver
2
3 import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "encoding/pem"
7 "fmt"
8 "io"
9 "log/slog"
10 "net"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "strings"
15 "testing"
16 "time"
17
18 "golang.org/x/crypto/ssh"
19
20 "github.com/rodent-software/vole/internal/acl"
21 "github.com/rodent-software/vole/internal/identity"
22 "github.com/rodent-software/vole/internal/repo"
23 )
24
25 var binPath string
26
27 // TestMain builds the real binary: installed hooks exec it, so a stub would let
28 // every policy test pass for the wrong reason.
29 func TestMain(m *testing.M) {
30 dir, err := os.MkdirTemp("", "vole-ssh-bin-")
31 if err != nil {
32 fmt.Fprintln(os.Stderr, err)
33 os.Exit(1)
34 }
35 binPath = filepath.Join(dir, "vole")
36 build := exec.Command("go", "build", "-o", binPath, "github.com/rodent-software/vole/cmd/vole")
37 build.Stdout, build.Stderr = os.Stdout, os.Stderr
38 if err := build.Run(); err != nil {
39 fmt.Fprintln(os.Stderr, "build vole:", err)
40 os.Exit(1)
41 }
42 code := m.Run()
43 os.RemoveAll(dir)
44 os.Exit(code)
45 }
46
47 type harness struct {
48 t *testing.T
49 dir string
50 addr string
51 ident *identity.Store
52 repos *repo.Store
53 srv *Server
54 keyPath string // private key file for git
55 pubLine string // authorized_keys line for the writer
56 roKey string // private key file for the read-only user
57 repoName string
58 }
59
60 func newHarness(t *testing.T) *harness {
61 t.Helper()
62 dir := t.TempDir()
63 sysRepo := filepath.Join(dir, "system.git")
64 if err := identity.Init(sysRepo); err != nil {
65 t.Fatal(err)
66 }
67 ident, err := identity.Open(sysRepo)
68 if err != nil {
69 t.Fatal(err)
70 }
71
72 writer, writerPub := keypair(t, filepath.Join(dir, "writer"))
73 reader, readerPub := keypair(t, filepath.Join(dir, "reader"))
74
75 for _, u := range []struct{ name, pub string }{
76 {"writer", writerPub},
77 {"reader", readerPub},
78 } {
79 if err := ident.Create(u.name); err != nil {
80 t.Fatal(err)
81 }
82 if _, err := ident.AddKey(u.name, u.pub, "test"); err != nil {
83 t.Fatal(err)
84 }
85 }
86
87 rs := repo.New(filepath.Join(dir, "repos"), binPath)
88 rs.MaxInputBytes = 1 << 20
89 m, err := rs.Create("demo", "writer", "")
90 if err != nil {
91 t.Fatal(err)
92 }
93 a, err := m.ACL()
94 if err != nil {
95 t.Fatal(err)
96 }
97 if err := a.SetEntry("reader", "read"); err != nil {
98 t.Fatal(err)
99 }
100 if err := m.SetACL(a, "test"); err != nil {
101 t.Fatal(err)
102 }
103
104 log := slog.New(slog.NewTextHandler(io.Discard, nil))
105 srv, err := New(Config{
106 Identity: ident,
107 Repos: rs,
108 Log: log,
109 HostKeyPath: filepath.Join(dir, "host_key"),
110 })
111 if err != nil {
112 t.Fatal(err)
113 }
114 ln, err := net.Listen("tcp", "127.0.0.1:0")
115 if err != nil {
116 t.Fatal(err)
117 }
118 go srv.Serve(ln)
119 t.Cleanup(func() { srv.Close() })
120
121 return &harness{
122 t: t, dir: dir, addr: ln.Addr().String(), ident: ident, repos: rs,
123 srv: srv, keyPath: writer, roKey: reader, repoName: "demo",
124 }
125 }
126
127 // keypair writes an OpenSSH private key and returns its path plus the public
128 // key in authorized_keys form.
129 func keypair(t *testing.T, path string) (string, string) {
130 t.Helper()
131 pub, priv, err := ed25519.GenerateKey(rand.Reader)
132 if err != nil {
133 t.Fatal(err)
134 }
135 blk, err := ssh.MarshalPrivateKey(priv, "test")
136 if err != nil {
137 t.Fatal(err)
138 }
139 if err := os.WriteFile(path, pem.EncodeToMemory(blk), 0o600); err != nil {
140 t.Fatal(err)
141 }
142 sshPub, err := ssh.NewPublicKey(pub)
143 if err != nil {
144 t.Fatal(err)
145 }
146 line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub))) + " test@vole\n"
147 if err := os.WriteFile(path+".pub", []byte(line), 0o644); err != nil {
148 t.Fatal(err)
149 }
150 return path, strings.TrimSpace(line)
151 }
152
153 func (h *harness) url() string {
154 return "ssh://git@" + h.addr + "/" + h.repoName + ".git"
155 }
156
157 func (h *harness) git(dir, key string, args ...string) (string, string, error) {
158 h.t.Helper()
159 cmd := exec.Command("git", args...)
160 cmd.Dir = dir
161 cmd.Env = append(os.Environ(),
162 "GIT_TERMINAL_PROMPT=0",
163 "GIT_CONFIG_GLOBAL=/dev/null",
164 "GIT_CONFIG_SYSTEM=/dev/null",
165 "GIT_SSH_COMMAND=ssh -i "+key+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
166 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
167 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
168 )
169 var out, errb strings.Builder
170 cmd.Stdout, cmd.Stderr = &out, &errb
171 err := cmd.Run()
172 return out.String(), errb.String(), err
173 }
174
175 func workRepo(t *testing.T) string {
176 t.Helper()
177 dir := t.TempDir()
178 for _, args := range [][]string{
179 {"init", "-q", "-b", "main"},
180 } {
181 cmd := exec.Command("git", args...)
182 cmd.Dir = dir
183 if out, err := cmd.CombinedOutput(); err != nil {
184 t.Fatalf("%v: %s", err, out)
185 }
186 }
187 if err := os.WriteFile(filepath.Join(dir, "readme.md"), []byte("# hi\n"), 0o644); err != nil {
188 t.Fatal(err)
189 }
190 env := append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e")
191 for _, args := range [][]string{{"add", "-A"}, {"commit", "-qm", "init"}} {
192 cmd := exec.Command("git", args...)
193 cmd.Dir = dir
194 cmd.Env = env
195 if out, err := cmd.CombinedOutput(); err != nil {
196 t.Fatalf("%v: %s", err, out)
197 }
198 }
199 return dir
200 }
201
202 func (h *harness) serverRef() string {
203 out, _, err := h.git(filepath.Join(h.dir, "repos", "demo.git"), h.keyPath,
204 "rev-parse", "--verify", "--quiet", "refs/heads/main")
205 if err != nil {
206 return ""
207 }
208 return strings.TrimSpace(out)
209 }
210
211 func TestPushAndCloneOverSSH(t *testing.T) {
212 h := newHarness(t)
213 work := workRepo(t)
214
215 if _, errs, err := h.git(work, h.keyPath, "push", h.url(), "main:refs/heads/main"); err != nil {
216 t.Fatalf("push failed: %v\n%s", err, errs)
217 }
218 if h.serverRef() == "" {
219 t.Fatal("push landed no ref")
220 }
221
222 dst := filepath.Join(t.TempDir(), "clone")
223 if _, errs, err := h.git("", h.roKey, "clone", "-q", h.url(), dst); err != nil {
224 t.Fatalf("read-only clone failed: %v\n%s", err, errs)
225 }
226 got, err := os.ReadFile(filepath.Join(dst, "readme.md"))
227 if err != nil {
228 t.Fatal(err)
229 }
230 if string(got) != "# hi\n" {
231 t.Fatalf("cloned content = %q", got)
232 }
233 }
234
235 // TestReadOnlyUserCannotPush proves the hook sees the SSH identity: the
236 // rejection has to name the user, not just fail.
237 func TestReadOnlyUserCannotPush(t *testing.T) {
238 h := newHarness(t)
239 work := workRepo(t)
240 if _, errs, err := h.git(work, h.keyPath, "push", h.url(), "main:refs/heads/main"); err != nil {
241 t.Fatalf("writer push: %s", errs)
242 }
243 before := h.serverRef()
244
245 if err := os.WriteFile(filepath.Join(work, "readme.md"), []byte("# changed\n"), 0o644); err != nil {
246 t.Fatal(err)
247 }
248 cmd := exec.Command("git", "commit", "-aqm", "change")
249 cmd.Dir = work
250 cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e")
251 if out, err := cmd.CombinedOutput(); err != nil {
252 t.Fatalf("%v: %s", err, out)
253 }
254
255 _, errs, err := h.git(work, h.roKey, "push", h.url(), "main:refs/heads/main")
256 if err == nil {
257 t.Fatal("read-only user pushed successfully")
258 }
259 if !strings.Contains(errs, "does not have write access") || !strings.Contains(errs, "reader") {
260 t.Fatalf("rejection did not name the SSH identity:\n%s", errs)
261 }
262 if after := h.serverRef(); after != before {
263 t.Fatalf("ref moved on a rejected push: %s -> %s", before, after)
264 }
265 }
266
267 // TestExecWhitelistRefusesShell is the security test for this transport. A
268 // session that can exec arbitrary commands is a remote shell.
269 func TestExecWhitelistRefusesShell(t *testing.T) {
270 h := newHarness(t)
271 client := h.dial(t, h.keyPath)
272
273 for _, command := range []string{
274 "sh -c 'echo pwned'",
275 "cat /etc/passwd",
276 "/bin/sh",
277 "git-upload-pack /demo.git; cat /etc/passwd",
278 "git-receive-pack",
279 "git-upload-pack '/demo.git' extra",
280 } {
281 sess, err := client.NewSession()
282 if err != nil {
283 t.Fatalf("new session: %v", err)
284 }
285 out, err := sess.CombinedOutput(command)
286 sess.Close()
287 if err == nil {
288 t.Fatalf("command %q succeeded, want refusal (output: %s)", command, out)
289 }
290 if strings.Contains(string(out), "pwned") || strings.Contains(string(out), "root:") {
291 t.Fatalf("command %q executed: %s", command, out)
292 }
293 }
294 }
295
296 func TestShellRequestRefused(t *testing.T) {
297 h := newHarness(t)
298 client := h.dial(t, h.keyPath)
299 sess, err := client.NewSession()
300 if err != nil {
301 t.Fatal(err)
302 }
303 defer sess.Close()
304 if err := sess.Shell(); err == nil {
305 t.Fatal("interactive shell was accepted")
306 }
307 }
308
309 func TestUnknownKeyRejected(t *testing.T) {
310 h := newHarness(t)
311 stranger, _ := keypair(t, filepath.Join(h.dir, "stranger"))
312 if _, err := ssh.Dial("tcp", h.addr, &ssh.ClientConfig{
313 User: "git",
314 Auth: []ssh.AuthMethod{publicKey(t, stranger)},
315 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
316 Timeout: 5 * time.Second,
317 }); err == nil {
318 t.Fatal("a key that is not registered was accepted")
319 }
320 }
321
322 func TestRepoOutsideTheStoreIsRefused(t *testing.T) {
323 h := newHarness(t)
324 client := h.dial(t, h.keyPath)
325 for _, arg := range []string{
326 "/../../etc.git",
327 "/demo.git/../../x",
328 "/nothere.git",
329 } {
330 sess, err := client.NewSession()
331 if err != nil {
332 t.Fatal(err)
333 }
334 out, err := sess.CombinedOutput("git-upload-pack '" + arg + "'")
335 sess.Close()
336 if err == nil {
337 t.Fatalf("path %q was accepted: %s", arg, out)
338 }
339 }
340 }
341
342 func (h *harness) dial(t *testing.T, keyPath string) *ssh.Client {
343 t.Helper()
344 c, err := ssh.Dial("tcp", h.addr, &ssh.ClientConfig{
345 User: "git",
346 Auth: []ssh.AuthMethod{publicKey(t, keyPath)},
347 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
348 Timeout: 5 * time.Second,
349 })
350 if err != nil {
351 t.Fatalf("dial: %v", err)
352 }
353 t.Cleanup(func() { c.Close() })
354 return c
355 }
356
357 func publicKey(t *testing.T, path string) ssh.AuthMethod {
358 t.Helper()
359 raw, err := os.ReadFile(path)
360 if err != nil {
361 t.Fatal(err)
362 }
363 blk, _ := pem.Decode(raw)
364 if blk == nil {
365 t.Fatal("bad private key")
366 }
367 key, err := ssh.ParseRawPrivateKey(pem.EncodeToMemory(blk))
368 if err != nil {
369 t.Fatal(err)
370 }
371 signer, err := ssh.NewSignerFromKey(key)
372 if err != nil {
373 t.Fatal(err)
374 }
375 return ssh.PublicKeys(signer)
376 }
377
378 func TestSplitCommand(t *testing.T) {
379 for _, c := range []struct {
380 in string
381 want []string
382 ok bool
383 }{
384 {"git-upload-pack '/demo.git'", []string{"git-upload-pack", "/demo.git"}, true},
385 {`git-upload-pack "/demo.git"`, []string{"git-upload-pack", "/demo.git"}, true},
386 {"git-receive-pack demo.git", []string{"git-receive-pack", "demo.git"}, true},
387 {"git-upload-pack '/unterminated", nil, false},
388 {"", nil, true},
389 } {
390 got, err := splitCommand(c.in)
391 if c.ok != (err == nil) {
392 t.Fatalf("%q err=%v", c.in, err)
393 }
394 if !c.ok {
395 continue
396 }
397 if len(got) != len(c.want) {
398 t.Fatalf("%q = %v, want %v", c.in, got, c.want)
399 }
400 for i := range got {
401 if got[i] != c.want[i] {
402 t.Fatalf("%q = %v, want %v", c.in, got, c.want)
403 }
404 }
405 }
406 }
407
408 func TestRepoNameValidation(t *testing.T) {
409 for _, c := range []struct {
410 in, want string
411 ok bool
412 }{
413 {"/demo.git", "demo", true},
414 {"demo.git", "demo", true},
415 {"/demo", "", false},
416 {"../demo.git", "", false},
417 {"/a/b.git", "", false},
418 } {
419 got, ok := repoName(c.in)
420 if ok != c.ok || got != c.want {
421 t.Errorf("repoName(%q) = %q,%v want %q,%v", c.in, got, ok, c.want, c.ok)
422 }
423 }
424 }
425
426 var _ = acl.Read