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