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.
|
c5902451
Keenan Nemetz
16d ago
|
1 | package ssh |
|
64ac5071
Keenan Nemetz
17d ago
|
2 | |
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "strconv" | |
| 6 | "time" | |
| 7 | ||
|
c5902451
Keenan Nemetz
16d ago
|
8 | xssh "golang.org/x/crypto/ssh" |
|
64ac5071
Keenan Nemetz
17d ago
|
9 | |
|
d37d301f
Keenan Nemetz
15h ago
|
10 | "rodent.software/vole/core/lfs" |
| 11 | "rodent.software/vole/forge/access" | |
|
64ac5071
Keenan Nemetz
17d ago
|
12 | ) |
| 13 | ||
| 14 | // serveLFS runs a git-lfs transfer session: `ssh host git-lfs-transfer <repo> | |
|
6c84d387
Keenan Nemetz
15d ago
|
15 | // upload|download`. Uploads require maintain access to the repository, downloads |
|
64ac5071
Keenan Nemetz
17d ago
|
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. | |
|
62576fa8
Keenan Nemetz
14d ago
|
19 | func (s *Server) serveLFS(ch xssh.Channel, req *xssh.Request, pathArg, op, user string) { |
|
64ac5071
Keenan Nemetz
17d ago
|
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 | m, err := s.cfg.Repos.Open(name) | |
| 41 | if err != nil { | |
| 42 | refuse("repository %q not found", name) | |
| 43 | return | |
| 44 | } | |
| 45 | a, err := m.ACL() | |
| 46 | if err != nil { | |
| 47 | refuse("cannot read access rules for %s", name) | |
| 48 | return | |
| 49 | } | |
|
381d6aac
Keenan Nemetz
14d ago
|
50 | role := a.EffectiveRole(user, s.cfg.Identity.IsAdmin(user)) |
| 51 | canRead := role.AtLeast(access.Read) | |
|
6c84d387
Keenan Nemetz
15d ago
|
52 | canMaintain := role.AtLeast(access.Maintain) |
| 53 | if op == "upload" && !canMaintain { | |
|
64ac5071
Keenan Nemetz
17d ago
|
54 | s.cfg.Log.Warn("ssh: refused lfs upload", "user", user, "repo", name, "role", role) |
|
6c84d387
Keenan Nemetz
15d ago
|
55 | refuse("%s does not have maintain access to %s (role: %s)", user, name, role) |
|
64ac5071
Keenan Nemetz
17d ago
|
56 | return |
| 57 | } | |
| 58 | if op == "download" && !canRead { | |
| 59 | s.cfg.Log.Warn("ssh: refused lfs download", "user", user, "repo", name) | |
| 60 | // Same shape as clone: do not confirm that the repository exists. | |
| 61 | refuse("repository %q not found", name) | |
| 62 | return | |
| 63 | } | |
| 64 | ||
| 65 | if err := req.Reply(true, nil); err != nil { | |
| 66 | return | |
| 67 | } | |
| 68 | start := time.Now() | |
| 69 | t := &lfs.Transfer{ | |
|
62576fa8
Keenan Nemetz
14d ago
|
70 | Store: s.cfg.LFS, |
| 71 | Repo: name, | |
| 72 | RepoID: m.ID, | |
| 73 | User: user, | |
| 74 | CanRead: canRead, | |
| 75 | CanWrite: canMaintain, | |
|
bf1e027c
Keenan Nemetz
16d ago
|
76 | // Releasing somebody else's lock is an admin act, the same rule a force-push |
| 77 | // follows: the alternative is a lock nobody can clear once its holder leaves. | |
|
c5902451
Keenan Nemetz
16d ago
|
78 | IsAdmin: role.AtLeast(access.Admin), |
|
bf1e027c
Keenan Nemetz
16d ago
|
79 | Locks: s.cfg.Locks, |
|
9e8cc155
Keenan Nemetz
12d ago
|
80 | // The same per-repository object bound pre-receive uses for metadata: |
| 81 | // artifacts and logs arrive through this transfer, so it is where their | |
| 82 | // bytes can be refused rather than counted after the fact. | |
| 83 | RepoMaxBytes: s.cfg.MaxLFSBytes, | |
| 84 | Log: s.cfg.Log, | |
|
64ac5071
Keenan Nemetz
17d ago
|
85 | } |
| 86 | serveErr := t.Serve(op, ch, ch) | |
| 87 | code := 0 | |
| 88 | if serveErr != nil { | |
| 89 | code = 1 | |
| 90 | fmt.Fprintf(ch.Stderr(), "vole: lfs %s: %v\n", op, serveErr) | |
| 91 | } | |
| 92 | s.cfg.Log.Info("ssh lfs", "user", user, "op", op, "repo", name, | |
| 93 | "exit", code, "duration", time.Since(start).String()) | |
| 94 | exit(ch, code) | |
| 95 | } | |
| 96 | ||
| 97 | // lfsObjectSize is a small helper for messages and tests. | |
| 98 | func lfsObjectSize(n int64) string { return strconv.FormatInt(n, 10) } | |
| 99 | ||
| 100 | // lfsRoot and lfsMax keep the hook environment well-defined on an instance built | |
| 101 | // without a store (tests, embedders): pre-receive then has nothing to check. | |
| 102 | func lfsRoot(s *lfs.Store) string { | |
| 103 | if s == nil { | |
| 104 | return "" | |
| 105 | } | |
| 106 | return s.Root | |
| 107 | } | |
| 108 | ||
| 109 | func lfsMax(s *lfs.Store) int64 { | |
| 110 | if s == nil { | |
| 111 | return 0 | |
| 112 | } | |
| 113 | return s.MaxBytes | |
| 114 | } |