diff --git a/assets/go-licenses.json b/assets/go-licenses.json index d0b20c8bad..0304d9e087 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -144,11 +144,6 @@ "path": "github.com/andybalholm/cascadia/LICENSE", "licenseText": "Copyright (c) 2011 Andy Balholm. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, - { - "name": "github.com/anmitsu/go-shlex", - "path": "github.com/anmitsu/go-shlex/LICENSE", - "licenseText": "Copyright (c) anmitsu \u003canmitsu.s@gmail.com\u003e\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" - }, { "name": "github.com/aws/aws-sdk-go-v2", "path": "github.com/aws/aws-sdk-go-v2/LICENSE.txt", @@ -484,11 +479,6 @@ "path": "github.com/git-lfs/pktline/LICENSE.md", "licenseText": "MIT License\n\nCopyright (c) 2014- GitHub, Inc. and Git LFS contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\nNote that Git LFS uses components from other Go modules (included in `vendor/`)\nwhich are under different licenses. See those LICENSE files for details.\n" }, - { - "name": "github.com/gliderlabs/ssh", - "path": "github.com/gliderlabs/ssh/LICENSE", - "licenseText": "Copyright (c) 2016 Glider Labs. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Glider Labs nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" - }, { "name": "github.com/go-asn1-ber/asn1-ber", "path": "github.com/go-asn1-ber/asn1-ber/LICENSE", diff --git a/go.mod b/go.mod index 2354024bc8..97496ca7e6 100644 --- a/go.mod +++ b/go.mod @@ -44,7 +44,6 @@ require ( github.com/felixge/fgprof v0.9.5 github.com/fsnotify/fsnotify v1.10.1 github.com/getkin/kin-openapi v0.145.0 - github.com/gliderlabs/ssh v0.3.8 github.com/go-chi/chi/v5 v5.3.1 github.com/go-chi/cors v1.2.2 github.com/go-co-op/gocron/v2 v2.22.0 @@ -133,7 +132,6 @@ require ( github.com/STARRY-S/zip v0.2.3 // indirect github.com/andybalholm/brotli v1.2.1 // indirect github.com/andybalholm/cascadia v1.3.4 // indirect - github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/aws/aws-sdk-go-v2 v1.43.1 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.32 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.32 // indirect diff --git a/modules/ssh/init.go b/modules/ssh/init.go index 3bd5f3d232..9bd4988b57 100644 --- a/modules/ssh/init.go +++ b/modules/ssh/init.go @@ -11,11 +11,17 @@ import ( "strconv" "strings" + "gitea.dev/modules/graceful" "gitea.dev/modules/log" "gitea.dev/modules/setting" "gitea.dev/modules/util" ) +// builtinUnused informs our cleanup routine that we will not be using a ssh port +func builtinUnused() { + graceful.GetManager().InformCleanup() +} + func Init() error { if setting.SSH.Disabled { builtinUnused() diff --git a/modules/ssh/server.go b/modules/ssh/server.go new file mode 100644 index 0000000000..43020bc081 --- /dev/null +++ b/modules/ssh/server.go @@ -0,0 +1,144 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package ssh + +import ( + "context" + "errors" + "net" + "syscall" + "time" + + "gitea.dev/modules/graceful" + "gitea.dev/modules/log" + "gitea.dev/modules/setting" + + gossh "golang.org/x/crypto/ssh" +) + +// sshServer rejects everything but the "session" channel and its "exec" and "shell" requests, +// because pty, subsystems and forwarding are of no use to a git client. +type sshServer struct { + addr string + hostSigners []gossh.Signer + config gossh.Config +} + +type sshSession struct { + gossh.Channel + conn *gossh.ServerConn + ctx context.Context + rawCmd string + env []string +} + +func (srv *sshServer) newServerConfig(ctx context.Context) *gossh.ServerConfig { + config := &gossh.ServerConfig{ + Config: srv.config, + PublicKeyCallback: func(conn gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) { + return publicKeyHandler(ctx, conn, key) + }, + } + for _, signer := range srv.hostSigners { + config.AddHostKey(signer) // keeps only the newest key per algorithm + } + return config +} + +func listen(srv *sshServer) { + gracefulServer := graceful.NewServer("tcp", srv.addr, "SSH") + gracefulServer.PerWriteTimeout = setting.SSH.PerWriteTimeout + gracefulServer.PerWritePerKbTimeout = setting.SSH.PerWritePerKbTimeout + + err := gracefulServer.ListenAndServe(srv.serve, setting.SSH.UseProxyProtocol) + if err != nil { + select { + case <-graceful.GetManager().IsShutdown(): + log.Error("Failed to start SSH server: %v", err) + default: + log.Fatal("Failed to start SSH server: %v", err) + } + } + log.Info("SSH Listener: %s Closed", srv.addr) +} + +// serve is a graceful.ServeFunction +func (srv *sshServer) serve(listener net.Listener) error { + var acceptDelay time.Duration + for { + conn, err := listener.Accept() + if err != nil { + // out of file descriptors or an aborted handshake, both recover on their own + if !errors.Is(err, syscall.EMFILE) && !errors.Is(err, syscall.ENFILE) && !errors.Is(err, syscall.ECONNABORTED) { + return err + } + acceptDelay = min(max(2*acceptDelay, 5*time.Millisecond), time.Second) + log.Warn("SSH: Accept failed, retrying in %s: %v", acceptDelay, err) + time.Sleep(acceptDelay) + continue + } + acceptDelay = 0 + go srv.handleConn(conn) + } +} + +func (srv *sshServer) handleConn(netConn net.Conn) { + ctx, cancel := context.WithCancel(graceful.GetManager().HammerContext()) + defer cancel() + defer netConn.Close() + + conn, chans, reqs, err := gossh.NewServerConn(netConn, srv.newServerConfig(ctx)) + if err != nil { + sshConnectionFailed(netConn, err) + return + } + + go gossh.DiscardRequests(reqs) + for newChan := range chans { + if newChan.ChannelType() != "session" { + _ = newChan.Reject(gossh.UnknownChannelType, "unsupported channel type") + continue + } + go handleSessionChannel(ctx, conn, newChan) + } +} + +func handleSessionChannel(ctx context.Context, conn *gossh.ServerConn, newChan gossh.NewChannel) { + channel, reqs, err := newChan.Accept() + if err != nil { + log.Error("SSH: Accept session channel: %v", err) + return + } + defer channel.Close() + + session := &sshSession{Channel: channel, conn: conn, ctx: ctx} + for req := range reqs { + switch req.Type { + case "env": + var env struct{ Key, Value string } + if gossh.Unmarshal(req.Payload, &env) != nil { + _ = req.Reply(false, nil) + continue + } + session.env = append(session.env, env.Key+"="+env.Value) + _ = req.Reply(true, nil) + case "exec", "shell": + var payload struct{ Value string } // a "shell" carries no payload, it runs "gitea serv" without a command + if req.Type == "exec" && gossh.Unmarshal(req.Payload, &payload) != nil { + _ = req.Reply(false, nil) + continue + } + session.rawCmd = payload.Value + _ = req.Reply(true, nil) + go gossh.DiscardRequests(reqs) // the client keeps sending while the command runs + status := struct{ Status uint32 }{uint32(sessionHandler(session))} + if _, err := channel.SendRequest("exit-status", false, gossh.Marshal(&status)); err != nil { + log.Error("SSH: Send exit-status: %v", err) + } + return + default: + _ = req.Reply(false, nil) + } + } +} diff --git a/modules/ssh/ssh.go b/modules/ssh/ssh.go index 78e4b0805b..c80b83370b 100644 --- a/modules/ssh/ssh.go +++ b/modules/ssh/ssh.go @@ -13,7 +13,6 @@ import ( "os" "os/exec" "path/filepath" - "reflect" "strconv" "strings" "sync" @@ -25,30 +24,11 @@ import ( "gitea.dev/modules/log" "gitea.dev/modules/process" "gitea.dev/modules/setting" + "gitea.dev/modules/util" - "github.com/gliderlabs/ssh" gossh "golang.org/x/crypto/ssh" ) -// The ssh auth overall works like this: -// NewServerConn: -// serverHandshake+serverAuthenticate: -// PublicKeyCallback: -// PublicKeyHandler (our code): -// reset(ctx.Permissions) and set ctx.Permissions.giteaKeyID = keyID -// pubKey.Verify -// return ctx.Permissions // only reaches here, the pub key is really authenticated -// set conn.Permissions from serverAuthenticate -// sessionHandler(conn) -// -// Then sessionHandler should only use the "verified keyID" from the original ssh conn, but not the ctx one. -// Otherwise, if a user provides 2 keys A (a correct one) and B (public key matches but no private key), -// then only A succeeds to authenticate, sessionHandler will see B's keyID -// -// After x/crypto >= 0.31.0 (fix CVE-2024-45337), the PublicKeyCallback will be called again for the verified key, -// it mitigates the misuse for most cases, it's still good for us to make sure we don't rely on that mitigation -// and do not misuse the PublicKeyCallback: we should only use the verified keyID from the verified ssh conn. - const giteaPermissionExtensionKeyID = "gitea-perm-ext-key-id" func getExitStatusFromError(err error) int { @@ -75,45 +55,20 @@ func getExitStatusFromError(err error) int { return waitStatus.ExitStatus() } -// sessionPartial is the private struct from "gliderlabs/ssh/session.go" -// We need to read the original "conn" field from "ssh.Session interface" which contains the "*session pointer" -// https://github.com/gliderlabs/ssh/blob/d137aad99cd6f2d9495bfd98c755bec4e5dffb8c/session.go#L109-L113 -// If upstream fixes the problem and/or changes the struct, we need to follow. -// If the struct mismatches, the builtin ssh server will fail during integration tests. -type sessionPartial struct { - sync.Mutex - gossh.Channel - conn *gossh.ServerConn -} +func sessionHandler(session *sshSession) int { + // the conn permissions are the ones of the key which really authenticated, see publicKeyHandler + keyID := session.conn.Permissions.Extensions[giteaPermissionExtensionKeyID] -func ptr[T any](intf any) *T { - // https://pkg.go.dev/unsafe#Pointer - // (1) Conversion of a *T1 to Pointer to *T2. - // Provided that T2 is no larger than T1 and that the two share an equivalent memory layout, - // this conversion allows reinterpreting data of one type as data of another type. - v := reflect.ValueOf(intf) - p := v.UnsafePointer() - return (*T)(p) -} - -func sessionHandler(session ssh.Session) { - // here can't use session.Permissions() because it only uses the value from ctx, which might not be the authenticated one. - // so we must use the original ssh conn, which always contains the correct (verified) keyID. - sshSession := ptr[sessionPartial](session) - keyID := sshSession.conn.Permissions.Extensions[giteaPermissionExtensionKeyID] - - command := session.RawCommand() - - log.Trace("SSH: Payload: %v", command) + log.Trace("SSH: Payload: %v", session.rawCmd) args := []string{"--config=" + setting.CustomConf, "serv", "key-" + keyID} log.Trace("SSH: Arguments: %v", args) - ctx, cancel := context.WithCancel(session.Context()) + ctx, cancel := context.WithCancel(session.ctx) defer cancel() gitProtocol := "" - for _, env := range session.Environ() { + for _, env := range session.env { if strings.HasPrefix(env, "GIT_PROTOCOL=") { _, gitProtocol, _ = strings.Cut(env, "=") break @@ -123,7 +78,7 @@ func sessionHandler(session ssh.Session) { cmd := exec.CommandContext(ctx, setting.AppPath, args...) cmd.Env = append( os.Environ(), - "SSH_ORIGINAL_COMMAND="+command, + "SSH_ORIGINAL_COMMAND="+session.rawCmd, "SKIP_MINWINSVC=1", "GIT_PROTOCOL="+gitProtocol, ) @@ -131,21 +86,21 @@ func sessionHandler(session ssh.Session) { stdout, err := cmd.StdoutPipe() if err != nil { log.Error("SSH: StdoutPipe: %v", err) - return + return 1 } defer stdout.Close() stderr, err := cmd.StderrPipe() if err != nil { log.Error("SSH: StderrPipe: %v", err) - return + return 1 } defer stderr.Close() stdin, err := cmd.StdinPipe() if err != nil { log.Error("SSH: StdinPipe: %v", err) - return + return 1 } defer stdin.Close() @@ -155,7 +110,7 @@ func sessionHandler(session ssh.Session) { if err = cmd.Start(); err != nil { log.Error("SSH: Start: %v", err) - return + return 1 } go func() { @@ -193,52 +148,45 @@ func sessionHandler(session ssh.Session) { } } - if err := session.Exit(getExitStatusFromError(err)); err != nil && !errors.Is(err, io.EOF) { - log.Error("Session failed to exit. %s", err) - } + return getExitStatusFromError(err) } -func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool { - // The publicKeyHandler (PublicKeyCallback) only helps to provide the candidate keys to authenticate, - // It does NOT really verify here, so we could only record the related information here. - // After authentication (Verify), the "Permissions" will be assigned to the ssh conn, - // then we can use it in the "session handler" - - // first, reset the ctx permissions (just like https://github.com/gliderlabs/ssh/pull/243 does) - // it shouldn't be reused across different ssh conn (sessions), each pub key should have its own "Permissions" - ctx.Permissions().Permissions = &gossh.Permissions{} - setPermExt := func(keyID int64) { - ctx.Permissions().Permissions.Extensions = map[string]string{ - giteaPermissionExtensionKeyID: strconv.FormatInt(keyID, 10), - } - } +func keyPermissions(keyID int64) *gossh.Permissions { + return &gossh.Permissions{Extensions: map[string]string{ + giteaPermissionExtensionKeyID: strconv.FormatInt(keyID, 10), + }} +} +// publicKeyHandler only offers the candidate keys, it does not verify them. x/crypto assigns the +// returned Permissions to the ssh conn once it verified the signature for that key, so a user +// offering keys A (with a private key) and B (without one) authenticates and is served as A. +func publicKeyHandler(ctx context.Context, conn gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) { if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary - log.Debug("Handle Public Key: Fingerprint: %s from %s", gossh.FingerprintSHA256(key), ctx.RemoteAddr()) + log.Debug("Handle Public Key: Fingerprint: %s from %s", gossh.FingerprintSHA256(key), conn.RemoteAddr()) } - if ctx.User() != setting.SSH.BuiltinServerUser { - log.Warn("Invalid SSH username %s - must use %s for all git operations via ssh", ctx.User(), setting.SSH.BuiltinServerUser) - log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr()) - return false + if conn.User() != setting.SSH.BuiltinServerUser { + log.Warn("Invalid SSH username %s - must use %s for all git operations via ssh", conn.User(), setting.SSH.BuiltinServerUser) + log.Warn("Failed authentication attempt from %s", conn.RemoteAddr()) + return nil, util.ErrPermissionDenied } // check if we have a certificate if cert, ok := key.(*gossh.Certificate); ok { if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary - log.Debug("Handle Certificate: %s Fingerprint: %s is a certificate", ctx.RemoteAddr(), gossh.FingerprintSHA256(key)) + log.Debug("Handle Certificate: %s Fingerprint: %s is a certificate", conn.RemoteAddr(), gossh.FingerprintSHA256(key)) } if len(setting.SSH.TrustedUserCAKeys) == 0 { log.Warn("Certificate Rejected: No trusted certificate authorities for this server") - log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr()) - return false + log.Warn("Failed authentication attempt from %s", conn.RemoteAddr()) + return nil, util.ErrPermissionDenied } if cert.CertType != gossh.UserCert { log.Warn("Certificate Rejected: Not a user certificate") - log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr()) - return false + log.Warn("Failed authentication attempt from %s", conn.RemoteAddr()) + return nil, util.ErrPermissionDenied } // look for the exact principal @@ -247,11 +195,11 @@ func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool { pkey, err := asymkey_model.SearchPublicKeyByContentExact(ctx, principal) if err != nil { if asymkey_model.IsErrKeyNotExist(err) { - log.Debug("Principal Rejected: %s Unknown Principal: %s", ctx.RemoteAddr(), principal) + log.Debug("Principal Rejected: %s Unknown Principal: %s", conn.RemoteAddr(), principal) continue principalLoop } log.Error("SearchPublicKeyByContentExact: %v", err) - return false + return nil, util.ErrPermissionDenied } c := &gossh.CertChecker{ @@ -270,7 +218,7 @@ func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool { // check the CA of the cert if !c.IsUserAuthority(cert.SignatureKey) { if log.IsDebug() { - log.Debug("Principal Rejected: %s Untrusted Authority Signature Fingerprint %s for Principal: %s", ctx.RemoteAddr(), gossh.FingerprintSHA256(cert.SignatureKey), principal) + log.Debug("Principal Rejected: %s Untrusted Authority Signature Fingerprint %s for Principal: %s", conn.RemoteAddr(), gossh.FingerprintSHA256(cert.SignatureKey), principal) } continue principalLoop } @@ -278,44 +226,42 @@ func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool { // validate the cert for this principal if err := c.CheckCert(principal, cert); err != nil { // User is presenting an invalid certificate - STOP any further processing - 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()) - log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr()) + log.Error("Invalid Certificate KeyID %s with Signature Fingerprint %s presented for Principal: %s from %s", cert.KeyId, gossh.FingerprintSHA256(cert.SignatureKey), principal, conn.RemoteAddr()) + log.Warn("Failed authentication attempt from %s", conn.RemoteAddr()) - return false + return nil, util.ErrPermissionDenied } if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary - log.Debug("Successfully authenticated: %s Certificate Fingerprint: %s Principal: %s", ctx.RemoteAddr(), gossh.FingerprintSHA256(key), principal) + log.Debug("Successfully authenticated: %s Certificate Fingerprint: %s Principal: %s", conn.RemoteAddr(), gossh.FingerprintSHA256(key), principal) } - setPermExt(pkey.ID) - return true + return keyPermissions(pkey.ID), nil } - log.Warn("From %s Fingerprint: %s is a certificate, but no valid principals found", ctx.RemoteAddr(), gossh.FingerprintSHA256(key)) - log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr()) - return false + log.Warn("From %s Fingerprint: %s is a certificate, but no valid principals found", conn.RemoteAddr(), gossh.FingerprintSHA256(key)) + log.Warn("Failed authentication attempt from %s", conn.RemoteAddr()) + return nil, util.ErrPermissionDenied } if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary - log.Debug("Handle Public Key: %s Fingerprint: %s is not a certificate", ctx.RemoteAddr(), gossh.FingerprintSHA256(key)) + log.Debug("Handle Public Key: %s Fingerprint: %s is not a certificate", conn.RemoteAddr(), gossh.FingerprintSHA256(key)) } pkey, err := asymkey_model.SearchPublicKeyByContent(ctx, strings.TrimSpace(string(gossh.MarshalAuthorizedKey(key)))) if err != nil { if asymkey_model.IsErrKeyNotExist(err) { - log.Warn("Unknown public key: %s from %s", gossh.FingerprintSHA256(key), ctx.RemoteAddr()) - log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr()) - return false + log.Warn("Unknown public key: %s from %s", gossh.FingerprintSHA256(key), conn.RemoteAddr()) + log.Warn("Failed authentication attempt from %s", conn.RemoteAddr()) + return nil, util.ErrPermissionDenied } log.Error("SearchPublicKeyByContent: %v", err) - return false + return nil, util.ErrPermissionDenied } if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary - log.Debug("Successfully authenticated: %s Public Key Fingerprint: %s", ctx.RemoteAddr(), gossh.FingerprintSHA256(key)) + log.Debug("Successfully authenticated: %s Public Key Fingerprint: %s", conn.RemoteAddr(), gossh.FingerprintSHA256(key)) } - setPermExt(pkey.ID) - return true + return keyPermissions(pkey.ID), nil } // sshConnectionFailed logs a failed connection @@ -329,25 +275,6 @@ func sshConnectionFailed(conn net.Conn, err error) { // Listen starts an SSH server listening on given port. func Listen(host string, port int, ciphers, keyExchanges, macs []string) { - srv := ssh.Server{ - Addr: net.JoinHostPort(host, strconv.Itoa(port)), - PublicKeyHandler: publicKeyHandler, - Handler: sessionHandler, - ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig { - config := &gossh.ServerConfig{} - config.KeyExchanges = keyExchanges - config.MACs = macs - config.Ciphers = ciphers - return config - }, - ConnectionFailedCallback: sshConnectionFailed, - // We need to explicitly disable the PtyCallback so text displays - // properly. - PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool { - return false - }, - } - hostKeyFiles := make([]string, 0, len(setting.SSH.ServerHostKeys)) for _, key := range setting.SSH.ServerHostKeys { _, err := os.Stat(key) @@ -372,17 +299,34 @@ func Listen(host string, port int, ciphers, keyExchanges, macs []string) { } } + var hostSigners []gossh.Signer for _, keyFile := range hostKeyFiles { - log.Info("Adding SSH host key: %s", keyFile) - err := srv.SetOption(ssh.HostKeyFile(keyFile)) - if err != nil { - log.Error("Failed to set Host Key. %s", err) + pemBytes, err := os.ReadFile(keyFile) + if err == nil { + var signer gossh.Signer + if signer, err = gossh.ParsePrivateKey(pemBytes); err == nil { + log.Info("Adding SSH host key: %s", keyFile) + hostSigners = append(hostSigners, signer) + continue + } } + log.Error("Failed to load SSH host key %s: %v", keyFile, err) } + + if len(hostSigners) == 0 { + log.Fatal("No usable SSH host key, tried: %v", hostKeyFiles) + } + + srv := &sshServer{ + addr: net.JoinHostPort(host, strconv.Itoa(port)), + hostSigners: hostSigners, + config: gossh.Config{Ciphers: ciphers, KeyExchanges: keyExchanges, MACs: macs}, + } + go func() { _, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().HammerContext(), "Service: Built-in SSH server", process.SystemProcessType, true) defer finished() - listen(&srv) + listen(srv) }() } diff --git a/modules/ssh/ssh_graceful.go b/modules/ssh/ssh_graceful.go deleted file mode 100644 index b700c44ba0..0000000000 --- a/modules/ssh/ssh_graceful.go +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package ssh - -import ( - "gitea.dev/modules/graceful" - "gitea.dev/modules/log" - "gitea.dev/modules/setting" - - "github.com/gliderlabs/ssh" -) - -func listen(server *ssh.Server) { - gracefulServer := graceful.NewServer("tcp", server.Addr, "SSH") - gracefulServer.PerWriteTimeout = setting.SSH.PerWriteTimeout - gracefulServer.PerWritePerKbTimeout = setting.SSH.PerWritePerKbTimeout - - err := gracefulServer.ListenAndServe(server.Serve, setting.SSH.UseProxyProtocol) - if err != nil { - select { - case <-graceful.GetManager().IsShutdown(): - log.Error("Failed to start SSH server: %v", err) - default: - log.Fatal("Failed to start SSH server: %v", err) - } - } - log.Info("SSH Listener: %s Closed", server.Addr) -} - -// builtinUnused informs our cleanup routine that we will not be using a ssh port -func builtinUnused() { - graceful.GetManager().InformCleanup() -} diff --git a/tests/integration/ssh_key_test.go b/tests/integration/ssh_key_test.go index 9b5eaee880..260bc485c3 100644 --- a/tests/integration/ssh_key_test.go +++ b/tests/integration/ssh_key_test.go @@ -4,19 +4,25 @@ package integration import ( + "bytes" "fmt" + "net" "net/http" "net/url" "os" "path/filepath" + "strconv" "testing" "time" auth_model "gitea.dev/models/auth" "gitea.dev/modules/git" + "gitea.dev/modules/setting" api "gitea.dev/modules/structs" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + gossh "golang.org/x/crypto/ssh" ) func doCheckRepositoryEmptyStatus(ctx APITestContext, isEmpty bool) func(*testing.T) { @@ -42,6 +48,38 @@ func doAddChangesToCheckout(dstPath, filename string) func(*testing.T) { } } +// TestSSHShellWelcome covers the "shell" request, which carries no command payload unlike "exec" +func TestSSHShellWelcome(t *testing.T) { + onGiteaRun(t, func(t *testing.T, _ *url.URL) { + ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteUser) + withKeyFile(t, "welcome-key", func(keyFile string) { + t.Run("CreateUserKey", doAPICreateUserKey(ctx, "welcome-key", keyFile)) + + privateKey, err := os.ReadFile(keyFile) + require.NoError(t, err) + signer, err := gossh.ParsePrivateKey(privateKey) + require.NoError(t, err) + + client, err := gossh.Dial("tcp", net.JoinHostPort(setting.SSH.ListenHost, strconv.Itoa(setting.SSH.ListenPort)), &gossh.ClientConfig{ + User: setting.SSH.BuiltinServerUser, + Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)}, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + }) + require.NoError(t, err) + defer client.Close() + + session, err := client.NewSession() + require.NoError(t, err) + + var stderr bytes.Buffer + session.Stderr = &stderr // "gitea serv" writes the welcome with println, which goes to stderr + require.NoError(t, session.Shell()) + require.NoError(t, session.Wait()) // fails unless the server reports exit status 0 + assert.Contains(t, stderr.String(), "You've successfully authenticated with the key named welcome-key") + }) + }) +} + func TestPushDeployKeyOnEmptyRepo(t *testing.T) { onGiteaRun(t, testPushDeployKeyOnEmptyRepo) }