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/server/server.go +13−1
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "github.com/rodent-software/vole/internal/config" | |
| 16 | 16 | "github.com/rodent-software/vole/internal/identity" | |
| 17 | 17 | "github.com/rodent-software/vole/internal/index" | |
| 18 | "github.com/rodent-software/vole/internal/lfs" | ||
| 18 | 19 | "github.com/rodent-software/vole/internal/repo" | |
| 19 | 20 | "github.com/rodent-software/vole/internal/runs" | |
| 20 | 21 | "github.com/rodent-software/vole/internal/signing" | |
| @@ -31,6 +32,7 @@ type Server struct { | |||
| 31 | 32 | Identity *identity.Store | |
| 32 | 33 | Runs *runs.Store | |
| 33 | 34 | Index *index.Store | |
| 35 | LFS *lfs.Store | ||
| 34 | 36 | Proxy *transport.Proxy | |
| 35 | 37 | SSH *sshserver.Server | |
| 36 | 38 | Key *signing.Key | |
| @@ -76,9 +78,12 @@ func New(cfg *config.Config, binPath string, log *slog.Logger) (*Server, error) | |||
| 76 | 78 | Open: rs.Open, | |
| 77 | 79 | Log: log, | |
| 78 | 80 | }) | |
| 81 | lfsStore := lfs.New(cfg.LFSDir(), cfg.LFSObjectMaxBytes) | ||
| 79 | 82 | ssh, err := sshserver.New(sshserver.Config{ | |
| 80 | 83 | Identity: ident, | |
| 81 | 84 | Repos: rs, | |
| 85 | LFS: lfsStore, | ||
| 86 | LFSWarnBytes: cfg.LFSWarnBytes, | ||
| 82 | 87 | Log: log, | |
| 83 | 88 | HostKeyPath: cfg.SSHHostKey(), | |
| 84 | 89 | MaxBlobBytes: cfg.MaxBlobBytes, | |
| @@ -95,10 +100,11 @@ func New(cfg *config.Config, binPath string, log *slog.Logger) (*Server, error) | |||
| 95 | 100 | if err != nil { | |
| 96 | 101 | return nil, err | |
| 97 | 102 | } | |
| 103 | wp.LFS = lfsStore | ||
| 98 | 104 | wp.Logger = func(format string, args ...any) { log.Warn(fmt.Sprintf(format, args...)) } | |
| 99 | 105 | ||
| 100 | 106 | s := &Server{ | |
| 101 | Cfg: cfg, Repos: rs, Identity: ident, Runs: runStore, Index: ix, | ||
| 107 | Cfg: cfg, Repos: rs, Identity: ident, Runs: runStore, Index: ix, LFS: lfsStore, | ||
| 102 | 108 | Proxy: proxy, SSH: ssh, Key: key, Web: wp, Log: log, | |
| 103 | 109 | } | |
| 104 | 110 | s.mux = s.routes() | |
| @@ -126,6 +132,7 @@ func (s *Server) routes() *http.ServeMux { | |||
| 126 | 132 | mux.HandleFunc("GET /r/{name}/issues/{id}", w.IssueView) | |
| 127 | 133 | mux.HandleFunc("GET /r/{name}/requests", w.Requests) | |
| 128 | 134 | mux.HandleFunc("GET /r/{name}/requests/{id}", w.RequestView) | |
| 135 | mux.HandleFunc("GET /r/{name}/runs/{id}/log", w.RunLog) | ||
| 129 | 136 | ||
| 130 | 137 | mux.HandleFunc("GET /healthz", func(rw http.ResponseWriter, r *http.Request) { | |
| 131 | 138 | rw.Header().Set("Content-Type", "text/plain") | |
| @@ -134,6 +141,11 @@ func (s *Server) routes() *http.ServeMux { | |||
| 134 | 141 | ||
| 135 | 142 | // Anonymous, read-only git. Registered without a method so git's POSTs reach it. | |
| 136 | 143 | mux.Handle("/git/", s.Proxy) | |
| 144 | // LFS reads share the same read rule and the same URLs as clone; uploads are | ||
| 145 | // refused here and happen over SSH. | ||
| 146 | mux.HandleFunc("POST /git/{name}/info/lfs/objects/batch", s.LFSBatch) | ||
| 147 | mux.HandleFunc("GET /git/{name}/info/lfs/objects/{oid}", s.LFSObject) | ||
| 148 | mux.HandleFunc("HEAD /git/{name}/info/lfs/objects/{oid}", s.LFSObject) | ||
| 137 | 149 | return mux | |
| 138 | 150 | } | |
| 139 | 151 | ||