internal/sshd/sshd.go
263 lines · 7338 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/pem"
9 "errors"
10 "fmt"
11 "io"
12 "log/slog"
13 "net"
14 "os"
15 "path/filepath"
16 "strconv"
17
18 "golang.org/x/crypto/ssh"
19
20 "gitbay.org/gitbay/internal/config"
21 "gitbay.org/gitbay/internal/control"
22 "gitbay.org/gitbay/internal/gitutil"
23 "gitbay.org/gitbay/internal/hookd"
24 "gitbay.org/gitbay/internal/policy"
25 "gitbay.org/gitbay/internal/protocol"
26 "gitbay.org/gitbay/internal/store"
27)
28
29type Server struct {
30 cfg config.Config
31 st *store.Store
32 sshCfg *ssh.ServerConfig
33}
34
35func New(cfg config.Config, st *store.Store) (*Server, error) {
36 s := &Server{cfg: cfg, st: st}
37
38 sc := &ssh.ServerConfig{
39 PublicKeyCallback: s.authenticate,
40 ServerVersion: "SSH-2.0-gitbayd",
41 }
42 signers, err := loadHostKeys(cfg)
43 if err != nil {
44 return nil, err
45 }
46 for _, sg := range signers {
47 sc.AddHostKey(sg)
48 }
49 s.sshCfg = sc
50 return s, nil
51}
52
53// loadHostKeys loads the configured host keys, or generates an ed25519 key
54// under server.root/ssh/ when none are configured.
55func loadHostKeys(cfg config.Config) ([]ssh.Signer, error) {
56 paths := cfg.SSH.HostKeys
57 if len(paths) == 0 {
58 p := filepath.Join(cfg.Server.Root, "ssh", "host_ed25519")
59 if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
60 if err := generateHostKey(p); err != nil {
61 return nil, fmt.Errorf("generating host key: %w", err)
62 }
63 slog.Info("generated ssh host key", "path", p)
64 }
65 paths = []string{p}
66 }
67 var signers []ssh.Signer
68 for _, p := range paths {
69 raw, err := os.ReadFile(p)
70 if err != nil {
71 return nil, fmt.Errorf("host key %s: %w", p, err)
72 }
73 sg, err := ssh.ParsePrivateKey(raw)
74 if err != nil {
75 return nil, fmt.Errorf("host key %s: %w", p, err)
76 }
77 signers = append(signers, sg)
78 }
79 return signers, nil
80}
81
82func generateHostKey(path string) error {
83 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
84 return err
85 }
86 _, priv, err := ed25519.GenerateKey(rand.Reader)
87 if err != nil {
88 return err
89 }
90 block, err := ssh.MarshalPrivateKey(priv, "")
91 if err != nil {
92 return err
93 }
94 return os.WriteFile(path, pem.EncodeToMemory(block), 0o600)
95}
96
97// authenticate resolves the presented key to a registered account. The SSH
98// username is ignored; identity comes from the key alone.
99func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) {
100 fp := ssh.FingerprintSHA256(pub)
101 key, err := s.st.SSHKeyByFingerprint(fp)
102 if err != nil {
103 return nil, fmt.Errorf("unknown key %s", fp)
104 }
105 return &ssh.Permissions{Extensions: map[string]string{
106 "user-id": strconv.FormatInt(key.UserID, 10),
107 "key-id": strconv.FormatInt(key.ID, 10),
108 "scope": key.Scope,
109 }}, nil
110}
111
112// Serve accepts connections on ln until it is closed.
113func (s *Server) Serve(ln net.Listener) error {
114 for {
115 conn, err := ln.Accept()
116 if err != nil {
117 return err
118 }
119 go s.handleConn(conn)
120 }
121}
122
123func (s *Server) handleConn(conn net.Conn) {
124 defer conn.Close()
125 sconn, chans, reqs, err := ssh.NewServerConn(conn, s.sshCfg)
126 if err != nil {
127 return
128 }
129 defer sconn.Close()
130 go ssh.DiscardRequests(reqs)
131
132 for newCh := range chans {
133 if newCh.ChannelType() != "session" {
134 newCh.Reject(ssh.UnknownChannelType, "only session channels are supported")
135 continue
136 }
137 ch, chReqs, err := newCh.Accept()
138 if err != nil {
139 continue
140 }
141 go s.handleSession(sconn, ch, chReqs)
142 }
143}
144
145func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
146 defer ch.Close()
147 for req := range reqs {
148 switch req.Type {
149 case "exec":
150 var payload struct{ Command string }
151 if err := ssh.Unmarshal(req.Payload, &payload); err != nil {
152 req.Reply(false, nil)
153 continue
154 }
155 req.Reply(true, nil)
156 code := s.runExec(sconn, ch, payload.Command)
157 sendExit(ch, code)
158 return
159 case "shell":
160 req.Reply(true, nil)
161 fmt.Fprintf(ch, "gitbay control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL)
162 sendExit(ch, protocol.ExitUsage)
163 return
164 case "pty-req", "env":
165 // Harmless; accept and ignore.
166 req.Reply(true, nil)
167 default:
168 req.Reply(false, nil)
169 }
170 }
171}
172
173func sendExit(ch ssh.Channel, code int) {
174 var msg = struct{ Status uint32 }{uint32(code)}
175 ch.SendRequest("exit-status", false, ssh.Marshal(&msg))
176}
177
178func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) int {
179 ext := sconn.Permissions.Extensions
180 userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
181 keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64)
182 user, err := s.st.UserByID(userID)
183 if err != nil {
184 fmt.Fprintln(ch.Stderr(), "account no longer exists")
185 return protocol.ExitDenied
186 }
187 _ = s.st.TouchSSHKey(keyID)
188 return Exec(s.cfg, s.st, user, ext["scope"], cmdline, ch, ch, ch.Stderr())
189}
190
191// Exec runs one SSH exec command line for an authenticated key. It is the
192// single dispatch path shared by the embedded listener and the system-sshd
193// forced command (gitbayd shell).
194func Exec(cfg config.Config, st *store.Store, user store.User, scope, cmdline string,
195 stdin io.Reader, stdout, stderr io.Writer) int {
196 argv, err := protocol.Tokenize(cmdline)
197 if err != nil {
198 fmt.Fprintf(stderr, "cannot parse command: %v\n", err)
199 return protocol.ExitUsage
200 }
201 if len(argv) > 0 {
202 switch argv[0] {
203 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
204 return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr)
205 }
206 }
207 ctx := &control.Ctx{
208 User: user,
209 Scope: scope,
210 Store: st,
211 Cfg: cfg,
212 Stdin: stdin,
213 Stdout: stdout,
214 Stderr: stderr,
215 }
216 return control.Dispatch(ctx, argv)
217}
218
219// runGit streams a git transport service after access checks.
220func runGit(cfg config.Config, st *store.Store, user store.User, scope string, argv []string,
221 stdin io.Reader, stdout, stderr io.Writer) int {
222 service := argv[0]
223 if len(argv) != 2 {
224 fmt.Fprintf(stderr, "usage: %s <path>\n", service)
225 return protocol.ExitUsage
226 }
227 write := service == "git-receive-pack"
228
229 repo, err := st.RepoByPath(argv[1])
230 if err != nil {
231 fmt.Fprintln(stderr, "repository not found")
232 return protocol.ExitNotFound
233 }
234 grant, err := st.AccessRole(repo.ID, user.ID)
235 if err != nil {
236 fmt.Fprintln(stderr, "internal error")
237 return protocol.ExitFailure
238 }
239 if !policy.CanRead(user, repo, grant) {
240 // Same answer as nonexistence: private repos must not be enumerable.
241 fmt.Fprintln(stderr, "repository not found")
242 return protocol.ExitNotFound
243 }
244 if !policy.ScopeAllowsGit(scope, repo.Path(), write) {
245 fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path())
246 return protocol.ExitDenied
247 }
248 if write && !policy.CanWrite(user, repo, grant) {
249 fmt.Fprintf(stderr, "write access to %s denied\n", repo.Path())
250 return protocol.ExitDenied
251 }
252
253 dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
254 env := []string{
255 hookd.EnvSocket + "=" + hookd.SocketPath(cfg.Server.Root),
256 hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
257 hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10),
258 }
259 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env); err != nil {
260 return protocol.ExitFailure
261 }
262 return protocol.ExitOK
263}