mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2025-11-24 08:46:54 +08:00
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:
@@ -43,6 +43,7 @@ var Commands = []*Command{
|
||||
cmdVersion,
|
||||
cmdVolume,
|
||||
cmdWebDav,
|
||||
cmdSftp,
|
||||
}
|
||||
|
||||
type Command struct {
|
||||
|
||||
@@ -35,6 +35,8 @@ var (
|
||||
filerWebDavOptions WebDavOption
|
||||
filerStartIam *bool
|
||||
filerIamOptions IamOptions
|
||||
filerStartSftp *bool
|
||||
filerSftpOptions SftpOptions
|
||||
)
|
||||
|
||||
type FilerOptions struct {
|
||||
@@ -141,6 +143,19 @@ func init() {
|
||||
filerStartIam = cmdFiler.Flag.Bool("iam", false, "whether to start IAM service")
|
||||
filerIamOptions.ip = cmdFiler.Flag.String("iam.ip", *f.ip, "iam server http listen ip address")
|
||||
filerIamOptions.port = cmdFiler.Flag.Int("iam.port", 8111, "iam server http listen port")
|
||||
|
||||
filerStartSftp = cmdFiler.Flag.Bool("sftp", false, "whether to start the SFTP server")
|
||||
filerSftpOptions.port = cmdFiler.Flag.Int("sftp.port", 2022, "SFTP server listen port")
|
||||
filerSftpOptions.sshPrivateKey = cmdFiler.Flag.String("sftp.sshPrivateKey", "", "path to the SSH private key file for host authentication")
|
||||
filerSftpOptions.hostKeysFolder = cmdFiler.Flag.String("sftp.hostKeysFolder", "", "path to folder containing SSH private key files for host authentication")
|
||||
filerSftpOptions.authMethods = cmdFiler.Flag.String("sftp.authMethods", "password,publickey", "comma-separated list of allowed auth methods: password, publickey, keyboard-interactive")
|
||||
filerSftpOptions.maxAuthTries = cmdFiler.Flag.Int("sftp.maxAuthTries", 6, "maximum number of authentication attempts per connection")
|
||||
filerSftpOptions.bannerMessage = cmdFiler.Flag.String("sftp.bannerMessage", "SeaweedFS SFTP Server - Unauthorized access is prohibited", "message displayed before authentication")
|
||||
filerSftpOptions.loginGraceTime = cmdFiler.Flag.Duration("sftp.loginGraceTime", 2*time.Minute, "timeout for authentication")
|
||||
filerSftpOptions.clientAliveInterval = cmdFiler.Flag.Duration("sftp.clientAliveInterval", 5*time.Second, "interval for sending keep-alive messages")
|
||||
filerSftpOptions.clientAliveCountMax = cmdFiler.Flag.Int("sftp.clientAliveCountMax", 3, "maximum number of missed keep-alive messages before disconnecting")
|
||||
filerSftpOptions.userStoreFile = cmdFiler.Flag.String("sftp.userStoreFile", "", "path to JSON file containing user credentials and permissions")
|
||||
filerSftpOptions.localSocket = cmdFiler.Flag.String("sftp.localSocket", "", "default to /tmp/seaweedfs-sftp-<port>.sock")
|
||||
}
|
||||
|
||||
func filerLongDesc() string {
|
||||
@@ -235,6 +250,18 @@ func runFiler(cmd *Command, args []string) bool {
|
||||
time.Sleep(delay * time.Second)
|
||||
filerIamOptions.startIamServer()
|
||||
}(startDelay)
|
||||
startDelay++
|
||||
}
|
||||
|
||||
if *filerStartSftp {
|
||||
sftpOptions.filer = &filerAddress
|
||||
if *f.dataCenter != "" && *filerSftpOptions.dataCenter == "" {
|
||||
filerSftpOptions.dataCenter = f.dataCenter
|
||||
}
|
||||
go func(delay time.Duration) {
|
||||
time.Sleep(delay * time.Second)
|
||||
sftpOptions.startSftpServer()
|
||||
}(startDelay)
|
||||
}
|
||||
|
||||
f.masters = pb.ServerAddresses(*f.mastersString).ToServiceDiscovery()
|
||||
|
||||
@@ -28,6 +28,7 @@ var (
|
||||
masterOptions MasterOptions
|
||||
filerOptions FilerOptions
|
||||
s3Options S3Options
|
||||
sftpOptions SftpOptions
|
||||
iamOptions IamOptions
|
||||
webdavOptions WebDavOption
|
||||
mqBrokerOptions MessageQueueBrokerOptions
|
||||
@@ -73,6 +74,7 @@ var (
|
||||
isStartingVolumeServer = cmdServer.Flag.Bool("volume", true, "whether to start volume server")
|
||||
isStartingFiler = cmdServer.Flag.Bool("filer", false, "whether to start filer")
|
||||
isStartingS3 = cmdServer.Flag.Bool("s3", false, "whether to start S3 gateway")
|
||||
isStartingSftp = cmdServer.Flag.Bool("sftp", false, "whether to start Sftp server")
|
||||
isStartingIam = cmdServer.Flag.Bool("iam", false, "whether to start IAM service")
|
||||
isStartingWebDav = cmdServer.Flag.Bool("webdav", false, "whether to start WebDAV gateway")
|
||||
isStartingMqBroker = cmdServer.Flag.Bool("mq.broker", false, "whether to start message queue broker")
|
||||
@@ -159,6 +161,17 @@ func init() {
|
||||
s3Options.bindIp = cmdServer.Flag.String("s3.ip.bind", "", "ip address to bind to. If empty, default to same as -ip.bind option.")
|
||||
s3Options.idleTimeout = cmdServer.Flag.Int("s3.idleTimeout", 10, "connection idle seconds")
|
||||
|
||||
sftpOptions.port = cmdServer.Flag.Int("sftp.port", 2022, "SFTP server listen port")
|
||||
sftpOptions.sshPrivateKey = cmdServer.Flag.String("sftp.sshPrivateKey", "", "path to the SSH private key file for host authentication")
|
||||
sftpOptions.hostKeysFolder = cmdServer.Flag.String("sftp.hostKeysFolder", "", "path to folder containing SSH private key files for host authentication")
|
||||
sftpOptions.authMethods = cmdServer.Flag.String("sftp.authMethods", "password,publickey", "comma-separated list of allowed auth methods: password, publickey, keyboard-interactive")
|
||||
sftpOptions.maxAuthTries = cmdServer.Flag.Int("sftp.maxAuthTries", 6, "maximum number of authentication attempts per connection")
|
||||
sftpOptions.bannerMessage = cmdServer.Flag.String("sftp.bannerMessage", "SeaweedFS SFTP Server - Unauthorized access is prohibited", "message displayed before authentication")
|
||||
sftpOptions.loginGraceTime = cmdServer.Flag.Duration("sftp.loginGraceTime", 2*time.Minute, "timeout for authentication")
|
||||
sftpOptions.clientAliveInterval = cmdServer.Flag.Duration("sftp.clientAliveInterval", 5*time.Second, "interval for sending keep-alive messages")
|
||||
sftpOptions.clientAliveCountMax = cmdServer.Flag.Int("sftp.clientAliveCountMax", 3, "maximum number of missed keep-alive messages before disconnecting")
|
||||
sftpOptions.userStoreFile = cmdServer.Flag.String("sftp.userStoreFile", "", "path to JSON file containing user credentials and permissions")
|
||||
sftpOptions.localSocket = cmdServer.Flag.String("sftp.localSocket", "", "default to /tmp/seaweedfs-sftp-<port>.sock")
|
||||
iamOptions.port = cmdServer.Flag.Int("iam.port", 8111, "iam server http listen port")
|
||||
|
||||
webdavOptions.port = cmdServer.Flag.Int("webdav.port", 7333, "webdav server http listen port")
|
||||
@@ -190,6 +203,9 @@ func runServer(cmd *Command, args []string) bool {
|
||||
if *isStartingS3 {
|
||||
*isStartingFiler = true
|
||||
}
|
||||
if *isStartingSftp {
|
||||
*isStartingFiler = true
|
||||
}
|
||||
if *isStartingIam {
|
||||
*isStartingFiler = true
|
||||
}
|
||||
@@ -223,6 +239,9 @@ func runServer(cmd *Command, args []string) bool {
|
||||
if *s3Options.bindIp == "" {
|
||||
s3Options.bindIp = serverBindIp
|
||||
}
|
||||
if sftpOptions.bindIp == nil || *sftpOptions.bindIp == "" {
|
||||
sftpOptions.bindIp = serverBindIp
|
||||
}
|
||||
iamOptions.ip = serverBindIp
|
||||
iamOptions.masters = masterOptions.peers
|
||||
webdavOptions.ipBind = serverBindIp
|
||||
@@ -246,11 +265,13 @@ func runServer(cmd *Command, args []string) bool {
|
||||
mqBrokerOptions.dataCenter = serverDataCenter
|
||||
mqBrokerOptions.rack = serverRack
|
||||
s3Options.dataCenter = serverDataCenter
|
||||
sftpOptions.dataCenter = serverDataCenter
|
||||
filerOptions.disableHttp = serverDisableHttp
|
||||
masterOptions.disableHttp = serverDisableHttp
|
||||
|
||||
filerAddress := string(pb.NewServerAddress(*serverIp, *filerOptions.port, *filerOptions.portGrpc))
|
||||
s3Options.filer = &filerAddress
|
||||
sftpOptions.filer = &filerAddress
|
||||
iamOptions.filer = &filerAddress
|
||||
webdavOptions.filer = &filerAddress
|
||||
mqBrokerOptions.filerGroup = filerOptions.filerGroup
|
||||
@@ -291,6 +312,14 @@ func runServer(cmd *Command, args []string) bool {
|
||||
}()
|
||||
}
|
||||
|
||||
if *isStartingSftp {
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
sftpOptions.localSocket = filerOptions.localSocket
|
||||
sftpOptions.startSftpServer()
|
||||
}()
|
||||
}
|
||||
|
||||
if *isStartingIam {
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
193
weed/command/sftp.go
Normal file
193
weed/command/sftp.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/sftpd"
|
||||
stats_collect "github.com/seaweedfs/seaweedfs/weed/stats"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
var (
|
||||
sftpOptionsStandalone SftpOptions
|
||||
)
|
||||
|
||||
// SftpOptions holds configuration options for the SFTP server.
|
||||
type SftpOptions struct {
|
||||
filer *string
|
||||
bindIp *string
|
||||
port *int
|
||||
sshPrivateKey *string
|
||||
hostKeysFolder *string
|
||||
authMethods *string
|
||||
maxAuthTries *int
|
||||
bannerMessage *string
|
||||
loginGraceTime *time.Duration
|
||||
clientAliveInterval *time.Duration
|
||||
clientAliveCountMax *int
|
||||
userStoreFile *string
|
||||
dataCenter *string
|
||||
metricsHttpPort *int
|
||||
metricsHttpIp *string
|
||||
localSocket *string
|
||||
}
|
||||
|
||||
// cmdSftp defines the SFTP command similar to the S3 command.
|
||||
var cmdSftp = &Command{
|
||||
UsageLine: "sftp [-port=2022] [-filer=<ip:port>] [-sshPrivateKey=</path/to/private_key>]",
|
||||
Short: "start an SFTP server that is backed by a SeaweedFS filer",
|
||||
Long: `Start an SFTP server that leverages the SeaweedFS filer service to handle file operations.
|
||||
|
||||
Instead of reading from or writing to a local filesystem, all file operations
|
||||
are routed through the filer (filer_pb) gRPC API. This allows you to centralize
|
||||
your file management in SeaweedFS.
|
||||
`,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Register the command to avoid cyclic dependencies.
|
||||
cmdSftp.Run = runSftp
|
||||
|
||||
sftpOptionsStandalone.filer = cmdSftp.Flag.String("filer", "localhost:8888", "filer server address (ip:port)")
|
||||
sftpOptionsStandalone.bindIp = cmdSftp.Flag.String("ip.bind", "0.0.0.0", "ip address to bind SFTP server")
|
||||
sftpOptionsStandalone.port = cmdSftp.Flag.Int("port", 2022, "SFTP server listen port")
|
||||
sftpOptionsStandalone.sshPrivateKey = cmdSftp.Flag.String("sshPrivateKey", "", "path to the SSH private key file for host authentication")
|
||||
sftpOptionsStandalone.hostKeysFolder = cmdSftp.Flag.String("hostKeysFolder", "", "path to folder containing SSH private key files for host authentication")
|
||||
sftpOptionsStandalone.authMethods = cmdSftp.Flag.String("authMethods", "password,publickey", "comma-separated list of allowed auth methods: password, publickey, keyboard-interactive")
|
||||
sftpOptionsStandalone.maxAuthTries = cmdSftp.Flag.Int("maxAuthTries", 6, "maximum number of authentication attempts per connection")
|
||||
sftpOptionsStandalone.bannerMessage = cmdSftp.Flag.String("bannerMessage", "SeaweedFS SFTP Server - Unauthorized access is prohibited", "message displayed before authentication")
|
||||
sftpOptionsStandalone.loginGraceTime = cmdSftp.Flag.Duration("loginGraceTime", 2*time.Minute, "timeout for authentication")
|
||||
sftpOptionsStandalone.clientAliveInterval = cmdSftp.Flag.Duration("clientAliveInterval", 5*time.Second, "interval for sending keep-alive messages")
|
||||
sftpOptionsStandalone.clientAliveCountMax = cmdSftp.Flag.Int("clientAliveCountMax", 3, "maximum number of missed keep-alive messages before disconnecting")
|
||||
sftpOptionsStandalone.userStoreFile = cmdSftp.Flag.String("userStoreFile", "", "path to JSON file containing user credentials and permissions")
|
||||
sftpOptionsStandalone.dataCenter = cmdSftp.Flag.String("dataCenter", "", "prefer to read and write to volumes in this data center")
|
||||
sftpOptionsStandalone.metricsHttpPort = cmdSftp.Flag.Int("metricsPort", 0, "Prometheus metrics listen port")
|
||||
sftpOptionsStandalone.metricsHttpIp = cmdSftp.Flag.String("metricsIp", "", "metrics listen ip. If empty, default to same as -ip.bind option.")
|
||||
sftpOptionsStandalone.localSocket = cmdSftp.Flag.String("localSocket", "", "default to /tmp/seaweedfs-sftp-<port>.sock")
|
||||
}
|
||||
|
||||
// runSftp is the command entry point.
|
||||
func runSftp(cmd *Command, args []string) bool {
|
||||
// Load security configuration as done in other SeaweedFS services.
|
||||
util.LoadSecurityConfiguration()
|
||||
|
||||
// Configure metrics
|
||||
switch {
|
||||
case *sftpOptionsStandalone.metricsHttpIp != "":
|
||||
// nothing to do, use sftpOptionsStandalone.metricsHttpIp
|
||||
case *sftpOptionsStandalone.bindIp != "":
|
||||
*sftpOptionsStandalone.metricsHttpIp = *sftpOptionsStandalone.bindIp
|
||||
}
|
||||
go stats_collect.StartMetricsServer(*sftpOptionsStandalone.metricsHttpIp, *sftpOptionsStandalone.metricsHttpPort)
|
||||
|
||||
return sftpOptionsStandalone.startSftpServer()
|
||||
}
|
||||
|
||||
func (sftpOpt *SftpOptions) startSftpServer() bool {
|
||||
filerAddress := pb.ServerAddress(*sftpOpt.filer)
|
||||
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.client")
|
||||
|
||||
// metrics read from the filer
|
||||
var metricsAddress string
|
||||
var metricsIntervalSec int
|
||||
var filerGroup string
|
||||
|
||||
// Connect to the filer service and try to retrieve basic configuration.
|
||||
for {
|
||||
err := pb.WithGrpcFilerClient(false, 0, filerAddress, grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.GetFilerConfiguration(context.Background(), &filer_pb.GetFilerConfigurationRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get filer %s configuration: %v", filerAddress, err)
|
||||
}
|
||||
metricsAddress, metricsIntervalSec = resp.MetricsAddress, int(resp.MetricsIntervalSec)
|
||||
filerGroup = resp.FilerGroup
|
||||
glog.V(0).Infof("SFTP read filer configuration, using filer at: %s", filerAddress)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
glog.V(0).Infof("Waiting to connect to filer %s grpc address %s...", *sftpOpt.filer, filerAddress.ToGrpcAddress())
|
||||
time.Sleep(time.Second)
|
||||
} else {
|
||||
glog.V(0).Infof("Connected to filer %s grpc address %s", *sftpOpt.filer, filerAddress.ToGrpcAddress())
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
go stats_collect.LoopPushingMetric("sftp", stats_collect.SourceName(uint32(*sftpOpt.port)), metricsAddress, metricsIntervalSec)
|
||||
|
||||
// Parse auth methods
|
||||
var authMethods []string
|
||||
if *sftpOpt.authMethods != "" {
|
||||
authMethods = util.StringSplit(*sftpOpt.authMethods, ",")
|
||||
}
|
||||
|
||||
// Create a new SFTP service instance with all options
|
||||
service := sftpd.NewSFTPService(&sftpd.SFTPServiceOptions{
|
||||
GrpcDialOption: grpcDialOption,
|
||||
DataCenter: *sftpOpt.dataCenter,
|
||||
FilerGroup: filerGroup,
|
||||
Filer: filerAddress,
|
||||
SshPrivateKey: *sftpOpt.sshPrivateKey,
|
||||
HostKeysFolder: *sftpOpt.hostKeysFolder,
|
||||
AuthMethods: authMethods,
|
||||
MaxAuthTries: *sftpOpt.maxAuthTries,
|
||||
BannerMessage: *sftpOpt.bannerMessage,
|
||||
LoginGraceTime: *sftpOpt.loginGraceTime,
|
||||
ClientAliveInterval: *sftpOpt.clientAliveInterval,
|
||||
ClientAliveCountMax: *sftpOpt.clientAliveCountMax,
|
||||
UserStoreFile: *sftpOpt.userStoreFile,
|
||||
})
|
||||
|
||||
// Set up Unix socket if on non-Windows platforms
|
||||
if runtime.GOOS != "windows" {
|
||||
localSocket := *sftpOpt.localSocket
|
||||
if localSocket == "" {
|
||||
localSocket = fmt.Sprintf("/tmp/seaweedfs-sftp-%d.sock", *sftpOpt.port)
|
||||
}
|
||||
if err := os.Remove(localSocket); err != nil && !os.IsNotExist(err) {
|
||||
glog.Fatalf("Failed to remove %s, error: %s", localSocket, err.Error())
|
||||
}
|
||||
go func() {
|
||||
// start on local unix socket
|
||||
sftpSocketListener, err := net.Listen("unix", localSocket)
|
||||
if err != nil {
|
||||
glog.Fatalf("Failed to listen on %s: %v", localSocket, err)
|
||||
}
|
||||
if err := service.Serve(sftpSocketListener); err != nil {
|
||||
glog.Fatalf("Failed to serve SFTP on socket %s: %v", localSocket, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Start the SFTP service on TCP
|
||||
listenAddress := fmt.Sprintf("%s:%d", *sftpOpt.bindIp, *sftpOpt.port)
|
||||
sftpListener, sftpLocalListener, err := util.NewIpAndLocalListeners(*sftpOpt.bindIp, *sftpOpt.port, time.Duration(10)*time.Second)
|
||||
if err != nil {
|
||||
glog.Fatalf("SFTP server listener on %s error: %v", listenAddress, err)
|
||||
}
|
||||
|
||||
glog.V(0).Infof("Start Seaweed SFTP Server %s at %s", util.Version(), listenAddress)
|
||||
|
||||
if sftpLocalListener != nil {
|
||||
go func() {
|
||||
if err := service.Serve(sftpLocalListener); err != nil {
|
||||
glog.Fatalf("SFTP Server failed to serve on local listener: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if err := service.Serve(sftpListener); err != nil {
|
||||
glog.Fatalf("SFTP Server failed to serve: %v", err)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package ftpd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"net"
|
||||
|
||||
ftpserver "github.com/fclairamb/ftpserverlib"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type FtpServerOption struct {
|
||||
Filer string
|
||||
IP string
|
||||
IpBind string
|
||||
Port int
|
||||
FilerGrpcAddress string
|
||||
FtpRoot string
|
||||
GrpcDialOption grpc.DialOption
|
||||
PassivePortStart int
|
||||
PassivePortStop int
|
||||
}
|
||||
|
||||
type SftpServer struct {
|
||||
option *FtpServerOption
|
||||
ftpListener net.Listener
|
||||
}
|
||||
|
||||
var _ = ftpserver.MainDriver(&SftpServer{})
|
||||
|
||||
// NewFtpServer returns a new FTP server driver
|
||||
func NewFtpServer(ftpListener net.Listener, option *FtpServerOption) (*SftpServer, error) {
|
||||
var err error
|
||||
server := &SftpServer{
|
||||
option: option,
|
||||
ftpListener: ftpListener,
|
||||
}
|
||||
return server, err
|
||||
}
|
||||
|
||||
// GetSettings returns some general settings around the server setup
|
||||
func (s *SftpServer) GetSettings() (*ftpserver.Settings, error) {
|
||||
var portRange *ftpserver.PortRange
|
||||
if s.option.PassivePortStart > 0 && s.option.PassivePortStop > s.option.PassivePortStart {
|
||||
portRange = &ftpserver.PortRange{
|
||||
Start: s.option.PassivePortStart,
|
||||
End: s.option.PassivePortStop,
|
||||
}
|
||||
}
|
||||
|
||||
return &ftpserver.Settings{
|
||||
Listener: s.ftpListener,
|
||||
ListenAddr: util.JoinHostPort(s.option.IpBind, s.option.Port),
|
||||
PublicHost: s.option.IP,
|
||||
PassiveTransferPortRange: portRange,
|
||||
ActiveTransferPortNon20: true,
|
||||
IdleTimeout: -1,
|
||||
ConnectionTimeout: 20,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ClientConnected is called to send the very first welcome message
|
||||
func (s *SftpServer) ClientConnected(cc ftpserver.ClientContext) (string, error) {
|
||||
return "Welcome to SeaweedFS FTP Server", nil
|
||||
}
|
||||
|
||||
// ClientDisconnected is called when the user disconnects, even if he never authenticated
|
||||
func (s *SftpServer) ClientDisconnected(cc ftpserver.ClientContext) {
|
||||
}
|
||||
|
||||
// AuthUser authenticates the user and selects an handling driver
|
||||
func (s *SftpServer) AuthUser(cc ftpserver.ClientContext, username, password string) (ftpserver.ClientDriver, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetTLSConfig returns a TLS Certificate to use
|
||||
// The certificate could frequently change if we use something like "let's encrypt"
|
||||
func (s *SftpServer) GetTLSConfig() (*tls.Config, error) {
|
||||
return nil, errors.New("no TLS certificate configured")
|
||||
}
|
||||
76
weed/sftpd/auth/auth.go
Normal file
76
weed/sftpd/auth/auth.go
Normal 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)
|
||||
}
|
||||
64
weed/sftpd/auth/password.go
Normal file
64
weed/sftpd/auth/password.go
Normal 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
|
||||
}
|
||||
267
weed/sftpd/auth/permissions.go
Normal file
267
weed/sftpd/auth/permissions.go
Normal 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
|
||||
}
|
||||
68
weed/sftpd/auth/publickey.go
Normal file
68
weed/sftpd/auth/publickey.go
Normal 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
|
||||
}
|
||||
457
weed/sftpd/sftp_filer.go
Normal file
457
weed/sftpd/sftp_filer.go
Normal file
@@ -0,0 +1,457 @@
|
||||
// sftp_filer_refactored.go
|
||||
package sftpd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
weed_server "github.com/seaweedfs/seaweedfs/weed/server"
|
||||
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 30 * time.Second
|
||||
defaultListLimit = 1000
|
||||
)
|
||||
|
||||
// ==================== Filer RPC Helpers ====================
|
||||
|
||||
// callWithClient wraps a gRPC client call with timeout and client creation.
|
||||
func (fs *SftpServer) callWithClient(streaming bool, fn func(ctx context.Context, client filer_pb.SeaweedFilerClient) error) error {
|
||||
return fs.withTimeoutContext(func(ctx context.Context) error {
|
||||
return fs.WithFilerClient(streaming, func(client filer_pb.SeaweedFilerClient) error {
|
||||
return fn(ctx, client)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// getEntry retrieves a single directory entry by path.
|
||||
func (fs *SftpServer) getEntry(p string) (*filer_pb.Entry, error) {
|
||||
dir, name := util.FullPath(p).DirAndName()
|
||||
var entry *filer_pb.Entry
|
||||
err := fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
|
||||
r, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{Directory: dir, Name: name})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Entry == nil {
|
||||
return fmt.Errorf("%s not found in %s", name, dir)
|
||||
}
|
||||
entry = r.Entry
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup %s: %w", p, err)
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// updateEntry sends an UpdateEntryRequest for the given entry.
|
||||
func (fs *SftpServer) updateEntry(dir string, entry *filer_pb.Entry) error {
|
||||
return fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{Directory: dir, Entry: entry})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== FilerClient Interface ====================
|
||||
|
||||
func (fs *SftpServer) AdjustedUrl(location *filer_pb.Location) string { return location.Url }
|
||||
func (fs *SftpServer) GetDataCenter() string { return fs.dataCenter }
|
||||
func (fs *SftpServer) WithFilerClient(streamingMode bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
addr := fs.filerAddr.ToGrpcAddress()
|
||||
return pb.WithGrpcClient(streamingMode, util.RandomInt32(), func(conn *grpc.ClientConn) error {
|
||||
return fn(filer_pb.NewSeaweedFilerClient(conn))
|
||||
}, addr, false, fs.grpcDialOption)
|
||||
}
|
||||
func (fs *SftpServer) withTimeoutContext(fn func(ctx context.Context) error) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
// ==================== Command Dispatcher ====================
|
||||
|
||||
func (fs *SftpServer) dispatchCmd(r *sftp.Request) error {
|
||||
glog.V(0).Infof("Dispatch: %s %s", r.Method, r.Filepath)
|
||||
switch r.Method {
|
||||
case "Remove":
|
||||
return fs.removeEntry(r)
|
||||
case "Rename":
|
||||
return fs.renameEntry(r)
|
||||
case "Mkdir":
|
||||
return fs.makeDir(r)
|
||||
case "Rmdir":
|
||||
return fs.removeDir(r)
|
||||
case "Setstat":
|
||||
return fs.setFileStat(r)
|
||||
default:
|
||||
return fmt.Errorf("unsupported: %s", r.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== File Operations ====================
|
||||
|
||||
func (fs *SftpServer) readFile(r *sftp.Request) (io.ReaderAt, error) {
|
||||
if err := fs.checkFilePermission(r.Filepath, "read"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, err := fs.getEntry(r.Filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SeaweedFileReaderAt{fs: fs, entry: entry}, nil
|
||||
}
|
||||
|
||||
// putFile uploads a file to the filer and sets ownership metadata.
|
||||
func (fs *SftpServer) putFile(filepath string, data []byte, user *user.User) error {
|
||||
dir, filename := util.FullPath(filepath).DirAndName()
|
||||
uploadUrl := fmt.Sprintf("http://%s%s", fs.filerAddr, filepath)
|
||||
|
||||
// Create a reader from our buffered data and calculate MD5 hash
|
||||
hash := md5.New()
|
||||
reader := bytes.NewReader(data)
|
||||
body := io.TeeReader(reader, hash)
|
||||
fileSize := int64(len(data))
|
||||
|
||||
// Create and execute HTTP request
|
||||
proxyReq, err := http.NewRequest(http.MethodPut, uploadUrl, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %v", err)
|
||||
}
|
||||
proxyReq.ContentLength = fileSize
|
||||
proxyReq.Header.Set("Content-Type", "application/octet-stream")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(proxyReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload to filer: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Process response
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result weed_server.FilerPostResult
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return fmt.Errorf("parse response: %v", err)
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
return fmt.Errorf("filer error: %s", result.Error)
|
||||
}
|
||||
|
||||
// Update file ownership using the same pattern as other functions
|
||||
if user != nil {
|
||||
err := fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
|
||||
// Look up the file to get its current entry
|
||||
lookupResp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: dir,
|
||||
Name: filename,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup file for attribute update: %v", err)
|
||||
}
|
||||
|
||||
if lookupResp.Entry == nil {
|
||||
return fmt.Errorf("file not found after upload: %s/%s", dir, filename)
|
||||
}
|
||||
|
||||
// Update the entry with new uid/gid
|
||||
entry := lookupResp.Entry
|
||||
entry.Attributes.Uid = user.Uid
|
||||
entry.Attributes.Gid = user.Gid
|
||||
|
||||
// Update the entry in the filer
|
||||
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
|
||||
Directory: dir,
|
||||
Entry: entry,
|
||||
})
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Log the error but don't fail the whole operation
|
||||
glog.Errorf("Failed to update file ownership for %s: %v", filepath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *SftpServer) newFileWriter(r *sftp.Request) (io.WriterAt, error) {
|
||||
return &filerFileWriter{fs: *fs, req: r, permissions: 0644, uid: fs.user.Uid, gid: fs.user.Gid}, nil
|
||||
}
|
||||
|
||||
func (fs *SftpServer) removeEntry(r *sftp.Request) error {
|
||||
return fs.deleteEntry(r.Filepath, false)
|
||||
}
|
||||
|
||||
func (fs *SftpServer) renameEntry(r *sftp.Request) error {
|
||||
if err := fs.checkFilePermission(r.Filepath, "rename"); err != nil {
|
||||
return err
|
||||
}
|
||||
oldDir, oldName := util.FullPath(r.Filepath).DirAndName()
|
||||
newDir, newName := util.FullPath(r.Target).DirAndName()
|
||||
return fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.AtomicRenameEntry(ctx, &filer_pb.AtomicRenameEntryRequest{
|
||||
OldDirectory: oldDir, OldName: oldName,
|
||||
NewDirectory: newDir, NewName: newName,
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (fs *SftpServer) setFileStat(r *sftp.Request) error {
|
||||
if err := fs.checkFilePermission(r.Filepath, "write"); err != nil {
|
||||
return err
|
||||
}
|
||||
entry, err := fs.getEntry(r.Filepath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir, _ := util.FullPath(r.Filepath).DirAndName()
|
||||
// apply attrs
|
||||
if r.AttrFlags().Permissions {
|
||||
entry.Attributes.FileMode = uint32(r.Attributes().FileMode())
|
||||
}
|
||||
if r.AttrFlags().UidGid {
|
||||
entry.Attributes.Uid = uint32(r.Attributes().UID)
|
||||
entry.Attributes.Gid = uint32(r.Attributes().GID)
|
||||
}
|
||||
if r.AttrFlags().Acmodtime {
|
||||
entry.Attributes.Mtime = int64(r.Attributes().Mtime)
|
||||
}
|
||||
if r.AttrFlags().Size {
|
||||
entry.Attributes.FileSize = uint64(r.Attributes().Size)
|
||||
}
|
||||
return fs.updateEntry(dir, entry)
|
||||
}
|
||||
|
||||
// ==================== Directory Operations ====================
|
||||
|
||||
func (fs *SftpServer) listDir(r *sftp.Request) (sftp.ListerAt, error) {
|
||||
if err := fs.checkFilePermission(r.Filepath, "list"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Method == "Stat" || r.Method == "Lstat" {
|
||||
entry, err := fs.getEntry(r.Filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fi := &EnhancedFileInfo{FileInfo: FileInfoFromEntry(entry), uid: entry.Attributes.Uid, gid: entry.Attributes.Gid}
|
||||
return listerat([]os.FileInfo{fi}), nil
|
||||
}
|
||||
return fs.listAllPages(r.Filepath)
|
||||
}
|
||||
|
||||
func (fs *SftpServer) listAllPages(dirPath string) (sftp.ListerAt, error) {
|
||||
var all []os.FileInfo
|
||||
last := ""
|
||||
for {
|
||||
page, err := fs.fetchDirectoryPage(dirPath, last)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, page...)
|
||||
if len(page) < defaultListLimit {
|
||||
break
|
||||
}
|
||||
last = page[len(page)-1].Name()
|
||||
}
|
||||
return listerat(all), nil
|
||||
}
|
||||
|
||||
func (fs *SftpServer) fetchDirectoryPage(dirPath, start string) ([]os.FileInfo, error) {
|
||||
var list []os.FileInfo
|
||||
err := fs.callWithClient(true, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
|
||||
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{Directory: dirPath, StartFromFileName: start, Limit: defaultListLimit})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
r, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil || r.Entry == nil {
|
||||
continue
|
||||
}
|
||||
p := path.Join(dirPath, r.Entry.Name)
|
||||
if err := fs.checkFilePermission(p, "list"); err != nil {
|
||||
continue
|
||||
}
|
||||
list = append(list, &EnhancedFileInfo{FileInfo: FileInfoFromEntry(r.Entry), uid: r.Entry.Attributes.Uid, gid: r.Entry.Attributes.Gid})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return list, err
|
||||
}
|
||||
|
||||
// makeDir creates a new directory with proper permissions.
|
||||
func (fs *SftpServer) makeDir(r *sftp.Request) error {
|
||||
if fs.user == nil {
|
||||
return fmt.Errorf("cannot create directory: no user info")
|
||||
}
|
||||
dir, name := util.FullPath(r.Filepath).DirAndName()
|
||||
if err := fs.checkFilePermission(dir, "mkdir"); err != nil {
|
||||
return err
|
||||
}
|
||||
// default mode and ownership
|
||||
err := filer_pb.Mkdir(fs, string(dir), name, func(entry *filer_pb.Entry) {
|
||||
mode := uint32(0755 | os.ModeDir)
|
||||
if strings.HasPrefix(r.Filepath, fs.user.HomeDir) {
|
||||
mode = uint32(0700 | os.ModeDir)
|
||||
}
|
||||
entry.Attributes.FileMode = mode
|
||||
entry.Attributes.Uid = fs.user.Uid
|
||||
entry.Attributes.Gid = fs.user.Gid
|
||||
now := time.Now().Unix()
|
||||
entry.Attributes.Crtime = now
|
||||
entry.Attributes.Mtime = now
|
||||
if entry.Extended == nil {
|
||||
entry.Extended = make(map[string][]byte)
|
||||
}
|
||||
entry.Extended["creator"] = []byte(fs.user.Username)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// removeDir deletes a directory.
|
||||
func (fs *SftpServer) removeDir(r *sftp.Request) error {
|
||||
return fs.deleteEntry(r.Filepath, false)
|
||||
}
|
||||
|
||||
// ==================== Common Arguments Helpers ====================
|
||||
|
||||
func FileInfoFromEntry(e *filer_pb.Entry) FileInfo {
|
||||
return FileInfo{name: e.Name, size: int64(e.Attributes.FileSize), mode: os.FileMode(e.Attributes.FileMode), modTime: time.Unix(e.Attributes.Mtime, 0), isDir: e.IsDirectory}
|
||||
}
|
||||
|
||||
func (fs *SftpServer) deleteEntry(p string, recursive bool) error {
|
||||
if err := fs.checkFilePermission(p, "delete"); err != nil {
|
||||
return err
|
||||
}
|
||||
dir, name := util.FullPath(p).DirAndName()
|
||||
return fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
|
||||
r, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{Directory: dir, Name: name, IsDeleteData: true, IsRecursive: recursive})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Error != "" {
|
||||
return fmt.Errorf("%s", r.Error)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Custom Types ====================
|
||||
|
||||
type EnhancedFileInfo struct {
|
||||
FileInfo
|
||||
uid uint32
|
||||
gid uint32
|
||||
}
|
||||
|
||||
func (fi *EnhancedFileInfo) Sys() interface{} {
|
||||
return &syscall.Stat_t{Uid: fi.uid, Gid: fi.gid}
|
||||
}
|
||||
|
||||
func (fi *EnhancedFileInfo) Owner() (uid, gid int) {
|
||||
return int(fi.uid), int(fi.gid)
|
||||
}
|
||||
|
||||
// SeaweedFileReaderAt implements io.ReaderAt for SeaweedFS files
|
||||
|
||||
type SeaweedFileReaderAt struct {
|
||||
fs *SftpServer
|
||||
entry *filer_pb.Entry
|
||||
}
|
||||
|
||||
func (ra *SeaweedFileReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
|
||||
// Create a new reader for each ReadAt call
|
||||
reader := filer.NewFileReader(ra.fs, ra.entry)
|
||||
if reader == nil {
|
||||
return 0, fmt.Errorf("failed to create file reader")
|
||||
}
|
||||
|
||||
// Check if we're reading past the end of the file
|
||||
fileSize := int64(ra.entry.Attributes.FileSize)
|
||||
if off >= fileSize {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
// Seek to the offset
|
||||
if seeker, ok := reader.(io.Seeker); ok {
|
||||
_, err = seeker.Seek(off, io.SeekStart)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("seek error: %v", err)
|
||||
}
|
||||
} else {
|
||||
// If the reader doesn't implement Seek, we need to read and discard bytes
|
||||
toSkip := off
|
||||
skipBuf := make([]byte, 8192)
|
||||
for toSkip > 0 {
|
||||
skipSize := int64(len(skipBuf))
|
||||
if skipSize > toSkip {
|
||||
skipSize = toSkip
|
||||
}
|
||||
read, err := reader.Read(skipBuf[:skipSize])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("skip error: %v", err)
|
||||
}
|
||||
if read == 0 {
|
||||
return 0, fmt.Errorf("unable to skip to offset %d", off)
|
||||
}
|
||||
toSkip -= int64(read)
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust read length if it would go past EOF
|
||||
readLen := len(p)
|
||||
remaining := fileSize - off
|
||||
if int64(readLen) > remaining {
|
||||
readLen = int(remaining)
|
||||
if readLen == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
}
|
||||
|
||||
// Read the data
|
||||
n, err = io.ReadFull(reader, p[:readLen])
|
||||
|
||||
// Handle EOF correctly
|
||||
if err == io.ErrUnexpectedEOF || (err == nil && n < len(p)) {
|
||||
err = io.EOF
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (fs *SftpServer) checkFilePermission(filepath string, permissions string) error {
|
||||
return fs.authManager.CheckPermission(fs.user, filepath, permissions)
|
||||
}
|
||||
126
weed/sftpd/sftp_helpers.go
Normal file
126
weed/sftpd/sftp_helpers.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// sftp_helpers.go
|
||||
package sftpd
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
// FileInfo implements os.FileInfo.
|
||||
type FileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
mode os.FileMode
|
||||
modTime time.Time
|
||||
isDir bool
|
||||
}
|
||||
|
||||
func (fi *FileInfo) Name() string { return fi.name }
|
||||
func (fi *FileInfo) Size() int64 { return fi.size }
|
||||
func (fi *FileInfo) Mode() os.FileMode { return fi.mode }
|
||||
func (fi *FileInfo) ModTime() time.Time { return fi.modTime }
|
||||
func (fi *FileInfo) IsDir() bool { return fi.isDir }
|
||||
func (fi *FileInfo) Sys() interface{} { return nil }
|
||||
|
||||
// bufferReader wraps a byte slice to io.ReaderAt.
|
||||
type bufferReader struct {
|
||||
b []byte
|
||||
i int64
|
||||
}
|
||||
|
||||
func NewBufferReader(b []byte) *bufferReader { return &bufferReader{b: b} }
|
||||
|
||||
func (r *bufferReader) Read(p []byte) (int, error) {
|
||||
if r.i >= int64(len(r.b)) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, r.b[r.i:])
|
||||
r.i += int64(n)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *bufferReader) ReadAt(p []byte, off int64) (int, error) {
|
||||
if off >= int64(len(r.b)) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, r.b[off:])
|
||||
if n < len(p) {
|
||||
return n, io.EOF
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// listerat implements sftp.ListerAt.
|
||||
type listerat []os.FileInfo
|
||||
|
||||
func (l listerat) ListAt(ls []os.FileInfo, offset int64) (int, error) {
|
||||
if offset >= int64(len(l)) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(ls, l[offset:])
|
||||
if n < len(ls) {
|
||||
return n, io.EOF
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// filerFileWriter buffers writes and flushes on Close.
|
||||
type filerFileWriter struct {
|
||||
fs SftpServer
|
||||
req *sftp.Request
|
||||
mu sync.Mutex
|
||||
data []byte
|
||||
permissions os.FileMode
|
||||
uid uint32
|
||||
gid uint32
|
||||
offset int64
|
||||
}
|
||||
|
||||
func (w *filerFileWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
end := w.offset + int64(len(p))
|
||||
if end > int64(len(w.data)) {
|
||||
newBuf := make([]byte, end)
|
||||
copy(newBuf, w.data)
|
||||
w.data = newBuf
|
||||
}
|
||||
n := copy(w.data[w.offset:], p)
|
||||
w.offset += int64(n)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (w *filerFileWriter) WriteAt(p []byte, off int64) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
end := int(off) + len(p)
|
||||
if end > len(w.data) {
|
||||
newBuf := make([]byte, end)
|
||||
copy(newBuf, w.data)
|
||||
w.data = newBuf
|
||||
}
|
||||
n := copy(w.data[off:], p)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (w *filerFileWriter) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
dir, _ := util.FullPath(w.req.Filepath).DirAndName()
|
||||
|
||||
// Check permissions based on file metadata and user permissions
|
||||
if err := w.fs.checkFilePermission(dir, "write"); err != nil {
|
||||
glog.Errorf("Permission denied for %s", dir)
|
||||
return err
|
||||
}
|
||||
|
||||
// Call the extracted putFile method on SftpServer
|
||||
return w.fs.putFile(w.req.Filepath, w.data, w.fs.user)
|
||||
}
|
||||
59
weed/sftpd/sftp_server.go
Normal file
59
weed/sftpd/sftp_server.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// sftp_server.go
|
||||
package sftpd
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/sftpd/auth"
|
||||
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type SftpServer struct {
|
||||
filerAddr pb.ServerAddress
|
||||
grpcDialOption grpc.DialOption
|
||||
dataCenter string
|
||||
filerGroup string
|
||||
user *user.User
|
||||
authManager *auth.Manager
|
||||
}
|
||||
|
||||
// NewSftpServer constructs the server.
|
||||
func NewSftpServer(filerAddr pb.ServerAddress, grpcDialOption grpc.DialOption, dataCenter, filerGroup string, user *user.User) SftpServer {
|
||||
// Create a file system helper for the auth manager
|
||||
fsHelper := NewFileSystemHelper(filerAddr, grpcDialOption, dataCenter, filerGroup)
|
||||
|
||||
// Create an auth manager for permission checking
|
||||
authManager := auth.NewManager(nil, fsHelper, []string{})
|
||||
|
||||
return SftpServer{
|
||||
filerAddr: filerAddr,
|
||||
grpcDialOption: grpcDialOption,
|
||||
dataCenter: dataCenter,
|
||||
filerGroup: filerGroup,
|
||||
user: user,
|
||||
authManager: authManager,
|
||||
}
|
||||
}
|
||||
|
||||
// Fileread is invoked for “get” requests.
|
||||
func (fs *SftpServer) Fileread(req *sftp.Request) (io.ReaderAt, error) {
|
||||
return fs.readFile(req)
|
||||
}
|
||||
|
||||
// Filewrite is invoked for “put” requests.
|
||||
func (fs *SftpServer) Filewrite(req *sftp.Request) (io.WriterAt, error) {
|
||||
return fs.newFileWriter(req)
|
||||
}
|
||||
|
||||
// Filecmd handles Remove, Rename, Mkdir, Rmdir, etc.
|
||||
func (fs *SftpServer) Filecmd(req *sftp.Request) error {
|
||||
return fs.dispatchCmd(req)
|
||||
}
|
||||
|
||||
// Filelist handles directory listings.
|
||||
func (fs *SftpServer) Filelist(req *sftp.Request) (sftp.ListerAt, error) {
|
||||
return fs.listDir(req)
|
||||
}
|
||||
394
weed/sftpd/sftp_service.go
Normal file
394
weed/sftpd/sftp_service.go
Normal file
@@ -0,0 +1,394 @@
|
||||
// sftp_service.go
|
||||
package sftpd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/sftpd/auth"
|
||||
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"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
|
||||
homeManager *user.HomeManager
|
||||
}
|
||||
|
||||
// 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 file system helper for permission checking
|
||||
fsHelper := NewFileSystemHelper(
|
||||
options.Filer,
|
||||
options.GrpcDialOption,
|
||||
options.DataCenter,
|
||||
options.FilerGroup,
|
||||
)
|
||||
|
||||
// Initialize auth manager
|
||||
service.authManager = auth.NewManager(userStore, fsHelper, options.AuthMethods)
|
||||
|
||||
// Initialize home directory manager
|
||||
service.homeManager = user.NewHomeManager(fsHelper)
|
||||
|
||||
return &service
|
||||
}
|
||||
|
||||
// FileSystemHelper implements auth.FileSystemHelper interface
|
||||
type FileSystemHelper struct {
|
||||
filerAddr pb.ServerAddress
|
||||
grpcDialOption grpc.DialOption
|
||||
dataCenter string
|
||||
filerGroup string
|
||||
}
|
||||
|
||||
func NewFileSystemHelper(filerAddr pb.ServerAddress, grpcDialOption grpc.DialOption, dataCenter, filerGroup string) *FileSystemHelper {
|
||||
return &FileSystemHelper{
|
||||
filerAddr: filerAddr,
|
||||
grpcDialOption: grpcDialOption,
|
||||
dataCenter: dataCenter,
|
||||
filerGroup: filerGroup,
|
||||
}
|
||||
}
|
||||
|
||||
// GetEntry implements auth.FileSystemHelper interface
|
||||
func (fs *FileSystemHelper) GetEntry(path string) (*auth.Entry, error) {
|
||||
dir, name := util.FullPath(path).DirAndName()
|
||||
var entry *filer_pb.Entry
|
||||
|
||||
err := fs.withTimeoutContext(func(ctx context.Context) error {
|
||||
return fs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: dir,
|
||||
Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Entry == nil {
|
||||
return fmt.Errorf("entry not found")
|
||||
}
|
||||
entry = resp.Entry
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &auth.Entry{
|
||||
IsDirectory: entry.IsDirectory,
|
||||
Attributes: &auth.EntryAttributes{
|
||||
Uid: entry.Attributes.GetUid(),
|
||||
Gid: entry.Attributes.GetGid(),
|
||||
FileMode: entry.Attributes.GetFileMode(),
|
||||
SymlinkTarget: entry.Attributes.GetSymlinkTarget(),
|
||||
},
|
||||
IsSymlink: entry.Attributes.GetSymlinkTarget() != "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Implement FilerClient interface for FileSystemHelper
|
||||
func (fs *FileSystemHelper) AdjustedUrl(location *filer_pb.Location) string {
|
||||
return location.Url
|
||||
}
|
||||
|
||||
func (fs *FileSystemHelper) GetDataCenter() string {
|
||||
return fs.dataCenter
|
||||
}
|
||||
|
||||
func (fs *FileSystemHelper) WithFilerClient(streamingMode bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
addr := fs.filerAddr.ToGrpcAddress()
|
||||
return pb.WithGrpcClient(streamingMode, util.RandomInt32(), func(conn *grpc.ClientConn) error {
|
||||
return fn(filer_pb.NewSeaweedFilerClient(conn))
|
||||
}, addr, false, fs.grpcDialOption)
|
||||
}
|
||||
|
||||
func (fs *FileSystemHelper) withTimeoutContext(fn func(ctx context.Context) error) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
// 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: %v", 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: %v", 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: %v", 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
|
||||
log.Printf("Warning: failed to add host key %s: %v", keyPath, err)
|
||||
continue
|
||||
}
|
||||
hostKeysAdded++
|
||||
}
|
||||
|
||||
if hostKeysAdded == 0 {
|
||||
log.Printf("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 := s.homeManager.EnsureHomeDirectory(sftpUser); 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)
|
||||
}
|
||||
}
|
||||
143
weed/sftpd/sftp_userstore.go
Normal file
143
weed/sftpd/sftp_userstore.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package sftpd
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// UserStore interface for user management.
|
||||
type UserStore interface {
|
||||
GetUser(username string) (*User, error)
|
||||
ValidatePassword(username string, password []byte) bool
|
||||
ValidatePublicKey(username string, keyData string) bool
|
||||
GetUserPermissions(username string, path string) []string
|
||||
}
|
||||
|
||||
// User represents an SFTP user with authentication and permission details.
|
||||
type User struct {
|
||||
Username string
|
||||
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
|
||||
}
|
||||
|
||||
// FileUserStore implements UserStore using a JSON file.
|
||||
type FileUserStore struct {
|
||||
filePath string
|
||||
users map[string]*User
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewFileUserStore creates a new user store from a JSON file.
|
||||
func NewFileUserStore(filePath string) (*FileUserStore, error) {
|
||||
store := &FileUserStore{
|
||||
filePath: filePath,
|
||||
users: make(map[string]*User),
|
||||
}
|
||||
|
||||
if err := store.loadUsers(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// loadUsers loads users from the JSON file.
|
||||
func (s *FileUserStore) loadUsers() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(s.filePath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("user store file not found: %s", s.filePath)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
s.users[user.Username] = user
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUser returns a user by username.
|
||||
func (s *FileUserStore) GetUser(username string) (*User, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
user, ok := s.users[username]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("user not found: %s", username)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ValidatePassword checks if the password is valid for the user.
|
||||
func (s *FileUserStore) 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 *FileUserStore) ValidatePublicKey(username string, keyData string) bool {
|
||||
user, err := s.GetUser(username)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, key := range user.PublicKeys {
|
||||
if subtle.ConstantTimeCompare([]byte(key), []byte(keyData)) == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetUserPermissions returns the permissions for a user on a path.
|
||||
func (s *FileUserStore) 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 strings.HasPrefix(path, p) && len(p) > len(bestMatch) {
|
||||
bestMatch = p
|
||||
bestPerms = perms
|
||||
}
|
||||
}
|
||||
|
||||
return bestPerms
|
||||
}
|
||||
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