You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							302 lines
						
					
					
						
							8.5 KiB
						
					
					
				
			
		
		
		
			
			
			
		
		
	
	
							302 lines
						
					
					
						
							8.5 KiB
						
					
					
				| // 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) | |
| 	} | |
| }
 |