mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-02 13:44:16 +09:00
With the built-in SSH server, closing the client connection cancelled the `gitea serv` process tree. go-git based clients close it right after the push report, so post-receive was killed and branches, pull requests, webhooks and Actions were not updated. The command now runs under the server's hammer context like with OpenSSH, where it ends once its pipes close or at shutdown.
148 lines
4.4 KiB
Go
148 lines
4.4 KiB
Go
// 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
|
|
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) {
|
|
defer netConn.Close()
|
|
|
|
sshConn, sshChannels, sshReqs, err := gossh.NewServerConn(netConn, srv.newServerConfig(graceful.GetManager().HammerContext()))
|
|
if err != nil {
|
|
// OpenSSH's logs are something like:
|
|
// * disconnect without sending anything: "Connection closed by 1.2.3.4 port 5678"
|
|
// * send invalid bytes: "banner exchange: Connection from 1.2.3.4 port 5678: invalid format"
|
|
// * preauth failed: "Connection closed by authenticating user SYSOP 1.2.3.4 port 5678 [preauth]"
|
|
// * successfully logon and disconnect: "Disconnected from user SYSOP 1.2.3.4 port 5678"
|
|
log.Warn("Failed connection from %s with error: %v", netConn.RemoteAddr(), err)
|
|
return
|
|
}
|
|
defer sshConn.Close()
|
|
|
|
go gossh.DiscardRequests(sshReqs)
|
|
for newChan := range sshChannels {
|
|
if newChan.ChannelType() != "session" {
|
|
_ = newChan.Reject(gossh.UnknownChannelType, "unsupported channel type")
|
|
continue
|
|
}
|
|
go handleSessionChannel(sshConn, newChan)
|
|
}
|
|
}
|
|
|
|
func handleSessionChannel(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}
|
|
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)
|
|
}
|
|
}
|
|
}
|