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