Add SFTP Server Support (#6753)

* Add SFTP Server Support

Signed-off-by: Mohamed Sekour <mohamed.sekour@exfo.com>

* fix s3 tests and helm lint

Signed-off-by: Mohamed Sekour <mohamed.sekour@exfo.com>

* increase  helm chart version

* adjust version

---------

Signed-off-by: Mohamed Sekour <mohamed.sekour@exfo.com>
Co-authored-by: chrislu <chris.lu@gmail.com>
This commit is contained in:
Mohamed Sekour
2025-05-05 20:43:49 +02:00
committed by GitHub
parent a2c5510ae1
commit 93aed187e9
28 changed files with 2997 additions and 103 deletions

View File

@@ -0,0 +1,228 @@
package user
import (
"crypto/subtle"
"encoding/json"
"fmt"
"os"
"sync"
"golang.org/x/crypto/ssh"
)
// FileStore implements Store using a JSON file
type FileStore struct {
filePath string
users map[string]*User
mu sync.RWMutex
}
// NewFileStore creates a new user store from a JSON file
func NewFileStore(filePath string) (*FileStore, error) {
store := &FileStore{
filePath: filePath,
users: make(map[string]*User),
}
// Create the file if it doesn't exist
if _, err := os.Stat(filePath); os.IsNotExist(err) {
// Create an empty users array
if err := os.WriteFile(filePath, []byte("[]"), 0600); err != nil {
return nil, fmt.Errorf("failed to create user store file: %v", err)
}
}
if err := store.loadUsers(); err != nil {
return nil, err
}
return store, nil
}
// loadUsers loads users from the JSON file
func (s *FileStore) loadUsers() error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := os.ReadFile(s.filePath)
if err != nil {
return fmt.Errorf("failed to read user store file: %v", err)
}
var users []*User
if err := json.Unmarshal(data, &users); err != nil {
return fmt.Errorf("failed to parse user store file: %v", err)
}
// Clear existing users and add the loaded ones
s.users = make(map[string]*User)
for _, user := range users {
// Process public keys to ensure they're in the correct format
for i, keyData := range user.PublicKeys {
// Try to parse the key as an authorized key format
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyData))
if err == nil {
// If successful, store the marshaled binary format
user.PublicKeys[i] = string(pubKey.Marshal())
}
}
s.users[user.Username] = user
}
return nil
}
// saveUsers saves users to the JSON file
func (s *FileStore) saveUsers() error {
s.mu.RLock()
defer s.mu.RUnlock()
// Convert map to slice for JSON serialization
var users []*User
for _, user := range s.users {
users = append(users, user)
}
data, err := json.MarshalIndent(users, "", " ")
if err != nil {
return fmt.Errorf("failed to serialize users: %v", err)
}
if err := os.WriteFile(s.filePath, data, 0600); err != nil {
return fmt.Errorf("failed to write user store file: %v", err)
}
return nil
}
// GetUser returns a user by username
func (s *FileStore) GetUser(username string) (*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.users[username]
if !ok {
return nil, &UserNotFoundError{Username: username}
}
return user, nil
}
// ValidatePassword checks if the password is valid for the user
func (s *FileStore) ValidatePassword(username string, password []byte) bool {
user, err := s.GetUser(username)
if err != nil {
return false
}
// Compare plaintext password using constant time comparison for security
return subtle.ConstantTimeCompare([]byte(user.Password), password) == 1
}
// ValidatePublicKey checks if the public key is valid for the user
func (s *FileStore) ValidatePublicKey(username string, keyData string) bool {
user, err := s.GetUser(username)
if err != nil {
return false
}
for _, key := range user.PublicKeys {
if key == keyData {
return true
}
}
return false
}
// GetUserPermissions returns the permissions for a user on a path
func (s *FileStore) GetUserPermissions(username string, path string) []string {
user, err := s.GetUser(username)
if err != nil {
return nil
}
// Check exact path match first
if perms, ok := user.Permissions[path]; ok {
return perms
}
// Check parent directories
var bestMatch string
var bestPerms []string
for p, perms := range user.Permissions {
if len(p) > len(bestMatch) && os.IsPathSeparator(p[len(p)-1]) && path[:len(p)] == p {
bestMatch = p
bestPerms = perms
}
}
return bestPerms
}
// SaveUser saves or updates a user
func (s *FileStore) SaveUser(user *User) error {
s.mu.Lock()
s.users[user.Username] = user
s.mu.Unlock()
return s.saveUsers()
}
// DeleteUser removes a user
func (s *FileStore) DeleteUser(username string) error {
s.mu.Lock()
_, exists := s.users[username]
if !exists {
s.mu.Unlock()
return &UserNotFoundError{Username: username}
}
delete(s.users, username)
s.mu.Unlock()
return s.saveUsers()
}
// ListUsers returns all usernames
func (s *FileStore) ListUsers() ([]string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
usernames := make([]string, 0, len(s.users))
for username := range s.users {
usernames = append(usernames, username)
}
return usernames, nil
}
// CreateUser creates a new user with the given username and password
func (s *FileStore) CreateUser(username, password string) (*User, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Check if user already exists
if _, exists := s.users[username]; exists {
return nil, fmt.Errorf("user already exists: %s", username)
}
// Create new user
user := NewUser(username)
// Store plaintext password
user.Password = password
// Add default permissions
user.Permissions[user.HomeDir] = []string{"all"}
// Save the user
s.users[username] = user
if err := s.saveUsers(); err != nil {
return nil, err
}
return user, nil
}

