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