Files
gitea/modules/ssh/server.go
T
silverwindandGitHub ecbef41c06 refactor: replace gliderlabs/ssh with golang.org/x/crypto/ssh (#38837)
Migrate away from this thin ssh wrapper module while adding more test
coverage.

Removes `sessionPartial`, which hand-copied the layout of a private
`gliderlabs/ssh` struct and reinterpreted a pointer to it via
`reflect.UnsafePointer` to reach the permissions of the authenticated
connection. The layout is unchecked, so an upstream field reorder would
mismatch silently.

The builtin server only needs the session channel with `exec` and
`shell`. Serving those on `x/crypto` drops the hack and the dependency,
since `PublicKeyCallback` returns permissions per key and `x/crypto`
assigns them only after verifying the signature.

Two benign behavior changes:

1. Internal session handler errors report exit status 1 rather than 0,
so a client no longer reads a failure as success.
1. An unusable host key is fatal at startup instead of being replaced by
an ephemeral one that would trigger an error at the client.
2026-08-09 11:32:50 +00:00

145 lines
4.0 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
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)
}
}
}