View File

@@ -0,0 +1,204 @@
package user
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// HomeManager handles user home directory operations
type HomeManager struct {
filerClient FilerClient
}
// FilerClient defines the interface for interacting with the filer
type FilerClient interface {
WithFilerClient(streamingMode bool, fn func(client filer_pb.SeaweedFilerClient) error) error
GetDataCenter() string
AdjustedUrl(location *filer_pb.Location) string
}
// NewHomeManager creates a new home directory manager
func NewHomeManager(filerClient FilerClient) *HomeManager {
return &HomeManager{
filerClient: filerClient,
}
}
// EnsureHomeDirectory creates the user's home directory if it doesn't exist
func (hm *HomeManager) EnsureHomeDirectory(user *User) error {
if user.HomeDir == "" {
return fmt.Errorf("user has no home directory configured")
}
glog.V(0).Infof("Ensuring home directory exists for user %s: %s", user.Username, user.HomeDir)
// Check if home directory exists and create it if needed
err := hm.createDirectoryIfNotExists(user.HomeDir, user)
if err != nil {
return fmt.Errorf("failed to ensure home directory: %v", err)
}
// Update user permissions map to include the home directory with full access if not already present
if user.Permissions == nil {
user.Permissions = make(map[string][]string)
}
// Only add permissions if not already present
if _, exists := user.Permissions[user.HomeDir]; !exists {
user.Permissions[user.HomeDir] = []string{"all"}
glog.V(0).Infof("Added full permissions for user %s to home directory %s",
user.Username, user.HomeDir)
}
return nil
}
// createDirectoryIfNotExists creates a directory path if it doesn't exist
func (hm *HomeManager) createDirectoryIfNotExists(dirPath string, user *User) error {
// Split the path into components
components := strings.Split(strings.Trim(dirPath, "/"), "/")
currentPath := "/"
for _, component := range components {
if component == "" {
continue
}
nextPath := filepath.Join(currentPath, component)
err := hm.createSingleDirectory(nextPath, user)
if err != nil {
return err
}
currentPath = nextPath
}
return nil
}
// createSingleDirectory creates a single directory if it doesn't exist
func (hm *HomeManager) createSingleDirectory(dirPath string, user *User) error {
var dirExists bool
err := hm.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
dir, name := util.FullPath(dirPath).DirAndName()
// Check if directory exists
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if err != nil || resp.Entry == nil {
// Directory doesn't exist, create it
glog.V(0).Infof("Creating directory %s for user %s", dirPath, user.Username)
err = filer_pb.Mkdir(hm, string(dir), name, func(entry *filer_pb.Entry) {
// Set appropriate permissions
entry.Attributes.FileMode = uint32(0700 | os.ModeDir) // rwx------ for user
entry.Attributes.Uid = user.Uid
entry.Attributes.Gid = user.Gid
// Set creation and modification times
now := time.Now().Unix()
entry.Attributes.Crtime = now
entry.Attributes.Mtime = now
// Add extended attributes
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
entry.Extended["creator"] = []byte(user.Username)
entry.Extended["auto_created"] = []byte("true")
})
if err != nil {
return fmt.Errorf("failed to create directory %s: %v", dirPath, err)
}
} else if !resp.Entry.IsDirectory {
return fmt.Errorf("path %s exists but is not a directory", dirPath)
} else {
dirExists = true
// Update ownership if needed
if resp.Entry.Attributes.Uid != user.Uid || resp.Entry.Attributes.Gid != user.Gid {
glog.V(0).Infof("Updating ownership of directory %s for user %s", dirPath, user.Username)
entry := resp.Entry
entry.Attributes.Uid = user.Uid
entry.Attributes.Gid = user.Gid
_, updateErr := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
Directory: dir,
Entry: entry,
})
if updateErr != nil {
glog.Warningf("Failed to update directory ownership: %v", updateErr)
}
}
}
return nil
})
if err != nil {
return err
}
if !dirExists {
// Verify the directory was created
verifyErr := hm.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dir, name := util.FullPath(dirPath).DirAndName()
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if err != nil || resp.Entry == nil {
return fmt.Errorf("directory not found after creation")
}
if !resp.Entry.IsDirectory {
return fmt.Errorf("path exists but is not a directory")
}
dirExists = true
return nil
})
if verifyErr != nil {
return fmt.Errorf("failed to verify directory creation: %v", verifyErr)
}
}
return nil
}
// Implement necessary methods to satisfy the filer_pb.FilerClient interface
func (hm *HomeManager) AdjustedUrl(location *filer_pb.Location) string {
return hm.filerClient.AdjustedUrl(location)
}
func (hm *HomeManager) GetDataCenter() string {
return hm.filerClient.GetDataCenter()
}
// WithFilerClient delegates to the underlying filer client
func (hm *HomeManager) WithFilerClient(streamingMode bool, fn func(client filer_pb.SeaweedFilerClient) error) error {
return hm.filerClient.WithFilerClient(streamingMode, fn)
}

