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.go +336

@@ -0,0 +1,336 @@
1 // Package sshserver is Vole's write transport. Identity comes from the SSH key
2 // presented at handshake; the only thing a session may do is run git's own
3 // upload-pack or receive-pack against a repository that exists in the store.
4 //
5 // The exec surface is a remote shell until it is made not one, so it is
6 // whitelisted by exact command name and validated path, and everything else is
7 // refused. A test asserts that `sh -c` cannot get through.
8 package sshserver
9
10 import (
11 "crypto/ed25519"
12 "crypto/rand"
13 "crypto/x509"
14 "encoding/pem"
15 "fmt"
16 "io"
17 "log/slog"
18 "net"
19 "os"
20 "os/exec"
21 "path/filepath"
22 "strconv"
23 "strings"
24 "sync"
25 "time"
26
27 "golang.org/x/crypto/ssh"
28
29 "github.com/rodent-software/vole/internal/identity"
30 "github.com/rodent-software/vole/internal/repo"
31 )
32
33 const serverVersion = "SSH-2.0-vole"
34
35 type Config struct {
36 Identity *identity.Store
37 Repos *repo.Store
38 Log *slog.Logger
39 HostKeyPath string
40 MaxBlobBytes int64
41 }
42
43 type Server struct {
44 cfg Config
45 sshCfg *ssh.ServerConfig
46 mu sync.Mutex
47 ln net.Listener
48 }
49
50 // New loads or creates the host key and prepares the SSH config.
51 func New(cfg Config) (*Server, error) {
52 if cfg.Log == nil {
53 cfg.Log = slog.Default()
54 }
55 signer, err := loadOrCreateHostKey(cfg.HostKeyPath)
56 if err != nil {
57 return nil, err
58 }
59 s := &Server{cfg: cfg}
60 s.sshCfg = &ssh.ServerConfig{
61 ServerVersion: serverVersion,
62 MaxAuthTries: 6,
63 PublicKeyCallback: func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
64 fp := ssh.FingerprintSHA256(key)
65 name, ok := cfg.Identity.Lookup(fp)
66 if !ok {
67 cfg.Log.Warn("ssh: unknown key", "fingerprint", fp, "remote", c.RemoteAddr().String())
68 return nil, fmt.Errorf("key not registered")
69 }
70 return &ssh.Permissions{Extensions: map[string]string{
71 "user": name,
72 "fingerprint": fp,
73 }}, nil
74 },
75 }
76 s.sshCfg.AddHostKey(signer)
77 return s, nil
78 }
79
80 func (s *Server) ListenAndServe(addr string) error {
81 ln, err := net.Listen("tcp", addr)
82 if err != nil {
83 return err
84 }
85 return s.Serve(ln)
86 }
87
88 // Serve accepts connections until the listener is closed.
89 func (s *Server) Serve(ln net.Listener) error {
90 s.mu.Lock()
91 s.ln = ln
92 s.mu.Unlock()
93 for {
94 conn, err := ln.Accept()
95 if err != nil {
96 if strings.Contains(err.Error(), "use of closed") {
97 return nil
98 }
99 return err
100 }
101 go s.handleConn(conn)
102 }
103 }
104
105 func (s *Server) Close() error {
106 s.mu.Lock()
107 defer s.mu.Unlock()
108 if s.ln != nil {
109 return s.ln.Close()
110 }
111 return nil
112 }
113
114 func (s *Server) handleConn(c net.Conn) {
115 sconn, chans, reqs, err := ssh.NewServerConn(c, s.sshCfg)
116 if err != nil {
117 c.Close()
118 return
119 }
120 defer sconn.Close()
121 go ssh.DiscardRequests(reqs)
122
123 user := sconn.Permissions.Extensions["user"]
124 fp := sconn.Permissions.Extensions["fingerprint"]
125 s.cfg.Log.Info("ssh connect", "user", user, "remote", sconn.RemoteAddr().String())
126
127 for newCh := range chans {
128 if newCh.ChannelType() != "session" {
129 newCh.Reject(ssh.UnknownChannelType, "only session channels are supported")
130 continue
131 }
132 ch, chReqs, err := newCh.Accept()
133 if err != nil {
134 continue
135 }
136 go s.handleSession(ch, chReqs, user, fp)
137 }
138 }
139
140 func (s *Server) handleSession(ch ssh.Channel, reqs <-chan *ssh.Request, user, fp string) {
141 defer ch.Close()
142 for req := range reqs {
143 switch req.Type {
144 case "exec":
145 var payload struct{ Command string }
146 ssh.Unmarshal(req.Payload, &payload)
147 s.exec(ch, req, payload.Command, user, fp)
148 return
149 case "shell", "pty-req", "subsystem":
150 req.Reply(false, nil)
151 fmt.Fprintf(ch, "vole: interactive shells are not available; push and fetch with git\n")
152 exit(ch, 1)
153 return
154 case "env":
155 // Accepting client env would let a session rewrite VOLE_ACTOR.
156 req.Reply(false, nil)
157 default:
158 req.Reply(false, nil)
159 }
160 }
161 }
162
163 func (s *Server) exec(ch ssh.Channel, req *ssh.Request, command, user, fp string) {
164 // A refusal is still answered on the channel rather than at the request
165 // layer: replying false makes the client print "exec request failed on
166 // channel 0" and the reason is lost. Nothing is ever executed on these
167 // paths — the message is the whole response.
168 refuse := func(format string, args ...any) {
169 req.Reply(true, nil)
170 fmt.Fprintf(ch, "vole: "+format+"\n", args...)
171 exit(ch, 1)
172 }
173
174 argv, err := splitCommand(command)
175 if err != nil {
176 refuse("malformed command")
177 return
178 }
179 if len(argv) != 2 {
180 refuse("only git-upload-pack and git-receive-pack are available")
181 return
182 }
183 verb := argv[0]
184 if verb != "git-upload-pack" && verb != "git-receive-pack" {
185 s.cfg.Log.Warn("ssh: refused command", "user", user, "command", verb)
186 refuse("%q is not permitted; this server only runs git-upload-pack and git-receive-pack", verb)
187 return
188 }
189
190 name, ok := repoName(argv[1])
191 if !ok {
192 refuse("invalid repository path")
193 return
194 }
195 m, err := s.cfg.Repos.Open(name)
196 if err != nil {
197 refuse("repository %q not found", name)
198 return
199 }
200 if err := req.Reply(true, nil); err != nil {
201 return
202 }
203
204 cmd := exec.Command("git", strings.TrimPrefix(verb, "git-"), m.Dir)
205 cmd.Env = append(os.Environ(),
206 "GIT_DIR="+m.Dir,
207 "VOLE_ACTOR="+user,
208 "VOLE_KEY_ID="+fp,
209 "VOLE_REPO="+name,
210 "VOLE_MAX_BLOB="+strconv.FormatInt(s.cfg.MaxBlobBytes, 10),
211 )
212 cmd.Stdin = struct{ io.Reader }{ch}
213 cmd.Stdout = struct{ io.Writer }{ch}
214 cmd.Stderr = struct{ io.Writer }{ch.Stderr()}
215
216 start := time.Now()
217 runErr := cmd.Run()
218 code := 0
219 if runErr != nil {
220 code = 1
221 if ee, ok := runErr.(*exec.ExitError); ok {
222 code = ee.ExitCode()
223 }
224 }
225 s.cfg.Log.Info("ssh git", "user", user, "verb", strings.TrimPrefix(verb, "git-"),
226 "repo", name, "exit", code, "duration", time.Since(start).String())
227 exit(ch, code)
228 }
229
230 // repoName turns a git SSH path argument into a repo name. git sends either
231 // "/demo.git" (ssh:// URL) or "demo.git" (scp-like), and never anything we
232 // should trust as a path — so the result is a validated name, not a path.
233 func repoName(arg string) (string, bool) {
234 arg = strings.TrimPrefix(arg, "/")
235 arg = strings.TrimSuffix(arg, "/")
236 if !strings.HasSuffix(arg, ".git") {
237 return "", false
238 }
239 name := strings.TrimSuffix(arg, ".git")
240 if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
241 return "", false
242 }
243 if err := repo.ValidateName(name); err != nil {
244 return "", false
245 }
246 return name, true
247 }
248
249 // splitCommand understands the quoting git uses: verb 'path'.
250 func splitCommand(s string) ([]string, error) {
251 var out []string
252 var cur strings.Builder
253 inSingle, inDouble := false, false
254 started := false
255 for i := 0; i < len(s); i++ {
256 c := s[i]
257 switch {
258 case inSingle:
259 if c == '\'' {
260 inSingle = false
261 } else {
262 cur.WriteByte(c)
263 }
264 case inDouble:
265 if c == '"' {
266 inDouble = false
267 } else if c == '\\' && i+1 < len(s) {
268 i++
269 cur.WriteByte(s[i])
270 } else {
271 cur.WriteByte(c)
272 }
273 case c == '\'':
274 inSingle, started = true, true
275 case c == '"':
276 inDouble, started = true, true
277 case c == '\\' && i+1 < len(s):
278 i++
279 cur.WriteByte(s[i])
280 started = true
281 case c == ' ' || c == '\t':
282 if started {
283 out = append(out, cur.String())
284 cur.Reset()
285 started = false
286 }
287 default:
288 cur.WriteByte(c)
289 started = true
290 }
291 }
292 if inSingle || inDouble {
293 return nil, fmt.Errorf("unterminated quote")
294 }
295 if started {
296 out = append(out, cur.String())
297 }
298 return out, nil
299 }
300
301 func exit(ch ssh.Channel, code int) {
302 ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{uint32(code)}))
303 }
304
305 func loadOrCreateHostKey(path string) (ssh.Signer, error) {
306 if data, err := os.ReadFile(path); err == nil {
307 blk, _ := pem.Decode(data)
308 if blk == nil {
309 return nil, fmt.Errorf("%s: not a PEM file", path)
310 }
311 key, err := x509.ParsePKCS8PrivateKey(blk.Bytes)
312 if err != nil {
313 return nil, fmt.Errorf("%s: %w", path, err)
314 }
315 return ssh.NewSignerFromKey(key)
316 } else if !os.IsNotExist(err) {
317 return nil, err
318 }
319
320 _, priv, err := ed25519.GenerateKey(rand.Reader)
321 if err != nil {
322 return nil, err
323 }
324 der, err := x509.MarshalPKCS8PrivateKey(priv)
325 if err != nil {
326 return nil, err
327 }
328 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
329 return nil, err
330 }
331 blk := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
332 if err := os.WriteFile(path, blk, 0o600); err != nil {
333 return nil, err
334 }
335 return ssh.NewSignerFromKey(priv)
336 }