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