111
weed/sftpd/user/user.go Normal file
View File

@@ -0,0 +1,111 @@
// Package user provides user management functionality for the SFTP server
package user
import (
"fmt"
"math/rand"
"path/filepath"
)
// User represents an SFTP user with authentication and permission details
type User struct {
Username string // Username for authentication
Password string // Plaintext password
PublicKeys []string // Authorized public keys
HomeDir string // User's home directory
Permissions map[string][]string // path -> permissions (read, write, list, etc.)
Uid uint32 // User ID for file ownership
Gid uint32 // Group ID for file ownership
}
// Store defines the interface for user storage and retrieval
type Store interface {
// GetUser retrieves a user by username
GetUser(username string) (*User, error)
// ValidatePassword checks if the password is valid for the user
ValidatePassword(username string, password []byte) bool
// ValidatePublicKey checks if the public key is valid for the user
ValidatePublicKey(username string, keyData string) bool
// GetUserPermissions returns the permissions for a user on a path
GetUserPermissions(username string, path string) []string
// SaveUser saves or updates a user
SaveUser(user *User) error
// DeleteUser removes a user
DeleteUser(username string) error
// ListUsers returns all usernames
ListUsers() ([]string, error)
}
// UserNotFoundError is returned when a user is not found
type UserNotFoundError struct {
Username string
}
func (e *UserNotFoundError) Error() string {
return fmt.Sprintf("user not found: %s", e.Username)
}
// NewUser creates a new user with default settings
func NewUser(username string) *User {
// Generate a random UID/GID between 1000 and 60000
// This range is typically safe for regular users in most systems
// 0-999 are often reserved for system users
randomId := 1000 + rand.Intn(59000)
return &User{
Username: username,
Permissions: make(map[string][]string),
HomeDir: filepath.Join("/home", username),
Uid: uint32(randomId),
Gid: uint32(randomId),
}
}
// SetPassword sets a plaintext password for the user
func (u *User) SetPassword(password string) {
u.Password = password
}
// AddPublicKey adds a public key to the user
func (u *User) AddPublicKey(key string) {
// Check if key already exists
for _, existingKey := range u.PublicKeys {
if existingKey == key {
return
}
}
u.PublicKeys = append(u.PublicKeys, key)
}
// RemovePublicKey removes a public key from the user
func (u *User) RemovePublicKey(key string) bool {
for i, existingKey := range u.PublicKeys {
if existingKey == key {
// Remove the key by replacing it with the last element and truncating
u.PublicKeys[i] = u.PublicKeys[len(u.PublicKeys)-1]
u.PublicKeys = u.PublicKeys[:len(u.PublicKeys)-1]
return true
}
}
return false
}
// SetPermission sets permissions for a specific path
func (u *User) SetPermission(path string, permissions []string) {
u.Permissions[path] = permissions
}
// RemovePermission removes permissions for a specific path
func (u *User) RemovePermission(path string) bool {
if _, exists := u.Permissions[path]; exists {
delete(u.Permissions, path)
return true
}
return false
}