mirror of https://github.com/harness/drone.git
418 lines
10 KiB
Go
418 lines
10 KiB
Go
// Copyright 2023 Harness, Inc.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package ssh
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/harness/gitness/app/api/controller/repo"
|
|
"github.com/harness/gitness/app/auth"
|
|
"github.com/harness/gitness/app/services/publickey"
|
|
"github.com/harness/gitness/errors"
|
|
"github.com/harness/gitness/git/api"
|
|
"github.com/harness/gitness/types"
|
|
"github.com/harness/gitness/types/enum"
|
|
|
|
"github.com/gliderlabs/ssh"
|
|
"github.com/rs/zerolog/log"
|
|
gossh "golang.org/x/crypto/ssh"
|
|
"golang.org/x/exp/slices"
|
|
)
|
|
|
|
type contextKey string
|
|
|
|
const principalKey = contextKey("principalKey")
|
|
|
|
var (
|
|
allowedCommands = []string{
|
|
"git-upload-pack",
|
|
"git-receive-pack",
|
|
}
|
|
defaultCiphers = []string{
|
|
"chacha20-poly1305@openssh.com",
|
|
"aes128-ctr",
|
|
"aes192-ctr",
|
|
"aes256-ctr",
|
|
"aes128-gcm@openssh.com",
|
|
"aes256-gcm@openssh.com",
|
|
}
|
|
defaultKeyExchanges = []string{
|
|
"curve25519-sha256",
|
|
"curve25519-sha256@libssh.org",
|
|
"ecdh-sha2-nistp256",
|
|
"ecdh-sha2-nistp384",
|
|
"ecdh-sha2-nistp521",
|
|
"diffie-hellman-group14-sha256",
|
|
"diffie-hellman-group14-sha1",
|
|
}
|
|
defaultMACs = []string{
|
|
"hmac-sha2-256-etm@openssh.com",
|
|
"hmac-sha2-512-etm@openssh.com",
|
|
"hmac-sha2-256",
|
|
"hmac-sha2-512",
|
|
}
|
|
defaultServerKeyPath = "ssh/gitness.rsa"
|
|
KeepAliveMsg = "keepalive@openssh.com"
|
|
)
|
|
|
|
type Server struct {
|
|
internal *ssh.Server
|
|
|
|
Host string
|
|
Port int
|
|
DefaultUser string
|
|
|
|
TrustedUserCAKeys []string
|
|
TrustedUserCAKeysParsed []gossh.PublicKey
|
|
Ciphers []string
|
|
KeyExchanges []string
|
|
MACs []string
|
|
HostKeys []string
|
|
KeepAliveInterval time.Duration
|
|
|
|
Verifier publickey.Service
|
|
RepoCtrl *repo.Controller
|
|
}
|
|
|
|
func (s *Server) sanitize() error {
|
|
if s.Port == 0 {
|
|
s.Port = 22
|
|
}
|
|
|
|
if len(s.Ciphers) == 0 {
|
|
s.Ciphers = defaultCiphers
|
|
}
|
|
|
|
if len(s.KeyExchanges) == 0 {
|
|
s.KeyExchanges = defaultKeyExchanges
|
|
}
|
|
|
|
if len(s.MACs) == 0 {
|
|
s.MACs = defaultMACs
|
|
}
|
|
|
|
if s.KeepAliveInterval == 0 {
|
|
s.KeepAliveInterval = 5000
|
|
}
|
|
|
|
if s.RepoCtrl == nil {
|
|
return errors.InvalidArgument("repository controller is needed to run git service pack commands")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) ListenAndServe() error {
|
|
err := s.sanitize()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to sanitize server defaults: %w", err)
|
|
}
|
|
s.internal = &ssh.Server{
|
|
Addr: net.JoinHostPort(s.Host, strconv.Itoa(s.Port)),
|
|
Handler: s.sessionHandler,
|
|
PublicKeyHandler: s.publicKeyHandler,
|
|
PtyCallback: func(ssh.Context, ssh.Pty) bool {
|
|
return false
|
|
},
|
|
ConnectionFailedCallback: sshConnectionFailed,
|
|
ServerConfigCallback: func(ssh.Context) *gossh.ServerConfig {
|
|
config := &gossh.ServerConfig{}
|
|
config.KeyExchanges = s.KeyExchanges
|
|
config.MACs = s.MACs
|
|
config.Ciphers = s.Ciphers
|
|
return config
|
|
},
|
|
}
|
|
|
|
err = s.setupHostKeys()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to setup host keys: %w", err)
|
|
}
|
|
|
|
log.Debug().Msgf("starting ssh service....: %v", s.internal.Addr)
|
|
err = s.internal.ListenAndServe()
|
|
if err != nil {
|
|
return fmt.Errorf("ssh service not running: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) setupHostKeys() error {
|
|
keys := make([]string, 0, len(s.HostKeys))
|
|
for _, key := range s.HostKeys {
|
|
_, err := os.Stat(key)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read provided host key %q: %w", key, err)
|
|
}
|
|
keys = append(keys, key)
|
|
}
|
|
|
|
if len(keys) == 0 {
|
|
log.Debug().Msg("no host key provided - setup default key if it doesn't exist yet")
|
|
err := createKeyIfNotExists(defaultServerKeyPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to setup default key %q: %w", defaultServerKeyPath, err)
|
|
}
|
|
keys = append(keys, defaultServerKeyPath)
|
|
}
|
|
|
|
// set keys to internal ssh server
|
|
for _, key := range keys {
|
|
err := s.internal.SetOption(ssh.HostKeyFile(key))
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to set host key to ssh server")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) Shutdown(ctx context.Context) error {
|
|
log.Debug().Msgf("stopping ssh service: %v", s.internal.Addr)
|
|
err := s.internal.Shutdown(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to stop ssh service: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) sessionHandler(session ssh.Session) {
|
|
command := session.RawCommand()
|
|
|
|
principal, ok := session.Context().Value(principalKey).(*types.PrincipalInfo)
|
|
if !ok {
|
|
_, _ = fmt.Fprintf(session.Stderr(), "principal not found or empty")
|
|
return
|
|
}
|
|
|
|
parts := strings.Fields(command)
|
|
if len(parts) < 2 {
|
|
_, _ = fmt.Fprintf(session.Stderr(), "command %q must have an argument\n", command)
|
|
return
|
|
}
|
|
|
|
// first part is git service pack command: git-upload-pack, git-receive-pack
|
|
gitCommand := parts[0]
|
|
if !slices.Contains(allowedCommands, gitCommand) {
|
|
_, _ = fmt.Fprintf(session.Stderr(), "command not supported: %q\n", command)
|
|
return
|
|
}
|
|
|
|
gitServicePack := strings.TrimPrefix(gitCommand, "git-")
|
|
service, err := enum.ParseGitServiceType(gitServicePack)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(session.Stderr(), "failed to parse service pack: %q\n", gitServicePack)
|
|
return
|
|
}
|
|
|
|
// git command args
|
|
gitArgs := parts[1:]
|
|
|
|
// first git service pack cmd arg is path: 'space/repository.git' so we need to remove
|
|
// single quotes.
|
|
repoRef := strings.Trim(gitArgs[0], "'")
|
|
// remove .git suffix
|
|
repoRef = strings.TrimSuffix(repoRef, ".git")
|
|
|
|
gitProtocol := ""
|
|
for _, key := range session.Environ() {
|
|
if strings.HasPrefix(key, "GIT_PROTOCOL=") {
|
|
gitProtocol = key[len("GIT_PROTOCOL="):]
|
|
}
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(session.Context())
|
|
defer cancel()
|
|
|
|
// set keep alive connection
|
|
if s.KeepAliveInterval > 0 {
|
|
go sendKeepAliveMsg(ctx, session, s.KeepAliveInterval)
|
|
}
|
|
|
|
err = s.RepoCtrl.GitServicePack(
|
|
ctx,
|
|
&auth.Session{
|
|
Principal: types.Principal{
|
|
ID: principal.ID,
|
|
UID: principal.UID,
|
|
Email: principal.Email,
|
|
Type: principal.Type,
|
|
DisplayName: principal.DisplayName,
|
|
Created: principal.Created,
|
|
Updated: principal.Updated,
|
|
},
|
|
},
|
|
repoRef,
|
|
api.ServicePackOptions{
|
|
Service: service,
|
|
Stdout: session,
|
|
Stdin: session,
|
|
Stderr: session.Stderr(),
|
|
Protocol: gitProtocol,
|
|
},
|
|
)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("git service pack failed")
|
|
_, err = io.Copy(session.Stderr(), strings.NewReader(err.Error()))
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("error writing to session stderr")
|
|
}
|
|
}
|
|
}
|
|
|
|
func sendKeepAliveMsg(ctx context.Context, session ssh.Session, interval time.Duration) {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
log.Ctx(ctx).Debug().Str("remote_addr", session.RemoteAddr().String()).Msgf("sendKeepAliveMsg")
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
log.Ctx(ctx).Debug().Msg("send keepalive message to ssh client")
|
|
_, err := session.SendRequest(KeepAliveMsg, true, nil)
|
|
if err != nil {
|
|
log.Ctx(ctx).Debug().Err(err).Msg("failed to send keepalive message to ssh client")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {
|
|
if slices.Contains(publickey.DisallowedTypes, key.Type()) {
|
|
log.Warn().Msgf("public key type not supported: %s", key.Type())
|
|
return false
|
|
}
|
|
|
|
if s.DefaultUser != "" && ctx.User() != s.DefaultUser {
|
|
log.Warn().Msgf("invalid SSH username %s - must use %s for all git operations via ssh",
|
|
ctx.User(), s.DefaultUser)
|
|
log.Warn().Msgf("failed authentication attempt from %s", ctx.RemoteAddr())
|
|
return false
|
|
}
|
|
|
|
principal, err := s.Verifier.ValidateKey(ctx, key, enum.PublicKeyUsageAuth)
|
|
if errors.IsNotFound(err) {
|
|
log.Debug().Err(err).Msg("public key is unknown")
|
|
return false
|
|
}
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("failed to validate public key")
|
|
return false
|
|
}
|
|
|
|
// check if we have a certificate
|
|
if cert, ok := key.(*gossh.Certificate); ok {
|
|
if len(s.TrustedUserCAKeys) == 0 {
|
|
log.Warn().Msg("Certificate Rejected: No trusted certificate authorities for this server")
|
|
log.Warn().Msgf("Failed authentication attempt from %s", ctx.RemoteAddr())
|
|
return false
|
|
}
|
|
|
|
if cert.CertType != gossh.UserCert {
|
|
log.Warn().Msg("Certificate Rejected: Not a user certificate")
|
|
log.Warn().Msgf("Failed authentication attempt from %s", ctx.RemoteAddr())
|
|
return false
|
|
}
|
|
|
|
certChecker := &gossh.CertChecker{}
|
|
if err := certChecker.CheckCert(principal.UID, cert); err != nil {
|
|
return false
|
|
}
|
|
}
|
|
|
|
ctx.SetValue(principalKey, principal)
|
|
return true
|
|
}
|
|
|
|
func sshConnectionFailed(conn net.Conn, err error) {
|
|
log.Err(err).Msgf("failed connection from %s with error: %v", conn.RemoteAddr(), err)
|
|
}
|
|
|
|
func createKeyIfNotExists(path string) error {
|
|
_, err := os.Stat(path)
|
|
if err == nil {
|
|
// if the path already exists there's nothing we have to do
|
|
return nil
|
|
}
|
|
if !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to check for for existence of key: %w", err)
|
|
}
|
|
|
|
log.Debug().Msgf("generate new key at %q", path)
|
|
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
|
return fmt.Errorf("failed to create dir %q for key: %w", dir, err)
|
|
}
|
|
|
|
err = GenerateKeyPair(path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate key pair: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GenerateKeyPair make a pair of public and private keys for SSH access.
|
|
func GenerateKeyPair(keyPath string) error {
|
|
privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}
|
|
f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
if err := pem.Encode(f, privateKeyPEM); err != nil {
|
|
return err
|
|
}
|
|
|
|
// generate public key
|
|
pub, err := gossh.NewPublicKey(&privateKey.PublicKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
public := gossh.MarshalAuthorizedKey(pub)
|
|
p, err := os.OpenFile(keyPath+".pub", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer p.Close()
|
|
|
|
_, err = p.Write(public)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write to public key: %w", err)
|
|
}
|
|
return nil
|
|
}
|