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

76
weed/sftpd/auth/auth.go Normal file
View File

@@ -0,0 +1,76 @@
// Package auth provides authentication and authorization functionality for the SFTP server
package auth
import (
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"golang.org/x/crypto/ssh"
)
// Provider defines the interface for authentication providers
type Provider interface {
// GetAuthMethods returns the SSH server auth methods
GetAuthMethods() []ssh.AuthMethod
}
// Manager handles authentication and authorization
type Manager struct {
userStore user.Store
passwordAuth *PasswordAuthenticator
publicKeyAuth *PublicKeyAuthenticator
permissionChecker *PermissionChecker
enabledAuthMethods []string
}
// NewManager creates a new authentication manager
func NewManager(userStore user.Store, fsHelper FileSystemHelper, enabledAuthMethods []string) *Manager {
manager := &Manager{
userStore: userStore,
enabledAuthMethods: enabledAuthMethods,
}
// Initialize authenticators based on enabled methods
passwordEnabled := false
publicKeyEnabled := false
for _, method := range enabledAuthMethods {
switch method {
case "password":
passwordEnabled = true
case "publickey":
publicKeyEnabled = true
}
}
manager.passwordAuth = NewPasswordAuthenticator(userStore, passwordEnabled)
manager.publicKeyAuth = NewPublicKeyAuthenticator(userStore, publicKeyEnabled)
manager.permissionChecker = NewPermissionChecker(fsHelper)
return manager
}
// GetSSHServerConfig returns an SSH server config with the appropriate authentication methods
func (m *Manager) GetSSHServerConfig() *ssh.ServerConfig {
config := &ssh.ServerConfig{}
// Add password authentication if enabled
if m.passwordAuth.Enabled() {
config.PasswordCallback = m.passwordAuth.Authenticate
}
// Add public key authentication if enabled
if m.publicKeyAuth.Enabled() {
config.PublicKeyCallback = m.publicKeyAuth.Authenticate
}
return config
}
// CheckPermission checks if a user has the required permission on a path
func (m *Manager) CheckPermission(user *user.User, path, permission string) error {
return m.permissionChecker.CheckFilePermission(user, path, permission)
}
// GetUser retrieves a user from the user store
func (m *Manager) GetUser(username string) (*user.User, error) {
return m.userStore.GetUser(username)
}

View File

@@ -0,0 +1,64 @@
package auth
import (
"fmt"
"math/rand"
"time"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"golang.org/x/crypto/ssh"
)
// PasswordAuthenticator handles password-based authentication
type PasswordAuthenticator struct {
userStore user.Store
enabled bool
}
// NewPasswordAuthenticator creates a new password authenticator
func NewPasswordAuthenticator(userStore user.Store, enabled bool) *PasswordAuthenticator {
return &PasswordAuthenticator{
userStore: userStore,
enabled: enabled,
}
}
// Enabled returns whether password authentication is enabled
func (a *PasswordAuthenticator) Enabled() bool {
return a.enabled
}
// Authenticate validates a password for a user
func (a *PasswordAuthenticator) Authenticate(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
username := conn.User()
// Check if password auth is enabled
if !a.enabled {
return nil, fmt.Errorf("password authentication disabled")
}
// Validate password against user store
if a.userStore.ValidatePassword(username, password) {
return &ssh.Permissions{
Extensions: map[string]string{
"username": username,
},
}, nil
}
// Add delay to prevent brute force attacks
time.Sleep(time.Duration(100+rand.Intn(100)) * time.Millisecond)
return nil, fmt.Errorf("authentication failed")
}
// ValidatePassword checks if the provided password is valid for the user
func ValidatePassword(store user.Store, username string, password []byte) bool {
user, err := store.GetUser(username)
if err != nil {
return false
}
// Compare plaintext password
return string(password) == user.Password
}

View File

