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.

Blame · server/ssh/server.go

Line attribution on main · 844 line(s)

c5902451
Keenan Nemetz
16d ago
1 // Package ssh is Vole's write transport. Identity comes from the SSH key
a5f19b3c
Keenan Nemetz
17d ago
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.
c5902451
Keenan Nemetz
16d ago
8 package ssh
a5f19b3c
Keenan Nemetz
17d ago
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"
d37d301f
Keenan Nemetz
15h ago
22 "rodent.software/vole/internal/secure"
a5f19b3c
Keenan Nemetz
17d ago
23 "strconv"
24 "strings"
25 "sync"
26 "time"
27
c5902451
Keenan Nemetz
16d ago
28 xssh "golang.org/x/crypto/ssh"
a5f19b3c
Keenan Nemetz
17d ago
29
d37d301f
Keenan Nemetz
15h ago
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"
a5f19b3c
Keenan Nemetz
17d ago
37 )
38
39 const serverVersion = "SSH-2.0-vole"
40
78cd3437
Keenan Nemetz
12d ago
41 const (
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
a5f19b3c
Keenan Nemetz
17d ago
62 type Config struct {
63 Identity *identity.Store
64 Repos *repo.Store
3abbe749
Keenan Nemetz
16d ago
65 Instance *forge.Instance
a5f19b3c
Keenan Nemetz
17d ago
66 Log *slog.Logger
67 HostKeyPath string
68 MaxBlobBytes int64
9e8cc155
Keenan Nemetz
12d ago
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
92b37587
Keenan Nemetz
17d ago
75 // AuditPath is handed to hooks so privileged operations can be recorded.
76 AuditPath string
b44cbfaf
Keenan Nemetz
17d ago
77 // SpoolDir is where post-receive drops accepted push events.
78 SpoolDir string
35eaeccf
Keenan Nemetz
17d ago
79 // RunsDB is handed to pre-receive so required checks are enforced there.
80 RunsDB string
dced06da
Keenan Nemetz
9d ago
81 // IndexDB is handed to post-receive so a push refreshes the read model.
82 IndexDB string
b44cbfaf
Keenan Nemetz
17d ago
83 // Runs is the CI queue, served to runners over the vole-runner verb.
84 Runs *runs.Store
64ac5071
Keenan Nemetz
17d ago
85 // LFS is the object store behind git-lfs-transfer.
86 LFS *lfs.Store
5ece70a5
Keenan Nemetz
10d ago
87 // Logs holds one file per CI attempt, streamed from the runner.
88 Logs *logs.Store
64ac5071
Keenan Nemetz
17d ago
89 // LFSWarnBytes is handed to pre-receive as the threshold for suggesting LFS.
90 LFSWarnBytes int64
62576fa8
Keenan Nemetz
14d ago
91 // Locks is the Git LFS lock table, keyed by account name. Nil means this server
bf1e027c
Keenan Nemetz
16d ago
92 // offers no locking, and `git-lfs-transfer` does not advertise the capability.
c5902451
Keenan Nemetz
16d ago
93 Locks *lfs.LockStore
b44cbfaf
Keenan Nemetz
17d ago
94 // Lease is how long a runner may hold a job without a heartbeat.
95 Lease time.Duration
8d336083
Keenan Nemetz
16d ago
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
88e980b0
Keenan Nemetz
17d ago
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
a63475ce
Keenan Nemetz
17d ago
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
78cd3437
Keenan Nemetz
12d ago
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
eb07ff56
Keenan Nemetz
8d ago
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)
78cd3437
Keenan Nemetz
12d ago
115 // HandshakeTimeout bounds how long a connection may take to complete the SSH
116 // handshake. Zero uses the default.
117 HandshakeTimeout time.Duration
a5f19b3c
Keenan Nemetz
17d ago
118 }
119
120 type Server struct {
121 cfg Config
c5902451
Keenan Nemetz
16d ago
122 sshCfg *xssh.ServerConfig
a5f19b3c
Keenan Nemetz
17d ago
123 mu sync.Mutex
124 ln net.Listener
78cd3437
Keenan Nemetz
12d ago
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{}
786d7ce1
Keenan Nemetz
2d ago
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
6e1fca51
Keenan Nemetz
6d ago
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
78cd3437
Keenan Nemetz
12d ago
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
eb07ff56
Keenan Nemetz
8d ago
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.
155 func (s *Server) SetDispatchWorkflow(fn func(repo, workflow, ref string, inputs map[string]string, actor string) (int, error)) {
156 s.cfg.DispatchWorkflow = fn
157 }
158
78cd3437
Keenan Nemetz
12d ago
159 // maxConnections is the configured listener cap, or the default when unset.
160 func (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.
168 func (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.
176 func (c Config) maxGitProcesses() int {
177 if c.MaxGitProcesses > 0 {
178 return c.MaxGitProcesses
179 }
180 return 16
a5f19b3c
Keenan Nemetz
17d ago
181 }
182
183 // New loads or creates the host key and prepares the SSH config.
184 func New(cfg Config) (*Server, error) {
185 if cfg.Log == nil {
186 cfg.Log = slog.Default()
187 }
78cd3437
Keenan Nemetz
12d ago
188 signer, err := loadOrCreateHostKey(cfg.HostKeyPath, cfg.Log)
a5f19b3c
Keenan Nemetz
17d ago
189 if err != nil {
190 return nil, err
191 }
78cd3437
Keenan Nemetz
12d ago
192 s := &Server{
193 cfg: cfg,
194 conns: make(chan struct{}, cfg.maxConnections()),
195 gitSlots: make(chan struct{}, cfg.maxGitProcesses()),
786d7ce1
Keenan Nemetz
2d ago
196 done: make(chan struct{}),
197 live: make(map[net.Conn]struct{}),
6e1fca51
Keenan Nemetz
6d ago
198 hostKey: strings.TrimSpace(string(xssh.MarshalAuthorizedKey(signer.PublicKey()))),
78cd3437
Keenan Nemetz
12d ago
199 }
c5902451
Keenan Nemetz
16d ago
200 s.sshCfg = &xssh.ServerConfig{
a5f19b3c
Keenan Nemetz
17d ago
201 ServerVersion: serverVersion,
202 MaxAuthTries: 6,
c5902451
Keenan Nemetz
16d ago
203 PublicKeyCallback: func(c xssh.ConnMetadata, key xssh.PublicKey) (*xssh.Permissions, error) {
204 fp := xssh.FingerprintSHA256(key)
62576fa8
Keenan Nemetz
14d ago
205 name, ok := cfg.Identity.Lookup(fp)
6e1fca51
Keenan Nemetz
6d ago
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 }
a5f19b3c
Keenan Nemetz
17d ago
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 }
6e1fca51
Keenan Nemetz
6d ago
222 ext := map[string]string{"user": name, "fingerprint": fp}
223 if scoped {
224 ext["credential"] = fp
225 }
226 return &xssh.Permissions{Extensions: ext}, nil
a5f19b3c
Keenan Nemetz
17d ago
227 },
228 }
229 s.sshCfg.AddHostKey(signer)
230 return s, nil
231 }
232
233 func (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.
242 func (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 }
78cd3437
Keenan Nemetz
12d ago
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 }
786d7ce1
Keenan Nemetz
2d ago
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 }
78cd3437
Keenan Nemetz
12d ago
270 go func(c net.Conn) {
271 defer func() { <-s.conns }()
786d7ce1
Keenan Nemetz
2d ago
272 defer s.handlers.Done()
273 defer s.forget(c)
78cd3437
Keenan Nemetz
12d ago
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.
281 func (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.
299 func (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
a5f19b3c
Keenan Nemetz
17d ago
313 }
314 }
315
786d7ce1
Keenan Nemetz
2d ago
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.
318 const 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.
324 func (s *Server) track(conn net.Conn) bool {
a5f19b3c
Keenan Nemetz
17d ago
325 s.mu.Lock()
326 defer s.mu.Unlock()
786d7ce1
Keenan Nemetz
2d ago
327 if s.closing {
328 return false
329 }
330 s.live[conn] = struct{}{}
331 s.handlers.Add(1)
332 return true
333 }
334
335 func (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.
346 func (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)
a5f19b3c
Keenan Nemetz
17d ago
382 }
786d7ce1
Keenan Nemetz
2d ago
383 return err
a5f19b3c
Keenan Nemetz
17d ago
384 }
385
386 func (s *Server) handleConn(c net.Conn) {
78cd3437
Keenan Nemetz
12d ago
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 }
c5902451
Keenan Nemetz
16d ago
393 sconn, chans, reqs, err := xssh.NewServerConn(c, s.sshCfg)
a5f19b3c
Keenan Nemetz
17d ago
394 if err != nil {
395 c.Close()
396 return
397 }
78cd3437
Keenan Nemetz
12d ago
398 if err := c.SetDeadline(time.Time{}); err != nil {
399 sconn.Close()
400 return
401 }
a5f19b3c
Keenan Nemetz
17d ago
402 defer sconn.Close()
c5902451
Keenan Nemetz
16d ago
403 go xssh.DiscardRequests(reqs)
a5f19b3c
Keenan Nemetz
17d ago
404
62576fa8
Keenan Nemetz
14d ago
405 // The account name is the authorization identity, resolved from the key at
406 // the handshake and used unchanged everywhere downstream.
a5f19b3c
Keenan Nemetz
17d ago
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
78cd3437
Keenan Nemetz
12d ago
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)
786d7ce1
Keenan Nemetz
2d ago
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
a5f19b3c
Keenan Nemetz
17d ago
417 for newCh := range chans {
418 if newCh.ChannelType() != "session" {
c5902451
Keenan Nemetz
16d ago
419 newCh.Reject(xssh.UnknownChannelType, "only session channels are supported")
a5f19b3c
Keenan Nemetz
17d ago
420 continue
421 }
78cd3437
Keenan Nemetz
12d ago
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 }
a5f19b3c
Keenan Nemetz
17d ago
429 ch, chReqs, err := newCh.Accept()
430 if err != nil {
78cd3437
Keenan Nemetz
12d ago
431 <-slots
a5f19b3c
Keenan Nemetz
17d ago
432 continue
433 }
786d7ce1
Keenan Nemetz
2d ago
434 sessions.Add(1)
78cd3437
Keenan Nemetz
12d ago
435 go func(ch xssh.Channel, reqs <-chan *xssh.Request) {
786d7ce1
Keenan Nemetz
2d ago
436 defer sessions.Done()
78cd3437
Keenan Nemetz
12d ago
437 defer func() { <-slots }()
438 s.handleSession(ch, reqs, user, fp, sconn.RemoteAddr().String())
439 }(ch, chReqs)
a5f19b3c
Keenan Nemetz
17d ago
440 }
786d7ce1
Keenan Nemetz
2d ago
441 sessions.Wait()
a5f19b3c
Keenan Nemetz
17d ago
442 }
443
62576fa8
Keenan Nemetz
14d ago
444 func (s *Server) handleSession(ch xssh.Channel, reqs <-chan *xssh.Request, user, fp, remote string) {
a5f19b3c
Keenan Nemetz
17d ago
445 defer ch.Close()
446 for req := range reqs {
447 switch req.Type {
448 case "exec":
449 var payload struct{ Command string }
c5902451
Keenan Nemetz
16d ago
450 xssh.Unmarshal(req.Payload, &payload)
62576fa8
Keenan Nemetz
14d ago
451 s.exec(ch, req, payload.Command, user, fp, remote)
a5f19b3c
Keenan Nemetz
17d ago
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
62576fa8
Keenan Nemetz
14d ago
467 func (s *Server) exec(ch xssh.Channel, req *xssh.Request, command, user, fp, remote string) {
a5f19b3c
Keenan Nemetz
17d ago
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
78cd3437
Keenan Nemetz
12d ago
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
a5f19b3c
Keenan Nemetz
17d ago
487 argv, err := splitCommand(command)
488 if err != nil {
489 refuse("malformed command")
490 return
491 }
6e1fca51
Keenan Nemetz
6d ago
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 }
eec0e603
Keenan Nemetz
5d ago
501 if cred != nil && !(len(argv) >= 1 && argv[0] == "vole-whoami" && len(argv) <= 2) &&
6e1fca51
Keenan Nemetz
6d ago
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 }
b44cbfaf
Keenan Nemetz
17d ago
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)
62576fa8
Keenan Nemetz
14d ago
511 s.serveRunner(ch, user)
b44cbfaf
Keenan Nemetz
17d ago
512 exit(ch, 0)
513 return
514 }
b14a7819
Keenan Nemetz
13d ago
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" {
78cd3437
Keenan Nemetz
12d ago
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()
b14a7819
Keenan Nemetz
13d ago
526 req.Reply(true, nil)
527 exit(ch, s.serveChangeMerge(ch, argv, user, remote))
528 return
529 }
e5478085
Keenan Nemetz
11d ago
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 }
70291860
Keenan Nemetz
6d ago
544 // Canceling and rerunning a run are writes to the repository's CI queue, not to a
545 // ref. The verbs are the CLI counterparts of the buttons on the run page, and the
546 // handlers ask the same maintainer capability before they touch the queue.
deb024d4
Keenan Nemetz
6d ago
547 if len(argv) >= 1 && argv[0] == "vole-run-cancel" {
548 req.Reply(true, nil)
549 exit(ch, s.serveRunCancel(ch, argv, user))
550 return
551 }
70291860
Keenan Nemetz
6d ago
552 if len(argv) >= 1 && argv[0] == "vole-run-rerun" {
553 req.Reply(true, nil)
554 exit(ch, s.serveRunRerun(ch, argv, user))
555 return
556 }
eb07ff56
Keenan Nemetz
8d ago
557 // A manual workflow run is a write to the repository's CI queue, not to a ref.
558 // The verb is the CLI counterpart of the browser's Run workflow form; the
559 // dispatcher re-checks the maintainer capability before it queues anything.
560 if len(argv) >= 1 && argv[0] == "vole-workflow-dispatch" {
561 req.Reply(true, nil)
562 exit(ch, s.serveWorkflowDispatch(ch, argv, user))
563 return
564 }
0c40a1d3
Keenan Nemetz
7d ago
565 // Repository secrets are a server-side store, so the CLI asks the instance for
566 // them over the same key it pushes with: reading, writing and deleting are all
567 // maintainer capabilities, re-checked here rather than trusted from the client.
568 if len(argv) >= 1 && argv[0] == "vole-secret" {
569 req.Reply(true, nil)
570 exit(ch, s.serveSecret(ch, argv, user))
571 return
572 }
3abbe749
Keenan Nemetz
16d ago
573 // The admin API is a fixed command set, not a shell. It is available only to
574 // accounts marked as instance admins.
575 if len(argv) >= 1 && argv[0] == "vole-admin" {
576 req.Reply(true, nil)
62576fa8
Keenan Nemetz
14d ago
577 exit(ch, s.serveAdmin(ch, argv[1:], user, remote))
3abbe749
Keenan Nemetz
16d ago
578 return
579 }
64ac5071
Keenan Nemetz
17d ago
580 // git-lfs speaks its own protocol over an SSH remote: the client runs
581 // `git-lfs-transfer <repo> upload|download` on the same connection family as
582 // the push. No token changes hands; the key is the identity.
583 if len(argv) == 3 && argv[0] == "git-lfs-transfer" {
62576fa8
Keenan Nemetz
14d ago
584 s.serveLFS(ch, req, argv[1], argv[2], user)
64ac5071
Keenan Nemetz
17d ago
585 return
586 }
427c4761
Keenan Nemetz
17d ago
587 // The client cannot see the server's database, so the roster is served
588 // read-only over the same key-authenticated channel it already uses for git:
62576fa8
Keenan Nemetz
14d ago
589 // one account name per line. `vole change` uses it to check a typed name
590 // against the active roster, and it is the whole surface — nothing here is
591 // writable.
427c4761
Keenan Nemetz
17d ago
592 if len(argv) == 1 && argv[0] == "vole-roster" {
593 req.Reply(true, nil)
62576fa8
Keenan Nemetz
14d ago
594 for _, name := range s.cfg.Identity.Roster() {
595 fmt.Fprintln(ch, name)
427c4761
Keenan Nemetz
17d ago
596 }
597 s.cfg.Log.Info("ssh roster", "user", user, "accounts", s.cfg.Identity.Snapshot().Count)
598 exit(ch, 0)
599 return
600 }
a63475ce
Keenan Nemetz
17d ago
601 // `vole whoami` asks the instance which account the presented key is. That
602 // mapping exists only here: a client's git config holds a name somebody typed,
603 // and guessing from it is how an issue ends up filed by an account that does
62576fa8
Keenan Nemetz
14d ago
604 // not exist. The second field is where a browser reads this instance, empty
a63475ce
Keenan Nemetz
17d ago
605 // when the operator has not set public_url — the caller that asks "who am I"
606 // is usually the same one that wants to print a link to what it just wrote.
eec0e603
Keenan Nemetz
5d ago
607 // With a repository it also answers the effective role there, which is the
608 // question `vole whoami --role` asks.
609 if len(argv) >= 1 && argv[0] == "vole-whoami" && len(argv) <= 2 {
610 repoArg := ""
611 if len(argv) == 2 {
612 name, ok := repoName(argv[1])
613 if !ok {
614 refuse("invalid repository path")
615 return
616 }
617 if cred != nil && cred.Repo != name {
618 refuse("this job's credential is for %q, not %q", cred.Repo, name)
619 return
620 }
621 repoArg = argv[1]
622 }
a63475ce
Keenan Nemetz
17d ago
623 req.Reply(true, nil)
eec0e603
Keenan Nemetz
5d ago
624 exit(ch, s.serveWhoAmI(ch, user, repoArg))
a63475ce
Keenan Nemetz
17d ago
625 return
626 }
a5f19b3c
Keenan Nemetz
17d ago
627 if len(argv) != 2 {
70291860
Keenan Nemetz
6d ago
628 refuse("only git-upload-pack, git-receive-pack, vole-admin, vole-roster, vole-whoami, vole-runner, vole-change-merge, vole-change-status, vole-run-cancel, vole-run-rerun and vole-workflow-dispatch are available")
a5f19b3c
Keenan Nemetz
17d ago
629 return
630 }
631 verb := argv[0]
632 if verb != "git-upload-pack" && verb != "git-receive-pack" {
633 s.cfg.Log.Warn("ssh: refused command", "user", user, "command", verb)
634 refuse("%q is not permitted; this server only runs git-upload-pack and git-receive-pack", verb)
635 return
636 }
637
638 name, ok := repoName(argv[1])
639 if !ok {
640 refuse("invalid repository path")
641 return
642 }
31df8bae
Keenan Nemetz
17d ago
643
6e1fca51
Keenan Nemetz
6d ago
644 // A credential names one repository. Anything else is a job reaching outside the
645 // work it was given, even if its account could reach further.
646 if cred != nil && cred.Repo != name {
647 refuse("this job's credential is for %q, not %q", cred.Repo, name)
648 return
649 }
a5f19b3c
Keenan Nemetz
17d ago
650 m, err := s.cfg.Repos.Open(name)
651 if err != nil {
652 refuse("repository %q not found", name)
653 return
654 }
381d6aac
Keenan Nemetz
14d ago
655 // Fetching runs the same read rule as the web and HTTP transports. Without
656 // this check any authenticated key could clone a private repository just by
657 // naming it; with it, an instance administrator still may, because they can
658 // already rewrite its rules.
659 if verb == "git-upload-pack" {
660 a, err := m.ACL()
661 if err != nil {
662 refuse("cannot read access rules for %s", name)
663 return
664 }
665 role := a.EffectiveRole(user, s.cfg.Identity.IsAdmin(user))
666 if !role.AtLeast(access.Read) {
667 s.cfg.Log.Warn("ssh: refused read", "user", user, "repo", name, "role", role)
668 // The same shape as a missing repository: do not confirm that a
669 // private repository exists.
670 refuse("repository %q not found", name)
671 return
672 }
673 }
78cd3437
Keenan Nemetz
12d ago
674 // One process slot per git command, instance-wide. The connection and channel
675 // caps bound how many sessions can ask; this bounds what they cost together.
676 release, ok := s.acquireGitProcess(gitProcessWait)
677 if !ok {
678 s.cfg.Log.Warn("ssh: no git process slot", "user", user, "repo", name,
679 "max", cap(s.gitSlots), "wait", gitProcessWait.String())
680 refuse("the server is busy running other git commands; try again shortly")
681 return
682 }
683 defer release()
684
a5f19b3c
Keenan Nemetz
17d ago
685 if err := req.Reply(true, nil); err != nil {
686 return
687 }
688
689 cmd := exec.Command("git", strings.TrimPrefix(verb, "git-"), m.Dir)
690 cmd.Env = append(os.Environ(),
691 "GIT_DIR="+m.Dir,
692 "VOLE_ACTOR="+user,
693 "VOLE_KEY_ID="+fp,
694 "VOLE_REPO="+name,
695 "VOLE_MAX_BLOB="+strconv.FormatInt(s.cfg.MaxBlobBytes, 10),
9e8cc155
Keenan Nemetz
12d ago
696 "VOLE_REPO_ID="+m.ID,
697 "VOLE_MAX_REFS="+strconv.Itoa(s.cfg.MaxRefs),
698 "VOLE_MAX_METADATA_ENTRIES="+strconv.Itoa(s.cfg.MaxMetadataEntries),
699 "VOLE_MAX_METADATA_BYTES="+strconv.FormatInt(s.cfg.MaxMetadataBytes, 10),
700 "VOLE_MAX_REPO_BYTES="+strconv.FormatInt(s.cfg.MaxRepoBytes, 10),
701 "VOLE_MAX_LFS_BYTES="+strconv.FormatInt(s.cfg.MaxLFSBytes, 10),
64ac5071
Keenan Nemetz
17d ago
702 "VOLE_LFS_DIR="+lfsRoot(s.cfg.LFS),
703 "VOLE_LFS_MAX="+strconv.FormatInt(lfsMax(s.cfg.LFS), 10),
704 "VOLE_LFS_WARN="+strconv.FormatInt(s.cfg.LFSWarnBytes, 10),
92b37587
Keenan Nemetz
17d ago
705 "VOLE_AUDIT="+s.cfg.AuditPath,
427c4761
Keenan Nemetz
17d ago
706 "VOLE_USERS_DB="+s.cfg.Identity.Path(),
b44cbfaf
Keenan Nemetz
17d ago
707 "VOLE_SPOOL="+s.cfg.SpoolDir,
35eaeccf
Keenan Nemetz
17d ago
708 "VOLE_RUNS_DB="+s.cfg.RunsDB,
dced06da
Keenan Nemetz
9d ago
709 "VOLE_INDEX_DB="+s.cfg.IndexDB,
a5f19b3c
Keenan Nemetz
17d ago
710 )
711 cmd.Stdin = struct{ io.Reader }{ch}
712 cmd.Stdout = struct{ io.Writer }{ch}
713 cmd.Stderr = struct{ io.Writer }{ch.Stderr()}
714
715 start := time.Now()
716 runErr := cmd.Run()
717 code := 0
718 if runErr != nil {
719 code = 1
720 if ee, ok := runErr.(*exec.ExitError); ok {
721 code = ee.ExitCode()
722 }
723 }
724 s.cfg.Log.Info("ssh git", "user", user, "verb", strings.TrimPrefix(verb, "git-"),
725 "repo", name, "exit", code, "duration", time.Since(start).String())
726 exit(ch, code)
727 }
728
729 // repoName turns a git SSH path argument into a repo name. git sends either
730 // "/demo.git" (ssh:// URL) or "demo.git" (scp-like), and never anything we
731 // should trust as a path — so the result is a validated name, not a path.
732 func repoName(arg string) (string, bool) {
733 arg = strings.TrimPrefix(arg, "/")
734 arg = strings.TrimSuffix(arg, "/")
735 if !strings.HasSuffix(arg, ".git") {
736 return "", false
737 }
738 name := strings.TrimSuffix(arg, ".git")
739 if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
740 return "", false
741 }
742 if err := repo.ValidateName(name); err != nil {
743 return "", false
744 }
745 return name, true
746 }
747
748 // splitCommand understands the quoting git uses: verb 'path'.
749 func splitCommand(s string) ([]string, error) {
750 var out []string
751 var cur strings.Builder
752 inSingle, inDouble := false, false
753 started := false
754 for i := 0; i < len(s); i++ {
755 c := s[i]
756 switch {
757 case inSingle:
758 if c == '\'' {
759 inSingle = false
760 } else {
761 cur.WriteByte(c)
762 }
763 case inDouble:
764 if c == '"' {
765 inDouble = false
766 } else if c == '\\' && i+1 < len(s) {
767 i++
768 cur.WriteByte(s[i])
769 } else {
770 cur.WriteByte(c)
771 }
772 case c == '\'':
773 inSingle, started = true, true
774 case c == '"':
775 inDouble, started = true, true
776 case c == '\\' && i+1 < len(s):
777 i++
778 cur.WriteByte(s[i])
779 started = true
780 case c == ' ' || c == '\t':
781 if started {
782 out = append(out, cur.String())
783 cur.Reset()
784 started = false
785 }
786 default:
787 cur.WriteByte(c)
788 started = true
789 }
790 }
791 if inSingle || inDouble {
792 return nil, fmt.Errorf("unterminated quote")
793 }
794 if started {
795 out = append(out, cur.String())
796 }
797 return out, nil
798 }
799
c5902451
Keenan Nemetz
16d ago
800 func exit(ch xssh.Channel, code int) {
801 ch.SendRequest("exit-status", false, xssh.Marshal(struct{ Status uint32 }{uint32(code)}))
a5f19b3c
Keenan Nemetz
17d ago
802 }
803
78cd3437
Keenan Nemetz
12d ago
804 func loadOrCreateHostKey(path string, log *slog.Logger) (xssh.Signer, error) {
805 // The host key is the instance's long-term secret. A copy another account can
806 // read is already disclosed, so a loose mode is corrected before the key is
807 // used, and a mode that cannot be corrected fails startup: an instance that
808 // cannot protect its key must not serve it.
809 if changed, err := secure.HardenFileIfExists(path); err != nil {
810 return nil, err
811 } else if changed {
812 log.Warn("tightened the SSH host key permissions", "path", path)
813 }
a5f19b3c
Keenan Nemetz
17d ago
814 if data, err := os.ReadFile(path); err == nil {
815 blk, _ := pem.Decode(data)
816 if blk == nil {
817 return nil, fmt.Errorf("%s: not a PEM file", path)
818 }
819 key, err := x509.ParsePKCS8PrivateKey(blk.Bytes)
820 if err != nil {
821 return nil, fmt.Errorf("%s: %w", path, err)
822 }
c5902451
Keenan Nemetz
16d ago
823 return xssh.NewSignerFromKey(key)
a5f19b3c
Keenan Nemetz
17d ago
824 } else if !os.IsNotExist(err) {
825 return nil, err
826 }
827
828 _, priv, err := ed25519.GenerateKey(rand.Reader)
829 if err != nil {
830 return nil, err
831 }
832 der, err := x509.MarshalPKCS8PrivateKey(priv)
833 if err != nil {
834 return nil, err
835 }
ed181212
Keenan Nemetz
13d ago
836 if err := secure.Mkdir(filepath.Dir(path)); err != nil {
a5f19b3c
Keenan Nemetz
17d ago
837 return nil, err
838 }
839 blk := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
ed181212
Keenan Nemetz
13d ago
840 if err := secure.WriteFile(path, blk); err != nil {
a5f19b3c
Keenan Nemetz
17d ago
841 return nil, err
842 }
c5902451
Keenan Nemetz
16d ago
843 return xssh.NewSignerFromKey(priv)
a5f19b3c
Keenan Nemetz
17d ago
844 }