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