internal/sshd/sshd.go
468 lines · 14388 bytes
1// Package sshd implements the embedded SSH listener: public-key auth against
2// registered keys, then dispatch to git transport or control commands.
3package sshd
4
5import (
6 "context"
7 "crypto/ed25519"
8 "crypto/rand"
9 "encoding/base64"
10 "encoding/pem"
11 "errors"
12 "fmt"
13 "io"
14 "log/slog"
15 "net"
16 "os"
17 "path/filepath"
18 "strconv"
19 "sync"
20 "sync/atomic"
21 "time"
22
23 "golang.org/x/crypto/ssh"
24
25 "gitbay.org/gitbay/internal/config"
26 "gitbay.org/gitbay/internal/control"
27 "gitbay.org/gitbay/internal/gitutil"
28 "gitbay.org/gitbay/internal/hookd"
29 "gitbay.org/gitbay/internal/policy"
30 "gitbay.org/gitbay/internal/protocol"
31 "gitbay.org/gitbay/internal/store"
32)
33
34type Server struct {
35 cfg config.Config
36 st *store.Store
37 sshCfg *ssh.ServerConfig
38 authLimiter *rateLimiter
39 sessions sync.WaitGroup // accepted connections still being served
40 mu sync.Mutex
41 conns map[*conn]struct{}
42 stopping chan struct{} // closed by Stop
43 stopOnce sync.Once
44}
45
46// conn is one accepted connection and how many sessions it is running.
47// A CLI's shared connection sits idle between commands; on shutdown an
48// idle connection is closed at once and only a session mid-command is
49// waited for (#141).
50type conn struct {
51 net net.Conn
52 active atomic.Int32
53}
54
55func New(cfg config.Config, st *store.Store) (*Server, error) {
56 s := &Server{cfg: cfg, st: st, authLimiter: newRateLimiter(cfg.Limits.SSHAuthRate, time.Minute), conns: map[*conn]struct{}{}, stopping: make(chan struct{})}
57
58 sc := &ssh.ServerConfig{
59 PublicKeyCallback: s.authenticate,
60 ServerVersion: "SSH-2.0-gitbayd",
61 }
62 signers, err := loadHostKeys(cfg)
63 if err != nil {
64 return nil, err
65 }
66 for _, sg := range signers {
67 sc.AddHostKey(sg)
68 }
69 s.sshCfg = sc
70 return s, nil
71}
72
73// loadHostKeys loads the configured host keys, or generates an ed25519 key
74// under server.root/ssh/ when none are configured.
75func loadHostKeys(cfg config.Config) ([]ssh.Signer, error) {
76 paths := cfg.SSH.HostKeys
77 if len(paths) == 0 {
78 p := filepath.Join(cfg.Server.Root, "ssh", "host_ed25519")
79 if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
80 if err := generateHostKey(p); err != nil {
81 return nil, fmt.Errorf("generating host key: %w", err)
82 }
83 slog.Info("generated ssh host key", "path", p)
84 }
85 paths = []string{p}
86 }
87 var signers []ssh.Signer
88 for _, p := range paths {
89 raw, err := os.ReadFile(p)
90 if err != nil {
91 return nil, fmt.Errorf("host key %s: %w", p, err)
92 }
93 sg, err := ssh.ParsePrivateKey(raw)
94 if err != nil {
95 return nil, fmt.Errorf("host key %s: %w", p, err)
96 }
97 signers = append(signers, sg)
98 }
99 return signers, nil
100}
101
102func generateHostKey(path string) error {
103 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
104 return err
105 }
106 _, priv, err := ed25519.GenerateKey(rand.Reader)
107 if err != nil {
108 return err
109 }
110 block, err := ssh.MarshalPrivateKey(priv, "")
111 if err != nil {
112 return err
113 }
114 return os.WriteFile(path, pem.EncodeToMemory(block), 0o600)
115}
116
117// authenticate resolves the presented key to a registered account. The SSH
118// username is ignored; identity comes from the key alone. When registration
119// is open or invite-based, unknown keys are admitted to run exactly one
120// command: register.
121func (s *Server) authenticate(meta ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) {
122 ip := remoteIP(meta.RemoteAddr())
123 if !s.authLimiter.allow(ip) {
124 // One audit entry per throttled window, not per rejected attempt.
125 if s.authLimiter.firstThrottle(ip) {
126 s.st.Audit(0, "auth.throttled", map[string]any{"ip": ip, "rate": s.cfg.Limits.SSHAuthRate})
127 }
128 return nil, fmt.Errorf("too many authentication attempts; try again shortly")
129 }
130 fp := ssh.FingerprintSHA256(pub)
131 key, err := s.st.SSHKeyByFingerprint(fp)
132 if err != nil && !errors.Is(err, store.ErrNotFound) {
133 // The store, not the key, failed. Neither a failure against the
134 // limiter nor "unknown key": a busy database during a restart
135 // would otherwise lock every client out for a minute.
136 slog.Error("ssh auth: key lookup", "err", err)
137 return nil, fmt.Errorf("authentication temporarily unavailable")
138 }
139 if err != nil {
140 if s.cfg.Registration.Mode != "closed" {
141 return &ssh.Permissions{Extensions: map[string]string{
142 "anon-key": base64.StdEncoding.EncodeToString(pub.Marshal()),
143 }}, nil
144 }
145 s.authLimiter.fail(ip)
146 s.st.Audit(0, "auth.failed", map[string]any{"ip": ip, "fingerprint": fp})
147 return nil, fmt.Errorf("unknown key %s", fp)
148 }
149 s.authLimiter.success(ip)
150 return &ssh.Permissions{Extensions: map[string]string{
151 "user-id": strconv.FormatInt(key.UserID, 10),
152 "key-id": strconv.FormatInt(key.ID, 10),
153 "key-fp": fp,
154 "scope": key.Scope,
155 }}, nil
156}
157
158// Serve accepts connections on ln until it is closed.
159func (s *Server) Serve(ln net.Listener) error {
160 for {
161 nc, err := ln.Accept()
162 if err != nil {
163 return err
164 }
165 c := &conn{net: nc}
166 s.mu.Lock()
167 s.conns[c] = struct{}{}
168 s.mu.Unlock()
169 s.sessions.Add(1)
170 go func() {
171 defer s.sessions.Done()
172 defer func() {
173 s.mu.Lock()
174 delete(s.conns, c)
175 s.mu.Unlock()
176 }()
177 s.handleConn(c)
178 }()
179 }
180}
181
182// Stop ends the commands that run until something happens (build log
183// --follow), so a shutdown drain waits only for work that finishes. It
184// does not close connections; Shutdown does.
185func (s *Server) Stop() {
186 s.stopOnce.Do(func() { close(s.stopping) })
187}
188
189// Shutdown closes every idle connection, then waits for the ones with a
190// session running, or for ctx. The caller closes the listener first; a
191// push in flight completes rather than being cut mid-pack.
192func (s *Server) Shutdown(ctx context.Context) error {
193 s.Stop()
194 s.mu.Lock()
195 for c := range s.conns {
196 if c.active.Load() == 0 {
197 c.net.Close()
198 }
199 }
200 s.mu.Unlock()
201 done := make(chan struct{})
202 go func() {
203 s.sessions.Wait()
204 close(done)
205 }()
206 select {
207 case <-done:
208 return nil
209 case <-ctx.Done():
210 return ctx.Err()
211 }
212}
213
214func (s *Server) handleConn(c *conn) {
215 defer c.net.Close()
216 sconn, chans, reqs, err := ssh.NewServerConn(c.net, s.sshCfg)
217 if err != nil {
218 return
219 }
220 defer sconn.Close()
221 go ssh.DiscardRequests(reqs)
222
223 for newCh := range chans {
224 if newCh.ChannelType() != "session" {
225 newCh.Reject(ssh.UnknownChannelType, "only session channels are supported")
226 continue
227 }
228 ch, chReqs, err := newCh.Accept()
229 if err != nil {
230 continue
231 }
232 c.active.Add(1)
233 go func() {
234 defer c.active.Add(-1)
235 s.handleSession(sconn, ch, chReqs)
236 }()
237 }
238}
239
240func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
241 defer ch.Close()
242 var term control.Term
243 for req := range reqs {
244 switch req.Type {
245 case "exec":
246 var payload struct{ Command string }
247 if err := ssh.Unmarshal(req.Payload, &payload); err != nil {
248 req.Reply(false, nil)
249 continue
250 }
251 req.Reply(true, nil)
252 // x/crypto closes reqs when the client closes the channel. That
253 // is how a follow learns nobody is reading: the CLI's shared
254 // connection outlives a Ctrl-C, the channel does not. Stop
255 // ends it too, for a restart.
256 closed := make(chan struct{})
257 go func() {
258 for r := range reqs {
259 r.Reply(false, nil)
260 }
261 close(closed)
262 }()
263 done := make(chan struct{})
264 go func() {
265 select {
266 case <-closed:
267 case <-s.stopping:
268 }
269 close(done)
270 }()
271 code := s.runExec(sconn, ch, term, payload.Command, done)
272 sendExit(ch, code)
273 return
274 case "shell":
275 req.Reply(true, nil)
276 fmt.Fprintf(ch, "gitbay control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL)
277 sendExit(ch, protocol.ExitUsage)
278 return
279 case "env":
280 var kv struct{ Name, Value string }
281 if ssh.Unmarshal(req.Payload, &kv) == nil && kv.Name == "GITBAY_TERM" {
282 term = control.ParseTerm(kv.Value)
283 }
284 req.Reply(true, nil)
285 case "pty-req":
286 // Harmless; accept and ignore.
287 req.Reply(true, nil)
288 default:
289 req.Reply(false, nil)
290 }
291 }
292}
293
294func sendExit(ch ssh.Channel, code int) {
295 var msg = struct{ Status uint32 }{uint32(code)}
296 ch.SendRequest("exit-status", false, ssh.Marshal(&msg))
297}
298
299func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int {
300 ext := sconn.Permissions.Extensions
301 if blob := ext["anon-key"]; blob != "" {
302 return s.runAnonymous(ch, blob, cmdline)
303 }
304 userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
305 keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64)
306 user, err := s.st.UserByID(userID)
307 if err != nil {
308 fmt.Fprintln(ch.Stderr(), "account no longer exists")
309 return protocol.ExitDenied
310 }
311 _ = s.st.TouchSSHKey(keyID)
312 return Exec(s.cfg, s.st, user, ext["scope"], ext["key-fp"], term, cmdline, ch, ch, ch.Stderr(), done, s.stopping)
313}
314
315// runAnonymous handles a session from an unregistered key: the register
316// command and nothing else.
317func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int {
318 raw, err := base64.StdEncoding.DecodeString(keyB64)
319 if err != nil {
320 return protocol.ExitFailure
321 }
322 pub, err := ssh.ParsePublicKey(raw)
323 if err != nil {
324 return protocol.ExitFailure
325 }
326 argv, err := protocol.Tokenize(cmdline)
327 if err != nil {
328 fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err)
329 return protocol.ExitUsage
330 }
331 if len(argv) == 0 || argv[0] != "register" {
332 fmt.Fprintf(ch.Stderr(), "this key is not registered here. Create an account with:\n ssh <host> register --username <name> %s\n",
333 map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode])
334 return protocol.ExitDenied
335 }
336 return control.RunRegister(s.cfg, s.st, pub, argv, ch, ch.Stderr())
337}
338
339// Exec runs one SSH exec command line for an authenticated key. It is the
340// single dispatch path shared by the embedded listener and the system-sshd
341// forced command (gitbayd shell).
342func Exec(cfg config.Config, st *store.Store, user store.User, scope, source string, term control.Term, cmdline string,
343 stdin io.Reader, stdout, stderr io.Writer, done, stopping <-chan struct{}) int {
344 if user.Disabled {
345 fmt.Fprintln(stderr, "this account is disabled; contact the instance admin")
346 return protocol.ExitDenied
347 }
348 argv, err := protocol.Tokenize(cmdline)
349 if err != nil {
350 fmt.Fprintf(stderr, "cannot parse command: %v\n", err)
351 return protocol.ExitUsage
352 }
353 if len(argv) > 0 {
354 switch argv[0] {
355 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
356 if user.Pending {
357 fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
358 return protocol.ExitDenied
359 }
360 return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr)
361 case "git-lfs-authenticate":
362 // Part of the git transport, not the control plane: usable by
363 // git-scoped and deploy keys, with the transports' access rules.
364 if user.Pending {
365 fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
366 return protocol.ExitDenied
367 }
368 return runLFSAuthenticate(cfg, st, user, scope, argv, stdout, stderr)
369 }
370 }
371 ctx := &control.Ctx{
372 User: user,
373 Scope: scope,
374 Source: source,
375 Term: term,
376 Store: st,
377 Cfg: cfg,
378 Stdin: stdin,
379 Stdout: stdout,
380 Stderr: stderr,
381 Done: done,
382 Stopping: stopping,
383 }
384 return control.Dispatch(ctx, argv)
385}
386
387// runGit streams a git transport service after access checks.
388func runGit(cfg config.Config, st *store.Store, user store.User, scope string, argv []string,
389 stdin io.Reader, stdout, stderr io.Writer) int {
390 service := argv[0]
391 if len(argv) != 2 {
392 fmt.Fprintf(stderr, "usage: %s <path>\n", service)
393 return protocol.ExitUsage
394 }
395 write := service == "git-receive-pack"
396
397 repo, err := st.RepoByPath(argv[1])
398 if err != nil {
399 fmt.Fprintln(stderr, "repository not found")
400 return protocol.ExitNotFound
401 }
402 if policy.IsDeployScope(scope) {
403 // A deploy key authorizes by its binding alone: one repository,
404 // its mode, nothing inherited from whoever registered it. Any
405 // mismatch reads as nonexistence, same as the access rules.
406 if !policy.DeployScopeAllows(scope, repo.ID, write) {
407 fmt.Fprintln(stderr, "repository not found")
408 return protocol.ExitNotFound
409 }
410 } else {
411 grant, err := st.AccessRole(repo.ID, user.ID)
412 if err != nil {
413 fmt.Fprintln(stderr, "internal error")
414 return protocol.ExitFailure
415 }
416 if !policy.CanRead(user, repo, grant) {
417 // Same answer as nonexistence: private repos must not be enumerable.
418 fmt.Fprintln(stderr, "repository not found")
419 return protocol.ExitNotFound
420 }
421 if !policy.ScopeAllowsGit(scope, repo.Path(), write) {
422 fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path())
423 return protocol.ExitDenied
424 }
425 if write && !policy.CanWrite(user, repo, grant) {
426 fmt.Fprintf(stderr, "write access to %s denied\n", repo.Path())
427 return protocol.ExitDenied
428 }
429 }
430 if write && repo.Settings.Archived {
431 fmt.Fprintf(stderr, "%s is archived and read-only\n", repo.Path())
432 return protocol.ExitDenied
433 }
434 if write {
435 if mirrored, err := st.PullMirrored(repo.ID); err == nil && mirrored {
436 fmt.Fprintf(stderr, "%s is a pull mirror: its refs come from the upstream; push there instead\n", repo.Path())
437 return protocol.ExitDenied
438 }
439 }
440
441 dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
442 env := []string{
443 hookd.EnvSocket + "=" + hookd.SocketPath(cfg.Server.Root),
444 hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
445 hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10),
446 hookd.EnvScope + "=" + scope,
447 }
448 // A storage quota on the owner rides the same mechanism as the pack
449 // cap: the pack may be no larger than what the owner has left.
450 maxPack := cfg.Limits.MaxPackBytes
451 if write && repo.OwnerKind == "user" {
452 if limit := control.ByteLimit(st, control.QuotaConfig(cfg), repo.OwnerID); limit > 0 {
453 used := control.OwnedBytes(st, cfg.Server.Root, repo.OwnerID)
454 left := limit - used
455 if left <= 0 {
456 fmt.Fprintf(stderr, "%s's storage quota is used up (%d of %d bytes); delete something, or ask an admin to raise the limit\n", repo.OwnerName, used, limit)
457 return protocol.ExitDenied
458 }
459 if maxPack == 0 || left < maxPack {
460 maxPack = left
461 }
462 }
463 }
464 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack); err != nil {
465 return protocol.ExitFailure
466 }
467 return protocol.ExitOK
468}