| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302 |
- // sftp_service.go
- package sftpd
- import (
- "context"
- "fmt"
- "io"
- "net"
- "os"
- "path/filepath"
- "time"
- "github.com/pkg/sftp"
- "github.com/seaweedfs/seaweedfs/weed/glog"
- "github.com/seaweedfs/seaweedfs/weed/pb"
- "github.com/seaweedfs/seaweedfs/weed/sftpd/auth"
- "github.com/seaweedfs/seaweedfs/weed/sftpd/user"
- "golang.org/x/crypto/ssh"
- "google.golang.org/grpc"
- )
- // SFTPService holds configuration for the SFTP service.
- type SFTPService struct {
- options SFTPServiceOptions
- userStore user.Store
- authManager *auth.Manager
- }
- // SFTPServiceOptions contains all configuration options for the SFTP service.
- type SFTPServiceOptions struct {
- GrpcDialOption grpc.DialOption
- DataCenter string
- FilerGroup string
- Filer pb.ServerAddress
- // SSH Configuration
- SshPrivateKey string // Legacy single host key
- HostKeysFolder string // Multiple host keys for different algorithms
- AuthMethods []string // Enabled auth methods: "password", "publickey", "keyboard-interactive"
- MaxAuthTries int // Limit authentication attempts
- BannerMessage string // Pre-auth banner message
- LoginGraceTime time.Duration // Timeout for authentication
- // Connection Management
- ClientAliveInterval time.Duration // Keep-alive check interval
- ClientAliveCountMax int // Max missed keep-alives before disconnect
- // User Management
- UserStoreFile string // Path to user store file
- }
- // NewSFTPService creates a new service instance.
- func NewSFTPService(options *SFTPServiceOptions) *SFTPService {
- service := SFTPService{options: *options}
- // Initialize user store
- userStore, err := user.NewFileStore(options.UserStoreFile)
- if err != nil {
- glog.Fatalf("Failed to initialize user store: %v", err)
- }
- service.userStore = userStore
- // Initialize auth manager
- service.authManager = auth.NewManager(userStore, options.AuthMethods)
- return &service
- }
- // Serve accepts incoming connections on the provided listener and handles them.
- func (s *SFTPService) Serve(listener net.Listener) error {
- // Build SSH server config
- sshConfig, err := s.buildSSHConfig()
- if err != nil {
- return fmt.Errorf("failed to create SSH config: %w", err)
- }
- glog.V(0).Infof("Starting Seaweed SFTP service on %s", listener.Addr().String())
- for {
- conn, err := listener.Accept()
- if err != nil {
- return fmt.Errorf("failed to accept incoming connection: %w", err)
- }
- go s.handleSSHConnection(conn, sshConfig)
- }
- }
- // buildSSHConfig creates the SSH server configuration with proper authentication.
- func (s *SFTPService) buildSSHConfig() (*ssh.ServerConfig, error) {
- // Get base config from auth manager
- config := s.authManager.GetSSHServerConfig()
- // Set additional options
- config.MaxAuthTries = s.options.MaxAuthTries
- config.BannerCallback = func(conn ssh.ConnMetadata) string {
- return s.options.BannerMessage
- }
- config.ServerVersion = "SSH-2.0-SeaweedFS-SFTP" // Custom server version
- hostKeysAdded := 0
- // Add legacy host key if specified
- if s.options.SshPrivateKey != "" {
- if err := s.addHostKey(config, s.options.SshPrivateKey); err != nil {
- return nil, err
- }
- hostKeysAdded++
- }
- // Add all host keys from the specified folder
- if s.options.HostKeysFolder != "" {
- files, err := os.ReadDir(s.options.HostKeysFolder)
- if err != nil {
- return nil, fmt.Errorf("failed to read host keys folder: %w", err)
- }
- for _, file := range files {
- if file.IsDir() {
- continue // Skip directories
- }
- keyPath := filepath.Join(s.options.HostKeysFolder, file.Name())
- if err := s.addHostKey(config, keyPath); err != nil {
- // Log the error but continue with other keys
- glog.V(0).Info(fmt.Sprintf("Failed to add host key %s: %v", keyPath, err))
- continue
- }
- hostKeysAdded++
- }
- if hostKeysAdded == 0 {
- glog.V(0).Info(fmt.Sprintf("Warning: no valid host keys found in folder %s", s.options.HostKeysFolder))
- }
- }
- // Ensure we have at least one host key
- if hostKeysAdded == 0 {
- return nil, fmt.Errorf("no host keys provided")
- }
- return config, nil
- }
- // addHostKey adds a host key to the SSH server configuration.
- func (s *SFTPService) addHostKey(config *ssh.ServerConfig, keyPath string) error {
- keyBytes, err := os.ReadFile(keyPath)
- if err != nil {
- return fmt.Errorf("failed to read host key %s: %v", keyPath, err)
- }
- // Try parsing as private key
- signer, err := ssh.ParsePrivateKey(keyBytes)
- if err != nil {
- // Try parsing with passphrase if available
- if passphraseErr, ok := err.(*ssh.PassphraseMissingError); ok {
- return fmt.Errorf("host key %s requires passphrase: %v", keyPath, passphraseErr)
- }
- return fmt.Errorf("failed to parse host key %s: %v", keyPath, err)
- }
- config.AddHostKey(signer)
- glog.V(0).Infof("Added host key %s (%s)", keyPath, signer.PublicKey().Type())
- return nil
- }
- // handleSSHConnection handles an incoming SSH connection.
- func (s *SFTPService) handleSSHConnection(conn net.Conn, config *ssh.ServerConfig) {
- // Set connection deadline for handshake
- _ = conn.SetDeadline(time.Now().Add(s.options.LoginGraceTime))
- // Perform SSH handshake
- sshConn, chans, reqs, err := ssh.NewServerConn(conn, config)
- if err != nil {
- glog.Errorf("Failed to handshake: %v", err)
- conn.Close()
- return
- }
- // Clear deadline after successful handshake
- _ = conn.SetDeadline(time.Time{})
- // Set up connection monitoring
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- // Start keep-alive monitoring
- go s.monitorConnection(ctx, sshConn)
- username := sshConn.Permissions.Extensions["username"]
- glog.V(0).Infof("New SSH connection from %s (%s) as user %s",
- sshConn.RemoteAddr(), sshConn.ClientVersion(), username)
- // Get user from store
- sftpUser, err := s.authManager.GetUser(username)
- if err != nil {
- glog.Errorf("Failed to retrieve user %s: %v", username, err)
- sshConn.Close()
- return
- }
- // Create user-specific filesystem
- userFs := NewSftpServer(
- s.options.Filer,
- s.options.GrpcDialOption,
- s.options.DataCenter,
- s.options.FilerGroup,
- sftpUser,
- )
- // Ensure home directory exists with proper permissions
- if err := userFs.EnsureHomeDirectory(); err != nil {
- glog.Errorf("Failed to ensure home directory for user %s: %v", username, err)
- // We don't close the connection here, as the user might still be able to access other directories
- }
- // Handle SSH requests and channels
- go ssh.DiscardRequests(reqs)
- for newChannel := range chans {
- go s.handleChannel(newChannel, &userFs)
- }
- }
- // monitorConnection monitors an SSH connection with keep-alives.
- func (s *SFTPService) monitorConnection(ctx context.Context, sshConn *ssh.ServerConn) {
- if s.options.ClientAliveInterval <= 0 {
- return
- }
- ticker := time.NewTicker(s.options.ClientAliveInterval)
- defer ticker.Stop()
- missedCount := 0
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- // Send keep-alive request
- _, _, err := sshConn.SendRequest("keepalive@openssh.com", true, nil)
- if err != nil {
- missedCount++
- glog.V(0).Infof("Keep-alive missed for %s: %v (%d/%d)",
- sshConn.RemoteAddr(), err, missedCount, s.options.ClientAliveCountMax)
- if missedCount >= s.options.ClientAliveCountMax {
- glog.Warningf("Closing unresponsive connection from %s", sshConn.RemoteAddr())
- sshConn.Close()
- return
- }
- } else {
- missedCount = 0
- }
- }
- }
- }
- // handleChannel handles a single SSH channel.
- func (s *SFTPService) handleChannel(newChannel ssh.NewChannel, fs *SftpServer) {
- if newChannel.ChannelType() != "session" {
- _ = newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
- return
- }
- channel, requests, err := newChannel.Accept()
- if err != nil {
- glog.Errorf("Could not accept channel: %v", err)
- return
- }
- go func(in <-chan *ssh.Request) {
- for req := range in {
- switch req.Type {
- case "subsystem":
- // Check that the subsystem is "sftp".
- if string(req.Payload[4:]) == "sftp" {
- _ = req.Reply(true, nil)
- s.handleSFTP(channel, fs)
- } else {
- _ = req.Reply(false, nil)
- }
- default:
- _ = req.Reply(false, nil)
- }
- }
- }(requests)
- }
- // handleSFTP starts the SFTP server on the SSH channel.
- func (s *SFTPService) handleSFTP(channel ssh.Channel, fs *SftpServer) {
- // Create server options with initial working directory set to user's home
- serverOptions := sftp.WithStartDirectory(fs.user.HomeDir)
- server := sftp.NewRequestServer(channel, sftp.Handlers{
- FileGet: fs,
- FilePut: fs,
- FileCmd: fs,
- FileList: fs,
- }, serverOptions)
- if err := server.Serve(); err == io.EOF {
- server.Close()
- glog.V(0).Info("SFTP client exited session.")
- } else if err != nil {
- glog.Errorf("SFTP server finished with error: %v", err)
- }
- }
|