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:
228
weed/sftpd/user/filestore.go
Normal file
228
weed/sftpd/user/filestore.go
Normal 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
|
||||
}
|
||||
204
weed/sftpd/user/homemanager.go
Normal file
204
weed/sftpd/user/homemanager.go
Normal 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
111
weed/sftpd/user/user.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user