1package ssh23import (4 "context"5 "fmt"6 "net"7 "os"8 "strconv"9 "time"1011 "charm.land/log/v2"12 "charm.land/wish/v2"13 bm "charm.land/wish/v2/bubbletea"14 rm "charm.land/wish/v2/recover"15 "github.com/charmbracelet/keygen"16 "github.com/charmbracelet/soft-serve/pkg/backend"17 "github.com/charmbracelet/soft-serve/pkg/config"18 "github.com/charmbracelet/soft-serve/pkg/db"19 "github.com/charmbracelet/soft-serve/pkg/store"20 "github.com/charmbracelet/ssh"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 // BubbleTea middleware.71 bm.MiddlewareWithProgramHandler(SessionHandler),72 // CLI middleware.73 CommandMiddleware,74 // Logging middleware.75 LoggingMiddleware,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 // Context middleware.82 // This must come first to set up the context.83 ContextMiddleware(cfg, dbx, datastore, be, logger),84 ),85 }8687 opts := []ssh.Option{88 ssh.PublicKeyAuth(s.PublicKeyHandler),89 ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler),90 wish.WithAddress(cfg.SSH.ListenAddr),91 wish.WithHostKeyPath(cfg.SSH.KeyPath),92 wish.WithMiddleware(mw...),93 }9495 // TODO: Support a real PTY in future version.96 opts = append(opts, ssh.EmulatePty())9798 s.srv, err = wish.NewServer(opts...)99 if err != nil {100 return nil, err101 }102103 if config.IsDebug() {104 s.srv.ServerConfigCallback = func(_ ssh.Context) *gossh.ServerConfig {105 return &gossh.ServerConfig{106 AuthLogCallback: func(conn gossh.ConnMetadata, method string, err error) {107 logger.Debug("authentication", "user", conn.User(), "method", method, "err", err)108 },109 }110 }111 }112113 if cfg.SSH.MaxTimeout > 0 {114 s.srv.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second115 }116117 if cfg.SSH.IdleTimeout > 0 {118 s.srv.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second119 }120121 // Create client ssh key122 if _, err := os.Stat(cfg.SSH.ClientKeyPath); err != nil && os.IsNotExist(err) {123 _, err := keygen.New(cfg.SSH.ClientKeyPath, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite())124 if err != nil {125 return nil, fmt.Errorf("client ssh key: %w", err)126 }127 }128129 return s, nil130}131132// ListenAndServe starts the SSH server.133func (s *SSHServer) ListenAndServe() error {134 return s.srv.ListenAndServe()135}136137// Serve starts the SSH server on the given net.Listener.138func (s *SSHServer) Serve(l net.Listener) error {139 return s.srv.Serve(l)140}141142// Close closes the SSH server.143func (s *SSHServer) Close() error {144 return s.srv.Close()145}146147// Shutdown gracefully shuts down the SSH server.148func (s *SSHServer) Shutdown(ctx context.Context) error {149 return s.srv.Shutdown(ctx)150}151152func initializePermissions(ctx ssh.Context) {153 perms := ctx.Permissions()154 if perms == nil || perms.Permissions == nil {155 perms = &ssh.Permissions{Permissions: &gossh.Permissions{}}156 }157 if perms.Extensions == nil {158 perms.Extensions = make(map[string]string)159 }160}161162// PublicKeyHandler 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 = true169170 // XXX: store the first "approved" public-key fingerprint in the171 // permissions block to use for authentication later.172 initializePermissions(ctx)173 perms := ctx.Permissions()174175 // Set the public key fingerprint to be used for authentication.176 perms.Extensions["pubkey-fp"] = gossh.FingerprintSHA256(pk)177 ctx.SetValue(ssh.ContextKeyPermissions, perms)178179 return180}181182// KeyboardInteractiveHandler handles keyboard interactive authentication.183// This is used after all public key authentication has failed.184func (s *SSHServer) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {185 ac := s.be.AllowKeyless(ctx)186 keyboardInteractiveCounter.WithLabelValues(strconv.FormatBool(ac)).Inc()187188 // If we're allowing keyless access, reset the public key fingerprint189 initializePermissions(ctx)190 perms := ctx.Permissions()191192 if ac {193 // XXX: reset the public-key fingerprint. This is used to validate the194 // public key being used to authenticate.195 perms.Extensions["pubkey-fp"] = ""196 ctx.SetValue(ssh.ContextKeyPermissions, perms)197 }198 return ac199}