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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 24m ago 32290114
history blame raw 28.8 KiB
1// Package ssh 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.
8package ssh
9
10import (
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 "rodent.software/vole/internal/secure"
23 "strconv"
24 "strings"
25 "sync"
26 "time"
27
28 xssh "golang.org/x/crypto/ssh"
29
30 "rodent.software/vole/ci/logs"
31 "rodent.software/vole/ci/runs"
32 "rodent.software/vole/core/lfs"
33 "rodent.software/vole/forge"
34 "rodent.software/vole/forge/access"
35 "rodent.software/vole/forge/identity"
36 "rodent.software/vole/forge/repo"
37)
38
39const serverVersion = "SSH-2.0-vole"
40
41const (
42 // defaultHandshakeTimeout bounds one connection's handshake. A client that opens
43 // a socket and says nothing would otherwise hold a connection slot, a goroutine
44 // and a file descriptor for as long as it liked. The handshake is a few round
45 // trips, so this is generous for a real client and fatal for a slowloris.
46 defaultHandshakeTimeout = 20 * time.Second
47 // maxChannelsPerConnection bounds the session channels one connection may have
48 // open at once. Each accepted channel starts a goroutine and may start a
49 // process, so the count is the client's leverage on the instance. Git opens one
50 // or two; a session pool opens one per runner.
51 maxChannelsPerConnection = 8
52 // maxCommandBytes bounds the exec request line. The longest legitimate command
53 // is a git verb with a repository path, so anything near this is a client
54 // sending a payload it expects the server to parse.
55 maxCommandBytes = 4 << 10
56 // gitProcessWait is how long a git command waits for a process slot when the
57 // instance is at its cap. A short wait absorbs a burst; past it the client is
58 // told to come back rather than parked forever.
59 gitProcessWait = 30 * time.Second
60)
61
62type Config struct {
63 Identity *identity.Store
64 Repos *repo.Store
65 Instance *forge.Instance
66 Log *slog.Logger
67 HostKeyPath string
68 MaxBlobBytes int64
69 // Quotas for one repository, handed to pre-receive. Zero disables a check.
70 MaxRefs int
71 MaxMetadataEntries int
72 MaxMetadataBytes int64
73 MaxRepoBytes int64
74 MaxLFSBytes int64
75 // AuditPath is handed to hooks so privileged operations can be recorded.
76 AuditPath string
77 // SpoolDir is where post-receive drops accepted push events.
78 SpoolDir string
79 // RunsDB is handed to pre-receive so required checks are enforced there.
80 RunsDB string
81 // IndexDB is handed to post-receive so a push refreshes the read model.
82 IndexDB string
83 // Runs is the CI queue, served to runners over the vole-runner verb.
84 Runs *runs.Store
85 // LFS is the object store behind git-lfs-transfer.
86 LFS *lfs.Store
87 // Logs holds one file per CI attempt, streamed from the runner.
88 Logs *logs.Store
89 // LFSWarnBytes is handed to pre-receive as the threshold for suggesting LFS.
90 LFSWarnBytes int64
91 // Locks is the Git LFS lock table, keyed by account name. Nil means this server
92 // offers no locking, and `git-lfs-transfer` does not advertise the capability.
93 Locks *lfs.LockStore
94 // Lease is how long a runner may hold a job without a heartbeat.
95 Lease time.Duration
96 // MaxJobsPerRepo bounds how many jobs one repository may have running at once.
97 // Zero means no bound, and fairness is ordering alone.
98 MaxJobsPerRepo int
99 // PagesRef is the branch a site is served from. The runner needs it because the
100 // workflow cannot know instance configuration.
101 PagesRef string
102 // PublicURL is where a browser reads this instance, handed to `vole-whoami` so
103 // the CLI can print a link to what it just created. Empty is fine.
104 PublicURL string
105 // MaxConnections bounds simultaneous connections on the listener. Zero uses a
106 // default; see server/ssh's maxConnections.
107 MaxConnections int
108 // MaxGitProcesses bounds git processes across all connections. Zero uses a
109 // default; see server/ssh's maxGitProcesses.
110 MaxGitProcesses int
111 // DispatchWorkflow queues a manual run. The server wires it after construction
112 // because the dispatcher lives on the instance and the SSH server is one of its
113 // callers, not the other way round.
114 DispatchWorkflow func(repo, workflow, ref string, inputs map[string]string, actor string) (int, error)
115 // HandshakeTimeout bounds how long a connection may take to complete the SSH
116 // handshake. Zero uses the default.
117 HandshakeTimeout time.Duration
118}
119
120type Server struct {
121 cfg Config
122 sshCfg *xssh.ServerConfig
123 mu sync.Mutex
124 ln net.Listener
125
126 // conns holds one token per open connection, and gitSlots one per running git
127 // process. Both are channels rather than counters because taking a token is
128 // also the wait.
129 conns chan struct{}
130 gitSlots chan struct{}
131 // done closes when the server is closing, so a lease parked on the queue answers
132 // instead of sitting out its wait.
133 done chan struct{}
134 // live is the connections being served, and handlers waits for their goroutines
135 // and session goroutines. Close ends them and returns only when nothing this
136 // server started is still running: a closed server must not be writing into a
137 // directory somebody is about to remove.
138 live map[net.Conn]struct{}
139 handlers sync.WaitGroup
140 closing bool
141 // hostKey is this instance's public host key as one authorized_keys line. It
142 // travels with a lease so a job that acts as a bot can write the known_hosts
143 // entry it needs without trusting whatever answers a scan.
144 hostKey string
145
146 // refused throttles the "listener is full" warning. A flood is exactly when the
147 // log must not become the next unbounded resource.
148 refusedMu sync.Mutex
149 refusedAt time.Time
150}
151
152// SetDispatchWorkflow wires the instance's manual-run operation after the SSH
153// server exists. It is a setter rather than a Config field because the two objects
154// reference each other through the instance.
155func (s *Server) SetDispatchWorkflow(fn func(repo, workflow, ref string, inputs map[string]string, actor string) (int, error)) {
156 s.cfg.DispatchWorkflow = fn
157}
158
159// maxConnections is the configured listener cap, or the default when unset.
160func (c Config) maxConnections() int {
161 if c.MaxConnections > 0 {
162 return c.MaxConnections
163 }
164 return 64
165}
166
167// handshakeTimeout is the configured handshake bound, or the default when unset.
168func (c Config) handshakeTimeout() time.Duration {
169 if c.HandshakeTimeout > 0 {
170 return c.HandshakeTimeout
171 }
172 return defaultHandshakeTimeout
173}
174
175// maxGitProcesses is the configured process cap, or the default when unset.
176func (c Config) maxGitProcesses() int {
177 if c.MaxGitProcesses > 0 {
178 return c.MaxGitProcesses
179 }
180 return 16
181}
182
183// New loads or creates the host key and prepares the SSH config.
184func New(cfg Config) (*Server, error) {
185 if cfg.Log == nil {
186 cfg.Log = slog.Default()
187 }
188 signer, err := loadOrCreateHostKey(cfg.HostKeyPath, cfg.Log)
189 if err != nil {
190 return nil, err
191 }
192 s := &Server{
193 cfg: cfg,
194 conns: make(chan struct{}, cfg.maxConnections()),
195 gitSlots: make(chan struct{}, cfg.maxGitProcesses()),
196 done: make(chan struct{}),
197 live: make(map[net.Conn]struct{}),
198 hostKey: strings.TrimSpace(string(xssh.MarshalAuthorizedKey(signer.PublicKey()))),
199 }
200 s.sshCfg = &xssh.ServerConfig{
201 ServerVersion: serverVersion,
202 MaxAuthTries: 6,
203 PublicKeyCallback: func(c xssh.ConnMetadata, key xssh.PublicKey) (*xssh.Permissions, error) {
204 fp := xssh.FingerprintSHA256(key)
205 name, ok := cfg.Identity.Lookup(fp)
206 scoped := false
207 if !ok && cfg.Runs != nil {
208 // A run credential is a key for one job. It resolves to the account the
209 // job acts as, but the session it opens is marked, because a job may do
210 // less than its account: it may write as that account, not administer it.
211 cred, err := cfg.Runs.RunCredential(fp, time.Now().UTC())
212 if err == nil && cred != nil {
213 if acct := cfg.Identity.Snapshot().ByName[cred.Account]; acct != nil && acct.Active() {
214 name, ok, scoped = cred.Account, true, true
215 }
216 }
217 }
218 if !ok {
219 cfg.Log.Warn("ssh: unknown key", "fingerprint", fp, "remote", c.RemoteAddr().String())
220 return nil, fmt.Errorf("key not registered")
221 }
222 ext := map[string]string{"user": name, "fingerprint": fp}
223 if scoped {
224 ext["credential"] = fp
225 }
226 return &xssh.Permissions{Extensions: ext}, nil
227 },
228 }
229 s.sshCfg.AddHostKey(signer)
230 return s, nil
231}
232
233func (s *Server) ListenAndServe(addr string) error {
234 ln, err := net.Listen("tcp", addr)
235 if err != nil {
236 return err
237 }
238 return s.Serve(ln)
239}
240
241// Serve accepts connections until the listener is closed.
242func (s *Server) Serve(ln net.Listener) error {
243 s.mu.Lock()
244 s.ln = ln
245 s.mu.Unlock()
246 for {
247 conn, err := ln.Accept()
248 if err != nil {
249 if strings.Contains(err.Error(), "use of closed") {
250 return nil
251 }
252 return err
253 }
254 // Past the cap the connection is refused here, before a handshake, a
255 // goroutine or a git process can be spent on it. The accept loop keeps
256 // running, so the listener is never wedged behind a flood either.
257 select {
258 case s.conns <- struct{}{}:
259 default:
260 s.logRefused(conn)
261 conn.Close()
262 continue
263 }
264 if !s.track(conn) {
265 // Close won the race for this connection: it is not being served.
266 <-s.conns
267 conn.Close()
268 continue
269 }
270 go func(c net.Conn) {
271 defer func() { <-s.conns }()
272 defer s.handlers.Done()
273 defer s.forget(c)
274 s.handleConn(c)
275 }(conn)
276 }
277}
278
279// logRefused reports a connection the listener had no room for, at most once every
280// ten seconds so a flood cannot fill the disk with the news.
281func (s *Server) logRefused(conn net.Conn) {
282 s.refusedMu.Lock()
283 now := time.Now()
284 silent := now.Sub(s.refusedAt) < 10*time.Second
285 if !silent {
286 s.refusedAt = now
287 }
288 s.refusedMu.Unlock()
289 if silent {
290 return
291 }
292 s.cfg.Log.Warn("ssh: connection refused, listener is at its cap",
293 "remote", conn.RemoteAddr().String(), "max", cap(s.conns))
294}
295
296// acquireGitProcess takes one of the instance's git process slots. It waits a
297// bounded time so a burst queues briefly instead of failing instantly, and reports
298// whether it got one; the returned function releases it.
299func (s *Server) acquireGitProcess(wait time.Duration) (func(), bool) {
300 release := func() { <-s.gitSlots }
301 select {
302 case s.gitSlots <- struct{}{}:
303 return release, true
304 default:
305 }
306 t := time.NewTimer(wait)
307 defer t.Stop()
308 select {
309 case s.gitSlots <- struct{}{}:
310 return release, true
311 case <-t.C:
312 return nil, false
313 }
314}
315
316// closeGrace bounds how long Close waits for connection handlers. A handler that is
317// still running after this is reported rather than waited for.
318const closeGrace = 10 * time.Second
319
320// track records a connection to serve, or reports false when the server is already
321// closing. Adding to the WaitGroup under the same lock that sets closing is what
322// makes Close's wait well defined: every handler is either registered before the
323// wait starts or never started.
324func (s *Server) track(conn net.Conn) bool {
325 s.mu.Lock()
326 defer s.mu.Unlock()
327 if s.closing {
328 return false
329 }
330 s.live[conn] = struct{}{}
331 s.handlers.Add(1)
332 return true
333}
334
335func (s *Server) forget(conn net.Conn) {
336 s.mu.Lock()
337 delete(s.live, conn)
338 s.mu.Unlock()
339}
340
341// Close stops the listener, ends the connections being served, and waits for their
342// goroutines. Waiting is the point: a server that has been closed has no handler
343// left to write into its data directory, which is what a caller shutting down — or a
344// test removing the temporary directory it handed the server — has to be able to
345// assume.
346func (s *Server) Close() error {
347 s.mu.Lock()
348 if s.closing {
349 // Closing a closed server is a no-op rather than an error: every caller is a
350 // shutdown path, and none of them can do anything about it.
351 s.mu.Unlock()
352 return nil
353 }
354 s.closing = true
355 ln := s.ln
356 live := make([]net.Conn, 0, len(s.live))
357 for c := range s.live {
358 live = append(live, c)
359 }
360 s.mu.Unlock()
361
362 close(s.done)
363 var err error
364 if ln != nil {
365 err = ln.Close()
366 }
367 for _, c := range live {
368 _ = c.Close()
369 }
370 // The wait is bounded because a git process stuck on a dead filesystem must not
371 // hold up a shutdown forever. Everything a healthy handler does when its
372 // connection ends — noticing the close, writing one last row — takes far less.
373 finished := make(chan struct{})
374 go func() {
375 s.handlers.Wait()
376 close(finished)
377 }()
378 select {
379 case <-finished:
380 case <-time.After(closeGrace):
381 s.cfg.Log.Warn("ssh: connections did not finish closing", "grace", closeGrace)
382 }
383 return err
384}
385
386func (s *Server) handleConn(c net.Conn) {
387 // The handshake is the one part of a connection with a deadline: a clone copies
388 // a repository and a push receives a pack, and both are legitimately slow.
389 if err := c.SetDeadline(time.Now().Add(s.cfg.handshakeTimeout())); err != nil {
390 c.Close()
391 return
392 }
393 sconn, chans, reqs, err := xssh.NewServerConn(c, s.sshCfg)
394 if err != nil {
395 c.Close()
396 return
397 }
398 if err := c.SetDeadline(time.Time{}); err != nil {
399 sconn.Close()
400 return
401 }
402 defer sconn.Close()
403 go xssh.DiscardRequests(reqs)
404
405 // The account name is the authorization identity, resolved from the key at
406 // the handshake and used unchanged everywhere downstream.
407 user := sconn.Permissions.Extensions["user"]
408 fp := sconn.Permissions.Extensions["fingerprint"]
409 s.cfg.Log.Info("ssh connect", "user", user, "remote", sconn.RemoteAddr().String())
410
411 // One token per open session channel. A flood is rejected at the channel layer
412 // rather than spawning a goroutine per request.
413 slots := make(chan struct{}, maxChannelsPerConnection)
414 // Closed waits for the session goroutines, so a connection handler is not done
415 // until the work it started is: Close relies on that.
416 var sessions sync.WaitGroup
417 for newCh := range chans {
418 if newCh.ChannelType() != "session" {
419 newCh.Reject(xssh.UnknownChannelType, "only session channels are supported")
420 continue
421 }
422 select {
423 case slots <- struct{}{}:
424 default:
425 newCh.Reject(xssh.Prohibited, fmt.Sprintf(
426 "at most %d open channels per connection", maxChannelsPerConnection))
427 continue
428 }
429 ch, chReqs, err := newCh.Accept()
430 if err != nil {
431 <-slots
432 continue
433 }
434 sessions.Add(1)
435 go func(ch xssh.Channel, reqs <-chan *xssh.Request) {
436 defer sessions.Done()
437 defer func() { <-slots }()
438 s.handleSession(ch, reqs, user, fp, sconn.RemoteAddr().String())
439 }(ch, chReqs)
440 }
441 sessions.Wait()
442}
443
444func (s *Server) handleSession(ch xssh.Channel, reqs <-chan *xssh.Request, user, fp, remote string) {
445 defer ch.Close()
446 for req := range reqs {
447 switch req.Type {
448 case "exec":
449 var payload struct{ Command string }
450 xssh.Unmarshal(req.Payload, &payload)
451 s.exec(ch, req, payload.Command, user, fp, remote)
452 return
453 case "shell", "pty-req", "subsystem":
454 req.Reply(false, nil)
455 fmt.Fprintf(ch, "vole: interactive shells are not available; push and fetch with git\n")
456 exit(ch, 1)
457 return
458 case "env":
459 // Accepting client env would let a session rewrite VOLE_ACTOR.
460 req.Reply(false, nil)
461 default:
462 req.Reply(false, nil)
463 }
464 }
465}
466
467func (s *Server) exec(ch xssh.Channel, req *xssh.Request, command, user, fp, remote string) {
468 // A refusal is still answered on the channel rather than at the request
469 // layer: replying false makes the client print "exec request failed on
470 // channel 0" and the reason is lost. Nothing is ever executed on these
471 // paths — the message is the whole response.
472 refuse := func(format string, args ...any) {
473 req.Reply(true, nil)
474 fmt.Fprintf(ch, "vole: "+format+"\n", args...)
475 exit(ch, 1)
476 }
477
478 // The exec line is the only client-supplied payload on this path. Refuse an
479 // oversized one before parsing it: the longest legitimate command is a git verb
480 // and a repository path.
481 if len(command) > maxCommandBytes {
482 s.cfg.Log.Warn("ssh: refused oversized command", "user", user, "bytes", len(command))
483 refuse("command line is longer than %d bytes", maxCommandBytes)
484 return
485 }
486
487 argv, err := splitCommand(command)
488 if err != nil {
489 refuse("malformed command")
490 return
491 }
492 // A run credential is a key for one job, and the job may do less than its account:
493 // push and fetch for its own repository, and ask who it is. Every other verb —
494 // administering the instance, reading secrets, leasing work, landing a change — is
495 // an account capability, and a credential that could reach them would be a
496 // long-lived key with extra steps.
497 cred, credErr := s.cfg.Runs.RunCredential(fp, time.Now().UTC())
498 if credErr != nil {
499 s.cfg.Log.Warn("ssh: cannot read a run credential", "fingerprint", fp, "err", credErr)
500 }
501 if cred != nil && !(len(argv) >= 1 && argv[0] == "vole-whoami" && len(argv) <= 2) &&
502 !(len(argv) == 2 && (argv[0] == "git-upload-pack" || argv[0] == "git-receive-pack")) {
503 refuse("a job credential may only push, fetch, and ask who it is")
504 return
505 }
506 // The CI runner is the one non-git verb. It reuses the same key identity rather
507 // than introducing a token, and it is a stream: the runner leases jobs and
508 // reports results over this channel.
509 if len(argv) == 1 && argv[0] == "vole-runner" {
510 req.Reply(true, nil)
511 s.serveRunner(ch, user)
512 exit(ch, 0)
513 return
514 }
515 // `vole change merge` lands a change request. It moves a branch, so the rules
516 // and the run results it needs live here, not in the caller's clone; the verb
517 // takes a repository name and a request id and answers one line.
518 if len(argv) >= 1 && argv[0] == "vole-change-merge" {
519 release, ok := s.acquireGitProcess(gitProcessWait)
520 if !ok {
521 s.cfg.Log.Warn("ssh: no git process slot for a merge", "user", user)
522 refuse("the server is busy running other git commands; try again shortly")
523 return
524 }
525 defer release()
526 req.Reply(true, nil)
527 exit(ch, s.serveChangeMerge(ch, argv, user, remote))
528 return
529 }
530 // `vole change show` asks the instance about the combined result; the clone has
531 // the commits but not the approvals or run results that answer it.
532 if len(argv) >= 1 && argv[0] == "vole-change-status" {
533 release, ok := s.acquireGitProcess(gitProcessWait)
534 if !ok {
535 s.cfg.Log.Warn("ssh: no git process slot for a status", "user", user)
536 refuse("the server is busy running other git commands; try again shortly")
537 return
538 }
539 defer release()
540 req.Reply(true, nil)
541 exit(ch, s.serveChangeStatus(ch, argv, user, remote))
542 return
543 }
544 // Reading the queue is the CLI counterpart of the runs page and a run's log.
545 // The verbs check the repository's read capability and scope a run id to the
546 // repository that owns it, exactly as the pages do.
547 if len(argv) >= 1 && argv[0] == "vole-run-list" {
548 req.Reply(true, nil)
549 exit(ch, s.serveRunList(ch, argv, user))
550 return
551 }
552 if len(argv) >= 1 && argv[0] == "vole-run-show" {
553 req.Reply(true, nil)
554 exit(ch, s.serveRunShow(ch, argv, user))
555 return
556 }
557 if len(argv) >= 1 && argv[0] == "vole-run-log" {
558 req.Reply(true, nil)
559 exit(ch, s.serveRunLog(ch, argv, user))
560 return
561 }
562 // Canceling and rerunning a run are writes to the repository's CI queue, not to a
563 // ref. The verbs are the CLI counterparts of the buttons on the run page, and the
564 // handlers ask the same maintainer capability before they touch the queue.
565 if len(argv) >= 1 && argv[0] == "vole-run-cancel" {
566 req.Reply(true, nil)
567 exit(ch, s.serveRunCancel(ch, argv, user))
568 return
569 }
570 if len(argv) >= 1 && argv[0] == "vole-run-rerun" {
571 req.Reply(true, nil)
572 exit(ch, s.serveRunRerun(ch, argv, user))
573 return
574 }
575 // A manual workflow run is a write to the repository's CI queue, not to a ref.
576 // The verb is the CLI counterpart of the browser's Run workflow form; the
577 // dispatcher re-checks the maintainer capability before it queues anything.
578 if len(argv) >= 1 && argv[0] == "vole-workflow-dispatch" {
579 req.Reply(true, nil)
580 exit(ch, s.serveWorkflowDispatch(ch, argv, user))
581 return
582 }
583 // Repository secrets are a server-side store, so the CLI asks the instance for
584 // them over the same key it pushes with: reading, writing and deleting are all
585 // maintainer capabilities, re-checked here rather than trusted from the client.
586 if len(argv) >= 1 && argv[0] == "vole-secret" {
587 req.Reply(true, nil)
588 exit(ch, s.serveSecret(ch, argv, user))
589 return
590 }
591 // The admin API is a fixed command set, not a shell. It is available only to
592 // accounts marked as instance admins.
593 if len(argv) >= 1 && argv[0] == "vole-admin" {
594 req.Reply(true, nil)
595 exit(ch, s.serveAdmin(ch, argv[1:], user, remote))
596 return
597 }
598 // git-lfs speaks its own protocol over an SSH remote: the client runs
599 // `git-lfs-transfer <repo> upload|download` on the same connection family as
600 // the push. No token changes hands; the key is the identity.
601 if len(argv) == 3 && argv[0] == "git-lfs-transfer" {
602 s.serveLFS(ch, req, argv[1], argv[2], user)
603 return
604 }
605 // The client cannot see the server's database, so the roster is served
606 // read-only over the same key-authenticated channel it already uses for git:
607 // one account name per line. `vole change` uses it to check a typed name
608 // against the active roster, and it is the whole surface — nothing here is
609 // writable.
610 if len(argv) == 1 && argv[0] == "vole-roster" {
611 req.Reply(true, nil)
612 for _, name := range s.cfg.Identity.Roster() {
613 fmt.Fprintln(ch, name)
614 }
615 s.cfg.Log.Info("ssh roster", "user", user, "accounts", s.cfg.Identity.Snapshot().Count)
616 exit(ch, 0)
617 return
618 }
619 // `vole whoami` asks the instance which account the presented key is. That
620 // mapping exists only here: a client's git config holds a name somebody typed,
621 // and guessing from it is how an issue ends up filed by an account that does
622 // not exist. The second field is where a browser reads this instance, empty
623 // when the operator has not set public_url — the caller that asks "who am I"
624 // is usually the same one that wants to print a link to what it just wrote.
625 // With a repository it also answers the effective role there, which is the
626 // question `vole whoami --role` asks.
627 if len(argv) >= 1 && argv[0] == "vole-whoami" && len(argv) <= 2 {
628 repoArg := ""
629 if len(argv) == 2 {
630 name, ok := repoName(argv[1])
631 if !ok {
632 refuse("invalid repository path")
633 return
634 }
635 if cred != nil && cred.Repo != name {
636 refuse("this job's credential is for %q, not %q", cred.Repo, name)
637 return
638 }
639 repoArg = argv[1]
640 }
641 req.Reply(true, nil)
642 exit(ch, s.serveWhoAmI(ch, user, repoArg))
643 return
644 }
645 if len(argv) != 2 {
646 refuse("only git-upload-pack, git-receive-pack, vole-admin, vole-roster, vole-whoami, vole-runner, vole-change-merge, vole-change-status, vole-run-list, vole-run-show, vole-run-log, vole-run-cancel, vole-run-rerun and vole-workflow-dispatch are available")
647 return
648 }
649 verb := argv[0]
650 if verb != "git-upload-pack" && verb != "git-receive-pack" {
651 s.cfg.Log.Warn("ssh: refused command", "user", user, "command", verb)
652 refuse("%q is not permitted; this server only runs git-upload-pack and git-receive-pack", verb)
653 return
654 }
655
656 name, ok := repoName(argv[1])
657 if !ok {
658 refuse("invalid repository path")
659 return
660 }
661
662 // A credential names one repository. Anything else is a job reaching outside the
663 // work it was given, even if its account could reach further.
664 if cred != nil && cred.Repo != name {
665 refuse("this job's credential is for %q, not %q", cred.Repo, name)
666 return
667 }
668 m, err := s.cfg.Repos.Open(name)
669 if err != nil {
670 refuse("repository %q not found", name)
671 return
672 }
673 // Fetching runs the same read rule as the web and HTTP transports. Without
674 // this check any authenticated key could clone a private repository just by
675 // naming it; with it, an instance administrator still may, because they can
676 // already rewrite its rules.
677 if verb == "git-upload-pack" {
678 a, err := m.ACL()
679 if err != nil {
680 refuse("cannot read access rules for %s", name)
681 return
682 }
683 role := a.EffectiveRole(user, s.cfg.Identity.IsAdmin(user))
684 if !role.AtLeast(access.Read) {
685 s.cfg.Log.Warn("ssh: refused read", "user", user, "repo", name, "role", role)
686 // The same shape as a missing repository: do not confirm that a
687 // private repository exists.
688 refuse("repository %q not found", name)
689 return
690 }
691 }
692 // One process slot per git command, instance-wide. The connection and channel
693 // caps bound how many sessions can ask; this bounds what they cost together.
694 release, ok := s.acquireGitProcess(gitProcessWait)
695 if !ok {
696 s.cfg.Log.Warn("ssh: no git process slot", "user", user, "repo", name,
697 "max", cap(s.gitSlots), "wait", gitProcessWait.String())
698 refuse("the server is busy running other git commands; try again shortly")
699 return
700 }
701 defer release()
702
703 if err := req.Reply(true, nil); err != nil {
704 return
705 }
706
707 cmd := exec.Command("git", strings.TrimPrefix(verb, "git-"), m.Dir)
708 cmd.Env = append(os.Environ(),
709 "GIT_DIR="+m.Dir,
710 "VOLE_ACTOR="+user,
711 "VOLE_KEY_ID="+fp,
712 "VOLE_REPO="+name,
713 "VOLE_MAX_BLOB="+strconv.FormatInt(s.cfg.MaxBlobBytes, 10),
714 "VOLE_REPO_ID="+m.ID,
715 "VOLE_MAX_REFS="+strconv.Itoa(s.cfg.MaxRefs),
716 "VOLE_MAX_METADATA_ENTRIES="+strconv.Itoa(s.cfg.MaxMetadataEntries),
717 "VOLE_MAX_METADATA_BYTES="+strconv.FormatInt(s.cfg.MaxMetadataBytes, 10),
718 "VOLE_MAX_REPO_BYTES="+strconv.FormatInt(s.cfg.MaxRepoBytes, 10),
719 "VOLE_MAX_LFS_BYTES="+strconv.FormatInt(s.cfg.MaxLFSBytes, 10),
720 "VOLE_LFS_DIR="+lfsRoot(s.cfg.LFS),
721 "VOLE_LFS_MAX="+strconv.FormatInt(lfsMax(s.cfg.LFS), 10),
722 "VOLE_LFS_WARN="+strconv.FormatInt(s.cfg.LFSWarnBytes, 10),
723 "VOLE_AUDIT="+s.cfg.AuditPath,
724 "VOLE_USERS_DB="+s.cfg.Identity.Path(),
725 "VOLE_SPOOL="+s.cfg.SpoolDir,
726 "VOLE_RUNS_DB="+s.cfg.RunsDB,
727 "VOLE_INDEX_DB="+s.cfg.IndexDB,
728 )
729 cmd.Stdin = struct{ io.Reader }{ch}
730 cmd.Stdout = struct{ io.Writer }{ch}
731 cmd.Stderr = struct{ io.Writer }{ch.Stderr()}
732
733 start := time.Now()
734 runErr := cmd.Run()
735 code := 0
736 if runErr != nil {
737 code = 1
738 if ee, ok := runErr.(*exec.ExitError); ok {
739 code = ee.ExitCode()
740 }
741 }
742 s.cfg.Log.Info("ssh git", "user", user, "verb", strings.TrimPrefix(verb, "git-"),
743 "repo", name, "exit", code, "duration", time.Since(start).String())
744 exit(ch, code)
745}
746
747// repoName turns a git SSH path argument into a repo name. git sends either
748// "/demo.git" (ssh:// URL) or "demo.git" (scp-like), and never anything we
749// should trust as a path — so the result is a validated name, not a path.
750func repoName(arg string) (string, bool) {
751 arg = strings.TrimPrefix(arg, "/")
752 arg = strings.TrimSuffix(arg, "/")
753 if !strings.HasSuffix(arg, ".git") {
754 return "", false
755 }
756 name := strings.TrimSuffix(arg, ".git")
757 if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
758 return "", false
759 }
760 if err := repo.ValidateName(name); err != nil {
761 return "", false
762 }
763 return name, true
764}
765
766// splitCommand understands the quoting git uses: verb 'path'.
767func splitCommand(s string) ([]string, error) {
768 var out []string
769 var cur strings.Builder
770 inSingle, inDouble := false, false
771 started := false
772 for i := 0; i < len(s); i++ {
773 c := s[i]
774 switch {
775 case inSingle:
776 if c == '\'' {
777 inSingle = false
778 } else {
779 cur.WriteByte(c)
780 }
781 case inDouble:
782 if c == '"' {
783 inDouble = false
784 } else if c == '\\' && i+1 < len(s) {
785 i++
786 cur.WriteByte(s[i])
787 } else {
788 cur.WriteByte(c)
789 }
790 case c == '\'':
791 inSingle, started = true, true
792 case c == '"':
793 inDouble, started = true, true
794 case c == '\\' && i+1 < len(s):
795 i++
796 cur.WriteByte(s[i])
797 started = true
798 case c == ' ' || c == '\t':
799 if started {
800 out = append(out, cur.String())
801 cur.Reset()
802 started = false
803 }
804 default:
805 cur.WriteByte(c)
806 started = true
807 }
808 }
809 if inSingle || inDouble {
810 return nil, fmt.Errorf("unterminated quote")
811 }
812 if started {
813 out = append(out, cur.String())
814 }
815 return out, nil
816}
817
818func exit(ch xssh.Channel, code int) {
819 ch.SendRequest("exit-status", false, xssh.Marshal(struct{ Status uint32 }{uint32(code)}))
820}
821
822func loadOrCreateHostKey(path string, log *slog.Logger) (xssh.Signer, error) {
823 // The host key is the instance's long-term secret. A copy another account can
824 // read is already disclosed, so a loose mode is corrected before the key is
825 // used, and a mode that cannot be corrected fails startup: an instance that
826 // cannot protect its key must not serve it.
827 if changed, err := secure.HardenFileIfExists(path); err != nil {
828 return nil, err
829 } else if changed {
830 log.Warn("tightened the SSH host key permissions", "path", path)
831 }
832 if data, err := os.ReadFile(path); err == nil {
833 blk, _ := pem.Decode(data)
834 if blk == nil {
835 return nil, fmt.Errorf("%s: not a PEM file", path)
836 }
837 key, err := x509.ParsePKCS8PrivateKey(blk.Bytes)
838 if err != nil {
839 return nil, fmt.Errorf("%s: %w", path, err)
840 }
841 return xssh.NewSignerFromKey(key)
842 } else if !os.IsNotExist(err) {
843 return nil, err
844 }
845
846 _, priv, err := ed25519.GenerateKey(rand.Reader)
847 if err != nil {
848 return nil, err
849 }
850 der, err := x509.MarshalPKCS8PrivateKey(priv)
851 if err != nil {
852 return nil, err
853 }
854 if err := secure.Mkdir(filepath.Dir(path)); err != nil {
855 return nil, err
856 }
857 blk := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
858 if err := secure.WriteFile(path, blk); err != nil {
859 return nil, err
860 }
861 return xssh.NewSignerFromKey(priv)
862}
863