@@ -0,0 +1,267 @@
package auth
import (
"context"
"fmt"
"os"
"strings"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
)
// Permission constants for clarity and consistency
const (
PermRead = "read"
PermWrite = "write"
PermExecute = "execute"
PermList = "list"
PermDelete = "delete"
PermMkdir = "mkdir"
PermTraverse = "traverse"
PermAll = "*"
PermAdmin = "admin"
PermReadWrite = "readwrite"
)
// PermissionChecker handles permission checking for file operations
// It verifies both Unix-style permissions and explicit ACLs defined in user configuration.
type PermissionChecker struct {
fsHelper FileSystemHelper
}
// FileSystemHelper provides necessary filesystem operations for permission checking
type FileSystemHelper interface {
GetEntry(path string) (*Entry, error)
}
// Entry represents a filesystem entry with attributes
type Entry struct {
IsDirectory bool
Attributes *EntryAttributes
IsSymlink bool // Added to track symlinks
Target string // For symlinks, stores the target path
}
// EntryAttributes contains file attributes
type EntryAttributes struct {
Uid uint32
Gid uint32
FileMode uint32
SymlinkTarget string
}
// PermissionError represents a permission-related error
type PermissionError struct {
Path string
Perm string
User string
}
func (e *PermissionError) Error() string {
return fmt.Sprintf("permission denied: %s required on %s for user %s", e.Perm, e.Path, e.User)
}
// NewPermissionChecker creates a new permission checker
func NewPermissionChecker(fsHelper FileSystemHelper) *PermissionChecker {
return &PermissionChecker{
fsHelper: fsHelper,
}
}
// CheckFilePermission verifies if a user has the required permission on a path
// It first checks if the path is in the user's home directory with explicit permissions.
// If not, it falls back to Unix permission checking followed by explicit permission checking.
// Parameters:
// - user: The user requesting access
// - path: The filesystem path to check
// - perm: The permission being requested (read, write, execute, etc.)
//
// Returns:
// - nil if permission is granted, error otherwise
func (pc *PermissionChecker) CheckFilePermission(user *user.User, path string, perm string) error {
if user == nil {
return &PermissionError{Path: path, Perm: perm, User: "unknown"}
}
// Retrieve metadata via helper
entry, err := pc.fsHelper.GetEntry(path)
if err != nil {
return fmt.Errorf("failed to get entry for path %s: %w", path, err)
}
// Handle symlinks by resolving them
if entry.IsSymlink {
// Get the actual entry for the resolved path
entry, err = pc.fsHelper.GetEntry(entry.Attributes.SymlinkTarget)
if err != nil {
return fmt.Errorf("failed to get entry for resolved path %s: %w", entry.Attributes.SymlinkTarget, err)
}
// Store the original target
entry.Target = entry.Attributes.SymlinkTarget
}
// Special case: root user always has permission
if user.Username == "root" || user.Uid == 0 {
return nil
}
// Check if path is within user's home directory and has explicit permissions
if isPathInHomeDirectory(user, path) {
// Check if user has explicit permissions for this path
if HasExplicitPermission(user, path, perm, entry.IsDirectory) {
return nil
}
} else {
// For paths outside home directory or without explicit home permissions,
// check UNIX-style perms first
isOwner := user.Uid == entry.Attributes.Uid
isGroup := user.Gid == entry.Attributes.Gid
mode := os.FileMode(entry.Attributes.FileMode)
if HasUnixPermission(isOwner, isGroup, mode, entry.IsDirectory, perm) {
return nil
}
// Then check explicit ACLs
if HasExplicitPermission(user, path, perm, entry.IsDirectory) {
return nil
}
}
return &PermissionError{Path: path, Perm: perm, User: user.Username}
}
// CheckFilePermissionWithContext is a context-aware version of CheckFilePermission
// that supports cancellation and timeouts
func (pc *PermissionChecker) CheckFilePermissionWithContext(ctx context.Context, user *user.User, path string, perm string) error {
// Check for context cancellation
if ctx.Err() != nil {
return ctx.Err()
}
return pc.CheckFilePermission(user, path, perm)
}
// isPathInHomeDirectory checks if a path is in the user's home directory
func isPathInHomeDirectory(user *user.User, path string) bool {
return strings.HasPrefix(path, user.HomeDir)
}
// HasUnixPermission checks if the user has the required Unix permission
// Uses bit masks for clarity and maintainability
func HasUnixPermission(isOwner, isGroup bool, fileMode os.FileMode, isDirectory bool, requiredPerm string) bool {
const (
ownerRead = 0400
ownerWrite = 0200
ownerExec = 0100
groupRead = 0040
groupWrite = 0020
groupExec = 0010
otherRead = 0004
otherWrite = 0002
otherExec = 0001
)
// Check read permission
hasRead := (isOwner && (fileMode&ownerRead != 0)) ||
(isGroup && (fileMode&groupRead != 0)) ||
(fileMode&otherRead != 0)
// Check write permission
hasWrite := (isOwner && (fileMode&ownerWrite != 0)) ||
(isGroup && (fileMode&groupWrite != 0)) ||
(fileMode&otherWrite != 0)
// Check execute permission
hasExec := (isOwner && (fileMode&ownerExec != 0)) ||
(isGroup && (fileMode&groupExec != 0)) ||
(fileMode&otherExec != 0)
switch requiredPerm {
case PermRead:
return hasRead
case PermWrite:
return hasWrite
case PermExecute:
return hasExec
case PermList:
if isDirectory {
return hasRead && hasExec
}
return hasRead
case PermDelete:
return hasWrite
case PermMkdir:
return isDirectory && hasWrite
case PermTraverse:
return isDirectory && hasExec
case PermReadWrite:
return hasRead && hasWrite
case PermAll, PermAdmin:
return hasRead && hasWrite && hasExec
}
return false
}
// HasExplicitPermission checks if the user has explicit permission from user config
func HasExplicitPermission(user *user.User, filepath, requiredPerm string, isDirectory bool) bool {
// Find the most specific permission that applies to this path
var bestMatch string
var perms []string
for p, userPerms := range user.Permissions {
// Check if the path is either the permission path exactly or is under that path
if strings.HasPrefix(filepath, p) && len(p) > len(bestMatch) {
bestMatch = p
perms = userPerms
}
}
// No matching permissions found
if bestMatch == "" {
return false
}
// Check if user has admin role
if containsString(perms, PermAdmin) {
return true
}
// If user has list permission and is requesting traverse/execute permission, grant it
if isDirectory && requiredPerm == PermExecute && containsString(perms, PermList) {
return true
}
// Check if the required permission is in the list
for _, perm := range perms {
if perm == requiredPerm || perm == PermAll {
return true
}
// Handle combined permissions
if perm == PermReadWrite && (requiredPerm == PermRead || requiredPerm == PermWrite) {
return true
}
// Directory-specific permissions
if isDirectory && perm == PermList && requiredPerm == PermRead {
return true
}
if isDirectory && perm == PermTraverse && requiredPerm == PermExecute {
return true
}
}
return false
}
// Helper function to check if a string is in a slice
func containsString(slice []string, s string) bool {
for _, item := range slice {
if item == s {
return true
}
}
return false
}

