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.
351 lines
11 KiB
Go
351 lines
11 KiB
Go
// Copyright 2017 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package ssh
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/pem"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
|
|
asymkey_model "gitea.dev/models/asymkey"
|
|
"gitea.dev/modules/generate"
|
|
"gitea.dev/modules/graceful"
|
|
"gitea.dev/modules/log"
|
|
"gitea.dev/modules/process"
|
|
"gitea.dev/modules/setting"
|
|
"gitea.dev/modules/util"
|
|
|
|
gossh "golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
const giteaPermissionExtensionKeyID = "gitea-perm-ext-key-id"
|
|
|
|
func getExitStatusFromError(err error) int {
|
|
if err == nil {
|
|
return 0
|
|
}
|
|
|
|
exitErr, ok := errors.AsType[*exec.ExitError](err)
|
|
if !ok {
|
|
return 1
|
|
}
|
|
|
|
waitStatus, ok := exitErr.Sys().(syscall.WaitStatus)
|
|
if !ok {
|
|
// This is a fallback and should at least let us return something useful
|
|
// when running on Windows, even if it isn't completely accurate.
|
|
if exitErr.Success() {
|
|
return 0
|
|
}
|
|
|
|
return 1
|
|
}
|
|
|
|
return waitStatus.ExitStatus()
|
|
}
|
|
|
|
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]
|
|
|
|
log.Trace("SSH: Payload: %v", session.rawCmd)
|
|
|
|
args := []string{"--config=" + setting.CustomConf, "serv", "key-" + keyID}
|
|
log.Trace("SSH: Arguments: %v", args)
|
|
|
|
gitProtocol := ""
|
|
for _, env := range session.env {
|
|
if strings.HasPrefix(env, "GIT_PROTOCOL=") {
|
|
_, gitProtocol, _ = strings.Cut(env, "=")
|
|
break
|
|
}
|
|
}
|
|
|
|
cmd := process.CommandContext(graceful.GetManager().HammerContext(), setting.AppPath, args...)
|
|
cmd.Env = append(
|
|
os.Environ(),
|
|
"SSH_ORIGINAL_COMMAND="+session.rawCmd,
|
|
"SKIP_MINWINSVC=1",
|
|
"GIT_PROTOCOL="+gitProtocol,
|
|
)
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
log.Error("SSH: StdoutPipe: %v", err)
|
|
return 1
|
|
}
|
|
defer stdout.Close()
|
|
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
log.Error("SSH: StderrPipe: %v", err)
|
|
return 1
|
|
}
|
|
defer stderr.Close()
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
log.Error("SSH: StdinPipe: %v", err)
|
|
return 1
|
|
}
|
|
defer stdin.Close()
|
|
|
|
wg := &sync.WaitGroup{}
|
|
|
|
if err = cmd.Start(); err != nil {
|
|
log.Error("SSH: Start: %v", err)
|
|
return 1
|
|
}
|
|
|
|
go func() {
|
|
defer stdin.Close()
|
|
if _, err := io.Copy(stdin, session); err != nil {
|
|
log.Error("Failed to write session to stdin. %s", err)
|
|
}
|
|
}()
|
|
|
|
wg.Go(func() {
|
|
defer stdout.Close()
|
|
if _, err := io.Copy(session, stdout); err != nil {
|
|
log.Error("Failed to write stdout to session. %s", err)
|
|
}
|
|
})
|
|
|
|
wg.Go(func() {
|
|
defer stderr.Close()
|
|
if _, err := io.Copy(session.Stderr(), stderr); err != nil {
|
|
log.Error("Failed to write stderr to session. %s", err)
|
|
}
|
|
})
|
|
|
|
// Ensure all the output has been written before we wait on the command
|
|
// to exit.
|
|
wg.Wait()
|
|
|
|
// Wait for the command to exit and log any errors we get
|
|
err = cmd.Wait()
|
|
if err != nil {
|
|
// Cannot use errors.Is here because ExitError doesn't implement Is
|
|
// Thus errors.Is will do equality test NOT type comparison
|
|
if _, ok := err.(*exec.ExitError); !ok {
|
|
log.Error("SSH: Wait: %v", err)
|
|
}
|
|
}
|
|
|
|
return getExitStatusFromError(err)
|
|
}
|
|
|
|
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) {
|
|
sshPubKey := string(gossh.MarshalAuthorizedKey(key))
|
|
fingerprint := gossh.FingerprintSHA256(key)
|
|
log.Debug("Handle Public Key: Fingerprint: %s from %s", fingerprint, conn.RemoteAddr())
|
|
|
|
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 {
|
|
log.Debug("Handle Certificate: %s Fingerprint: %s is a certificate", conn.RemoteAddr(), fingerprint)
|
|
|
|
if len(setting.SSH.TrustedUserCAKeys) == 0 {
|
|
log.Warn("Certificate Rejected: No trusted certificate authorities for this server")
|
|
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", conn.RemoteAddr())
|
|
return nil, util.ErrPermissionDenied
|
|
}
|
|
|
|
certChecker := &gossh.CertChecker{
|
|
IsUserAuthority: func(auth gossh.PublicKey) bool {
|
|
marshaled := auth.Marshal()
|
|
for _, k := range setting.SSH.TrustedUserCAKeysParsed {
|
|
if bytes.Equal(marshaled, k.Marshal()) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
},
|
|
}
|
|
|
|
// check the CA of the cert
|
|
if !certChecker.IsUserAuthority(cert.SignatureKey) {
|
|
log.Warn("Principal Rejected: %s Untrusted Authority Signature Fingerprint %s", conn.RemoteAddr(), gossh.FingerprintSHA256(cert.SignatureKey))
|
|
log.Warn("Failed authentication attempt from %s", conn.RemoteAddr())
|
|
return nil, util.ErrPermissionDenied
|
|
}
|
|
|
|
principalLoop: // look for the exact principal
|
|
for _, principal := range cert.ValidPrincipals {
|
|
pkey, err := asymkey_model.SearchPrincipalKey(ctx, principal)
|
|
if err != nil {
|
|
if asymkey_model.IsErrKeyNotExist(err) {
|
|
log.Debug("Principal Rejected: %s Unknown Principal: %s", conn.RemoteAddr(), principal)
|
|
continue principalLoop
|
|
}
|
|
log.Error("SearchPrincipalKey: %v", err)
|
|
return nil, util.ErrPermissionDenied
|
|
}
|
|
|
|
// validate the cert for this principal
|
|
if err := certChecker.CheckCert(principal, cert); err != nil {
|
|
// User is presenting an invalid certificate - STOP any further processing
|
|
log.Warn("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 nil, util.ErrPermissionDenied
|
|
}
|
|
|
|
log.Debug("Successfully authenticated: %s Certificate Fingerprint: %s Principal: %s", conn.RemoteAddr(), fingerprint, principal)
|
|
return keyPermissions(pkey.ID), nil
|
|
}
|
|
|
|
log.Warn("From %s Fingerprint: %s is a certificate, but no valid principals found", conn.RemoteAddr(), fingerprint)
|
|
log.Warn("Failed authentication attempt from %s", conn.RemoteAddr())
|
|
return nil, util.ErrPermissionDenied
|
|
}
|
|
|
|
log.Debug("Handle Public Key: %s Fingerprint: %s is not a certificate", conn.RemoteAddr(), fingerprint)
|
|
|
|
pkey, err := asymkey_model.SearchPublicKeyForSSH(ctx, sshPubKey)
|
|
if err != nil {
|
|
if asymkey_model.IsErrKeyNotExist(err) {
|
|
log.Warn("Unknown public key: %s from %s", fingerprint, conn.RemoteAddr())
|
|
log.Warn("Failed authentication attempt from %s", conn.RemoteAddr())
|
|
return nil, util.ErrPermissionDenied
|
|
}
|
|
log.Error("SearchPublicKeyForSSH: %v", err)
|
|
return nil, util.ErrPermissionDenied
|
|
}
|
|
|
|
log.Debug("Successfully authenticated: %s Public Key Fingerprint: %s", conn.RemoteAddr(), fingerprint)
|
|
return keyPermissions(pkey.ID), nil
|
|
}
|
|
|
|
// Listen starts an SSH server listening on given port.
|
|
func Listen(host string, port int, ciphers, keyExchanges, macs []string) {
|
|
hostKeyFiles := make([]string, 0, len(setting.SSH.ServerHostKeys))
|
|
for _, key := range setting.SSH.ServerHostKeys {
|
|
_, err := os.Stat(key)
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
log.Fatal("Unable to check if %s exists. Error: %v", setting.SSH.ServerHostKeys, err)
|
|
}
|
|
continue
|
|
}
|
|
hostKeyFiles = append(hostKeyFiles, key)
|
|
}
|
|
|
|
if len(hostKeyFiles) == 0 {
|
|
hostKeyDir := filepath.Dir(setting.SSH.ServerHostKeys[0])
|
|
err := os.MkdirAll(hostKeyDir, os.ModePerm)
|
|
if err != nil {
|
|
log.Error("Failed to create dir %s: %v", hostKeyDir, err)
|
|
}
|
|
hostKeyFiles, err = InitDefaultHostKeys(hostKeyDir)
|
|
if err != nil {
|
|
log.Fatal("Failed to generate private key: %v", err)
|
|
}
|
|
}
|
|
|
|
var hostSigners []gossh.Signer
|
|
for _, keyFile := range hostKeyFiles {
|
|
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)
|
|
}()
|
|
}
|
|
|
|
// GenKeyPair make a pair of public and private keys for SSH access.
|
|
// Public key is encoded in the format for inclusion in an OpenSSH authorized_keys file.
|
|
// Private Key generated is PEM encoded
|
|
func GenKeyPair(keyPath string, keyType generate.SSHKeyType, bits int) error {
|
|
publicKey, privateKeyPEM, err := generate.NewSSHKey(keyType, bits)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
public := gossh.MarshalAuthorizedKey(publicKey)
|
|
privateKeyBuf := &bytes.Buffer{}
|
|
err = pem.Encode(privateKeyBuf, privateKeyPEM)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = os.WriteFile(keyPath, privateKeyBuf.Bytes(), 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(keyPath+".pub", public, 0o644)
|
|
}
|
|
|
|
// InitDefaultHostKeys mirrors how ssh-keygen -A operates
|
|
// it runs checks if public and private keys are already defined and creates new ones if not present
|
|
// key naming does not follow the OpenSSH convention due to existing settings being gitea.{KeyType} so generation follows gitea convention
|
|
func InitDefaultHostKeys(path string) (keyFiles []string, _ error) {
|
|
var errs []error
|
|
keyTypes := []generate.SSHKeyType{generate.SSHKeyRSA, generate.SSHKeyECDSA, generate.SSHKeyED25519}
|
|
for _, keyType := range keyTypes {
|
|
keyPath := filepath.Join(path, "gitea."+string(keyType))
|
|
_, errStatPriv := os.Stat(keyPath)
|
|
if errStatPriv != nil {
|
|
err := GenKeyPair(keyPath, keyType, 0)
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
continue
|
|
}
|
|
}
|
|
keyFiles = append(keyFiles, keyPath)
|
|
}
|
|
return keyFiles, errors.Join(errs...)
|
|
}
|