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 5: LFS objects, over SSH for writes and the proxy for reads

64ac5071a173e4e9ce8bc8d7170f9b0411bee52a · Keenan Nemetz · 17d ago · parent 35eaeccf

Phase 5: LFS objects, over SSH for writes and the proxy for reads

Large files leave the git object database and land in one content-addressed
store per instance. Reads are anonymous like clone; writes ride the SSH key
that already authenticates the push, so no token exists anywhere.

- internal/lfs: sha256-addressed store that verifies content and size before
  an object becomes visible; parser for the pointer format a client writes
- HTTP: POST .../info/lfs/objects/batch for downloads, GET .../objects/<oid>
  with the object id as ETag; upload over HTTP is refused and names SSH
- SSH: git-lfs-transfer speaking the pure SSH protocol (pkt-line), so
  `git lfs push` needs nothing but the key at the handshake
- pre-receive: a pointer whose object is missing, or whose size disagrees
  with the stored object, fails the push that introduced it; a binary over
  lfs_warn_bytes is accepted with a `git lfs track` suggestion. Checks run
  before the protected-branch gate, because an admin's approval bypass is
  about review, not about whether the commit can be checked out
- runs: a finished log becomes a content-addressed object in the same write
  that records the status, served at /r/<repo>/runs/<id>/log
- vole gc [--dry-run] [--grace]: marks pointer targets from every ref in
  every repository plus archived logs, sweeps the rest. Mark-and-sweep
  rather than reference counts: a counter that is one too low deletes an
  artifact, a walk over the refs cannot be wrong about what is reachable

internal/sshserver/lfs.go +109

@@ -0,0 +1,109 @@
1 package sshserver
2
3 import (
4 "fmt"
5 "strconv"
6 "time"
7
8 "golang.org/x/crypto/ssh"
9
10 "github.com/rodent-software/vole/internal/acl"
11 "github.com/rodent-software/vole/internal/lfs"
12 )
13
14 // serveLFS runs a git-lfs transfer session: `ssh host git-lfs-transfer <repo>
15 // upload|download`. Uploads require write access to the repository, downloads
16 // the same read rule the web and clone use — but resolved against the account
17 // that owns the key, so a private repository's members can pull objects over
18 // SSH while anonymous readers cannot.
19 func (s *Server) serveLFS(ch ssh.Channel, req *ssh.Request, pathArg, op, user, accountID string) {
20 refuse := func(format string, args ...any) {
21 req.Reply(true, nil)
22 fmt.Fprintf(ch, "vole: "+format+"\n", args...)
23 ch.CloseWrite()
24 ch.Close()
25 }
26
27 if s.cfg.LFS == nil {
28 refuse("this instance has no LFS store")
29 return
30 }
31 if op != "upload" && op != "download" {
32 refuse("%q is not an LFS operation; expected upload or download", op)
33 return
34 }
35 name, ok := repoName(pathArg)
36 if !ok {
37 refuse("invalid repository path")
38 return
39 }
40 if name == "system" {
41 refuse("the instance roster has no LFS store")
42 return
43 }
44 m, err := s.cfg.Repos.Open(name)
45 if err != nil {
46 refuse("repository %q not found", name)
47 return
48 }
49 a, err := m.ACL()
50 if err != nil {
51 refuse("cannot read access rules for %s", name)
52 return
53 }
54 role := a.RoleFor(accountID)
55 canRead := a.Can(accountID, acl.Read)
56 canWrite := role.AtLeast(acl.Write)
57 if op == "upload" && !canWrite {
58 s.cfg.Log.Warn("ssh: refused lfs upload", "user", user, "repo", name, "role", role)
59 refuse("%s does not have write access to %s (role: %s)", user, name, role)
60 return
61 }
62 if op == "download" && !canRead {
63 s.cfg.Log.Warn("ssh: refused lfs download", "user", user, "repo", name)
64 // Same shape as clone: do not confirm that the repository exists.
65 refuse("repository %q not found", name)
66 return
67 }
68
69 if err := req.Reply(true, nil); err != nil {
70 return
71 }
72 start := time.Now()
73 t := &lfs.Transfer{
74 Store: s.cfg.LFS,
75 Repo: name,
76 AccountID: accountID,
77 CanRead: canRead,
78 CanWrite: canWrite,
79 Log: s.cfg.Log,
80 }
81 serveErr := t.Serve(op, ch, ch)
82 code := 0
83 if serveErr != nil {
84 code = 1
85 fmt.Fprintf(ch.Stderr(), "vole: lfs %s: %v\n", op, serveErr)
86 }
87 s.cfg.Log.Info("ssh lfs", "user", user, "op", op, "repo", name,
88 "exit", code, "duration", time.Since(start).String())
89 exit(ch, code)
90 }
91
92 // lfsObjectSize is a small helper for messages and tests.
93 func lfsObjectSize(n int64) string { return strconv.FormatInt(n, 10) }
94
95 // lfsRoot and lfsMax keep the hook environment well-defined on an instance built
96 // without a store (tests, embedders): pre-receive then has nothing to check.
97 func lfsRoot(s *lfs.Store) string {
98 if s == nil {
99 return ""
100 }
101 return s.Root
102 }
103
104 func lfsMax(s *lfs.Store) int64 {
105 if s == nil {
106 return 0
107 }
108 return s.MaxBytes
109 }