View File

@@ -0,0 +1,68 @@
package auth
import (
"crypto/subtle"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"golang.org/x/crypto/ssh"
)
// PublicKeyAuthenticator handles public key-based authentication
type PublicKeyAuthenticator struct {
userStore user.Store
enabled bool
}
// NewPublicKeyAuthenticator creates a new public key authenticator
func NewPublicKeyAuthenticator(userStore user.Store, enabled bool) *PublicKeyAuthenticator {
return &PublicKeyAuthenticator{
userStore: userStore,
enabled: enabled,
}
}
// Enabled returns whether public key authentication is enabled
func (a *PublicKeyAuthenticator) Enabled() bool {
return a.enabled
}
// Authenticate validates a public key for a user
func (a *PublicKeyAuthenticator) Authenticate(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
username := conn.User()
// Check if public key auth is enabled
if !a.enabled {
return nil, fmt.Errorf("public key authentication disabled")
}
// Convert key to string format for comparison
keyData := string(key.Marshal())
// Validate public key
if ValidatePublicKey(a.userStore, username, keyData) {
return &ssh.Permissions{
Extensions: map[string]string{
"username": username,
},
}, nil
}
return nil, fmt.Errorf("authentication failed")
}
// ValidatePublicKey checks if the provided public key is valid for the user
func ValidatePublicKey(store user.Store, username string, keyData string) bool {
user, err := store.GetUser(username)
if err != nil {
return false
}
for _, key := range user.PublicKeys {
if subtle.ConstantTimeCompare([]byte(key), []byte(keyData)) == 1 {
return true
}
}
return false
}