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