1package ssh23import (4 "context"5 "fmt"6 "net"7 "os"8 "strconv"9 "time"1011 "github.com/charmbracelet/keygen"12 "github.com/charmbracelet/log/v2"13 "github.com/charmbracelet/soft-serve/pkg/backend"14 "github.com/charmbracelet/soft-serve/pkg/config"15 "github.com/charmbracelet/soft-serve/pkg/db"16 "github.com/charmbracelet/soft-serve/pkg/proto"17 "github.com/charmbracelet/soft-serve/pkg/store"18 "github.com/charmbracelet/ssh"19 "github.com/charmbracelet/wish/v2"20 rm "github.com/charmbracelet/wish/v2/recover"21 "github.com/prometheus/client_golang/prometheus"22 "github.com/prometheus/client_golang/prometheus/promauto"23 gossh "golang.org/x/crypto/ssh"24)2526var (27 publicKeyCounter = promauto.NewCounterVec(prometheus.CounterOpts{28 Namespace: "soft_serve",29 Subsystem: "ssh",30 Name: "public_key_auth_total",31 Help: "The total number of public key auth requests",32 }, []string{"allowed"})3334 keyboardInteractiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{35 Namespace: "soft_serve",36 Subsystem: "ssh",37 Name: "keyboard_interactive_auth_total",38 Help: "The total number of keyboard interactive auth requests",39 }, []string{"allowed"})40)4142// SSHServer is a SSH server that implements the git protocol.43type SSHServer struct { // nolint: revive44 srv *ssh.Server45 cfg *config.Config46 be *backend.Backend47 ctx context.Context48 logger *log.Logger49}5051// NewSSHServer returns a new SSHServer.52func NewSSHServer(ctx context.Context) (*SSHServer, error) {53 cfg := config.FromContext(ctx)54 logger := log.FromContext(ctx).WithPrefix("ssh")55 dbx := db.FromContext(ctx)56 datastore := store.FromContext(ctx)57 be := backend.FromContext(ctx)5859 var err error60 s := &SSHServer{61 cfg: cfg,62 ctx: ctx,63 be: be,64 logger: logger,65 }6667 mw := []wish.Middleware{68 rm.MiddlewareWithLogger(69 logger,70 // CLI middleware.71 CommandMiddleware,72 // Logging middleware.73 LoggingMiddleware,74 // Context middleware.75 ContextMiddleware(cfg, dbx, datastore, be, logger),76 // Authentication middleware.77 // gossh.PublicKeyHandler doesn't guarantee that the public key78 // is in fact the one used for authentication, so we need to79 // check it again here.80 AuthenticationMiddleware,81 ),82 }8384 opts := []ssh.Option{85 ssh.PublicKeyAuth(s.PublicKeyHandler),86 ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler),87 wish.WithAddress(cfg.SSH.ListenAddr),88 wish.WithHostKeyPath(cfg.SSH.KeyPath),89 wish.WithMiddleware(mw...),90 }9192 // TODO: Support a real PTY in future version.93 opts = append(opts, ssh.EmulatePty())9495 s.srv, err = wish.NewServer(opts...)96 if err != nil {97 return nil, err98 }99100 if config.IsDebug() {101 s.srv.ServerConfigCallback = func(_ ssh.Context) *gossh.ServerConfig {102 return &gossh.ServerConfig{103 AuthLogCallback: func(conn gossh.ConnMetadata, method string, err error) {104 logger.Debug("authentication", "user", conn.User(), "method", method, "err", err)105 },106 }107 }108 }109110 if cfg.SSH.MaxTimeout > 0 {111 s.srv.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second112 }113114 if cfg.SSH.IdleTimeout > 0 {115 s.srv.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second116 }117118 // Create client ssh key119 if _, err := os.Stat(cfg.SSH.ClientKeyPath); err != nil && os.IsNotExist(err) {120 _, err := keygen.New(cfg.SSH.ClientKeyPath, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite())121 if err != nil {122 return nil, fmt.Errorf("client ssh key: %w", err)123 }124 }125126 return s, nil127}128129// ListenAndServe starts the SSH server.130func (s *SSHServer) ListenAndServe() error {131 return s.srv.ListenAndServe()132}133134// Serve starts the SSH server on the given net.Listener.135func (s *SSHServer) Serve(l net.Listener) error {136 return s.srv.Serve(l)137}138139// Close closes the SSH server.140func (s *SSHServer) Close() error {141 return s.srv.Close()142}143144// Shutdown gracefully shuts down the SSH server.145func (s *SSHServer) Shutdown(ctx context.Context) error {146 return s.srv.Shutdown(ctx)147}148149func initializePermissions(ctx ssh.Context) {150 perms := ctx.Permissions()151 if perms == nil || perms.Permissions == nil {152 perms = &ssh.Permissions{Permissions: &gossh.Permissions{}}153 }154 if perms.Extensions == nil {155 perms.Extensions = make(map[string]string)156 }157 if perms.Permissions.Extensions == nil {158 perms.Permissions.Extensions = make(map[string]string)159 }160}161162// PublicKeyAuthHandler handles public key authentication.163func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed bool) {164 if pk == nil {165 return false166 }167168 allowed = true169 defer func(allowed *bool) {170 publicKeyCounter.WithLabelValues(strconv.FormatBool(*allowed)).Inc()171 }(&allowed)172173 user, _ := s.be.UserByPublicKey(ctx, pk)174 if user != nil {175 ctx.SetValue(proto.ContextKeyUser, user)176 }177178 // XXX: store the first "approved" public-key fingerprint in the179 // permissions block to use for authentication later.180 initializePermissions(ctx)181 perms := ctx.Permissions()182183 // Set the public key fingerprint to be used for authentication.184 perms.Extensions["pubkey-fp"] = gossh.FingerprintSHA256(pk)185 ctx.SetValue(ssh.ContextKeyPermissions, perms)186187 return188}189190// KeyboardInteractiveHandler handles keyboard interactive authentication.191// This is used after all public key authentication has failed.192func (s *SSHServer) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {193 ac := s.be.AllowKeyless(ctx)194 keyboardInteractiveCounter.WithLabelValues(strconv.FormatBool(ac)).Inc()195196 // If we're allowing keyless access, reset the public key fingerprint197 if ac {198 initializePermissions(ctx)199 perms := ctx.Permissions()200201 // XXX: reset the public-key fingerprint. This is used to validate the202 // public key being used to authenticate.203 perms.Extensions["pubkey-fp"] = ""204 ctx.SetValue(ssh.ContextKeyPermissions, perms)205 }206 return ac207}