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 | ||