1// Copyright 2017 The Gitea Authors. All rights reserved.
2// SPDX-License-Identifier: MIT
3
4package ssh
5
6import (
7 "bytes"
8 "context"
9 "crypto/rand"
10 "crypto/rsa"
11 "crypto/x509"
12 "encoding/pem"
13 "errors"
14 "io"
15 "net"
16 "os"
17 "os/exec"
18 "path/filepath"
19 "strconv"
20 "strings"
21 "sync"
22 "syscall"
23
24 asymkey_model "forgejo.org/models/asymkey"
25 "forgejo.org/modules/graceful"
26 "forgejo.org/modules/log"
27 "forgejo.org/modules/process"
28 "forgejo.org/modules/setting"
29 "forgejo.org/modules/util"
30
31 "github.com/gliderlabs/ssh"
32 gossh "golang.org/x/crypto/ssh"
33)
34
35func getExitStatusFromError(err error) int {
36 if err == nil {
37 return 0
38 }
39
40 exitErr, ok := err.(*exec.ExitError)
41 if !ok {
42 return 1
43 }
44
45 waitStatus, ok := exitErr.Sys().(syscall.WaitStatus)
46 if !ok {
47 // This is a fallback and should at least let us return something useful
48 // when running on Windows, even if it isn't completely accurate.
49 if exitErr.Success() {
50 return 0
51 }
52
53 return 1
54 }
55
56 return waitStatus.ExitStatus()
57}
58
59func sessionHandler(session ssh.Session) {
60 keyID := session.ConnPermissions().Extensions["forgejo-key-id"]
61
62 command := session.RawCommand()
63
64 log.Trace("SSH: Payload: %v", command)
65
66 args := []string{"--config=" + setting.CustomConf, "serv", "key-" + keyID}
67 log.Trace("SSH: Arguments: %v", args)
68
69 ctx, cancel := context.WithCancel(session.Context())
70 defer cancel()
71
72 gitProtocol := ""
73 for _, env := range session.Environ() {
74 if strings.HasPrefix(env, "GIT_PROTOCOL=") {
75 _, gitProtocol, _ = strings.Cut(env, "=")
76 break
77 }
78 }
79
80 cmd := exec.CommandContext(ctx, setting.AppPath, args...)
81 cmd.Env = append(
82 os.Environ(),
83 "SSH_ORIGINAL_COMMAND="+command,
84 "SKIP_MINWINSVC=1",
85 "GIT_PROTOCOL="+gitProtocol,
86 )
87
88 stdout, err := cmd.StdoutPipe()
89 if err != nil {
90 log.Error("SSH: StdoutPipe: %v", err)
91 return
92 }
93 defer stdout.Close()
94
95 stderr, err := cmd.StderrPipe()
96 if err != nil {
97 log.Error("SSH: StderrPipe: %v", err)
98 return
99 }
100 defer stderr.Close()
101
102 stdin, err := cmd.StdinPipe()
103 if err != nil {
104 log.Error("SSH: StdinPipe: %v", err)
105 return
106 }
107 defer stdin.Close()
108
109 process.SetSysProcAttribute(cmd)
110
111 wg := &sync.WaitGroup{}
112 wg.Add(2)
113
114 if err = cmd.Start(); err != nil {
115 log.Error("SSH: Start: %v", err)
116 return
117 }
118
119 go func() {
120 defer stdin.Close()
121 if _, err := io.Copy(stdin, session); err != nil {
122 log.Error("Failed to write session to stdin. %s", err)
123 }
124 }()
125
126 go func() {
127 defer wg.Done()
128 defer stdout.Close()
129 if _, err := io.Copy(session, stdout); err != nil {
130 log.Error("Failed to write stdout to session. %s", err)
131 }
132 }()
133
134 go func() {
135 defer wg.Done()
136 defer stderr.Close()
137 if _, err := io.Copy(session.Stderr(), stderr); err != nil {
138 log.Error("Failed to write stderr to session. %s", err)
139 }
140 }()
141
142 // Ensure all the output has been written before we wait on the command
143 // to exit.
144 wg.Wait()
145
146 // Wait for the command to exit and log any errors we get
147 err = cmd.Wait()
148 if err != nil {
149 // Cannot use errors.Is here because ExitError doesn't implement Is
150 // Thus errors.Is will do equality test NOT type comparison
151 if _, ok := err.(*exec.ExitError); !ok {
152 log.Error("SSH: Wait: %v", err)
153 }
154 }
155
156 if err := session.Exit(getExitStatusFromError(err)); err != nil && !errors.Is(err, io.EOF) {
157 log.Error("Session failed to exit. %s", err)
158 }
159}
160
161func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {
162 if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary
163 log.Debug("Handle Public Key: Fingerprint: %s from %s", gossh.FingerprintSHA256(key), ctx.RemoteAddr())
164 }
165
166 if ctx.User() != setting.SSH.BuiltinServerUser {
167 log.Warn("Invalid SSH username %s - must use %s for all git operations via ssh", ctx.User(), setting.SSH.BuiltinServerUser)
168 log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr())
169 return false
170 }
171
172 // check if we have a certificate
173 if cert, ok := key.(*gossh.Certificate); ok {
174 if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary
175 log.Debug("Handle Certificate: %s Fingerprint: %s is a certificate", ctx.RemoteAddr(), gossh.FingerprintSHA256(key))
176 }
177
178 if len(setting.SSH.TrustedUserCAKeys) == 0 {
179 log.Warn("Certificate Rejected: No trusted certificate authorities for this server")
180 log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr())
181 return false
182 }
183
184 if cert.CertType != gossh.UserCert {
185 log.Warn("Certificate Rejected: Not a user certificate")
186 log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr())
187 return false
188 }
189
190 // look for the exact principal
191 principalLoop:
192 for _, principal := range cert.ValidPrincipals {
193 pkey, err := asymkey_model.SearchPublicKeyByContentExact(ctx, principal)
194 if err != nil {
195 if asymkey_model.IsErrKeyNotExist(err) {
196 log.Debug("Principal Rejected: %s Unknown Principal: %s", ctx.RemoteAddr(), principal)
197 continue principalLoop
198 }
199 log.Error("SearchPublicKeyByContentExact: %v", err)
200 return false
201 }
202
203 c := &gossh.CertChecker{
204 IsUserAuthority: func(auth gossh.PublicKey) bool {
205 marshaled := auth.Marshal()
206 for _, k := range setting.SSH.TrustedUserCAKeysParsed {
207 if bytes.Equal(marshaled, k.Marshal()) {
208 return true
209 }
210 }
211
212 return false
213 },
214 }
215
216 // check the CA of the cert
217 if !c.IsUserAuthority(cert.SignatureKey) {
218 if log.IsDebug() {
219 log.Debug("Principal Rejected: %s Untrusted Authority Signature Fingerprint %s for Principal: %s", ctx.RemoteAddr(), gossh.FingerprintSHA256(cert.SignatureKey), principal)
220 }
221 continue principalLoop
222 }
223
224 // validate the cert for this principal
225 if err := c.CheckCert(principal, cert); err != nil {
226 // User is presenting an invalid certificate - STOP any further processing
227 log.Error("Invalid Certificate KeyID %s with Signature Fingerprint %s presented for Principal: %s from %s", cert.KeyId, gossh.FingerprintSHA256(cert.SignatureKey), principal, ctx.RemoteAddr())
228 log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr())
229
230 return false
231 }
232
233 if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary
234 log.Debug("Successfully authenticated: %s Certificate Fingerprint: %s Principal: %s", ctx.RemoteAddr(), gossh.FingerprintSHA256(key), principal)
235 }
236 if ctx.Permissions().Extensions == nil {
237 ctx.Permissions().Extensions = map[string]string{}
238 }
239 ctx.Permissions().Extensions["forgejo-key-id"] = strconv.FormatInt(pkey.ID, 10)
240
241 return true
242 }
243
244 log.Warn("From %s Fingerprint: %s is a certificate, but no valid principals found", ctx.RemoteAddr(), gossh.FingerprintSHA256(key))
245 log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr())
246 return false
247 }
248
249 if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary
250 log.Debug("Handle Public Key: %s Fingerprint: %s is not a certificate", ctx.RemoteAddr(), gossh.FingerprintSHA256(key))
251 }
252
253 pkey, err := asymkey_model.SearchPublicKeyByContent(ctx, strings.TrimSpace(string(gossh.MarshalAuthorizedKey(key))))
254 if err != nil {
255 if asymkey_model.IsErrKeyNotExist(err) {
256 log.Warn("Unknown public key: %s from %s", gossh.FingerprintSHA256(key), ctx.RemoteAddr())
257 log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr())
258 return false
259 }
260 log.Error("SearchPublicKeyByContent: %v", err)
261 return false
262 }
263
264 if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary
265 log.Debug("Successfully authenticated: %s Public Key Fingerprint: %s", ctx.RemoteAddr(), gossh.FingerprintSHA256(key))
266 }
267 if ctx.Permissions().Extensions == nil {
268 ctx.Permissions().Extensions = map[string]string{}
269 }
270 ctx.Permissions().Extensions["forgejo-key-id"] = strconv.FormatInt(pkey.ID, 10)
271
272 return true
273}
274
275// sshConnectionFailed logs a failed connection
276// - this mainly exists to give a nice function name in logging
277func sshConnectionFailed(conn net.Conn, err error) {
278 // Log the underlying error with a specific message
279 log.Warn("Failed connection from %s with error: %v", conn.RemoteAddr(), err)
280 // Log with the standard failed authentication from message for simpler fail2ban configuration
281 log.Warn("Failed authentication attempt from %s", conn.RemoteAddr())
282}
283
284// Listen starts a SSH server listens on given port.
285func Listen(host string, port int, ciphers, keyExchanges, macs []string) {
286 srv := ssh.Server{
287 Addr: net.JoinHostPort(host, strconv.Itoa(port)),
288 PublicKeyHandler: publicKeyHandler,
289 Handler: sessionHandler,
290 ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
291 config := &gossh.ServerConfig{}
292 config.KeyExchanges = keyExchanges
293 config.MACs = macs
294 config.Ciphers = ciphers
295 return config
296 },
297 ConnectionFailedCallback: sshConnectionFailed,
298 // We need to explicitly disable the PtyCallback so text displays
299 // properly.
300 PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool {
301 return false
302 },
303 }
304
305 keys := make([]string, 0, len(setting.SSH.ServerHostKeys))
306 for _, key := range setting.SSH.ServerHostKeys {
307 isExist, err := util.IsExist(key)
308 if err != nil {
309 log.Fatal("Unable to check if %s exists. Error: %v", setting.SSH.ServerHostKeys, err)
310 }
311 if isExist {
312 keys = append(keys, key)
313 }
314 }
315
316 if len(keys) == 0 {
317 filePath := filepath.Dir(setting.SSH.ServerHostKeys[0])
318
319 if err := os.MkdirAll(filePath, os.ModePerm); err != nil {
320 log.Error("Failed to create dir %s: %v", filePath, err)
321 }
322
323 err := GenKeyPair(setting.SSH.ServerHostKeys[0])
324 if err != nil {
325 log.Fatal("Failed to generate private key: %v", err)
326 }
327 log.Trace("New private key is generated: %s", setting.SSH.ServerHostKeys[0])
328 keys = append(keys, setting.SSH.ServerHostKeys[0])
329 }
330
331 for _, key := range keys {
332 log.Info("Adding SSH host key: %s", key)
333 err := srv.SetOption(ssh.HostKeyFile(key))
334 if err != nil {
335 log.Error("Failed to set Host Key. %s", err)
336 }
337 }
338
339 go func() {
340 _, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().HammerContext(), "Service: Built-in SSH server", process.SystemProcessType, true)
341 defer finished()
342 listen(&srv)
343 }()
344}
345
346// GenKeyPair make a pair of public and private keys for SSH access.
347// Public key is encoded in the format for inclusion in an OpenSSH authorized_keys file.
348// Private Key generated is PEM encoded
349func GenKeyPair(keyPath string) error {
350 privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
351 if err != nil {
352 return err
353 }
354
355 privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}
356 f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
357 if err != nil {
358 return err
359 }
360 defer func() {
361 if err = f.Close(); err != nil {
362 log.Error("Close: %v", err)
363 }
364 }()
365
366 if err := pem.Encode(f, privateKeyPEM); err != nil {
367 return err
368 }
369
370 // generate public key
371 pub, err := gossh.NewPublicKey(&privateKey.PublicKey)
372 if err != nil {
373 return err
374 }
375
376 public := gossh.MarshalAuthorizedKey(pub)
377 p, err := os.OpenFile(keyPath+".pub", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
378 if err != nil {
379 return err
380 }
381 defer func() {
382 if err = p.Close(); err != nil {
383 log.Error("Close: %v", err)
384 }
385 }()
386 _, err = p.Write(public)
387 return err
388}