Files
seaweedfs/weed/command/sftp.go
T
Chris Lu 5f26ef6ad1 filer,webdav,iam,sftp: add -idleTimeout flag for connection idle timeout (#12136)
* filer: make the HTTP connection idle timeout configurable

The filer applied a hardcoded 10s activity timeout to its HTTP
listener. A buffering proxy in front of the filer can stall upstream
reads longer than that when the end client drains slowly, so large
downloads through it were truncated (#8062).

Expose the value as -idleTimeout (default 10, unchanged), matching the
existing -idleTimeout flags on the volume and s3 servers, and wire
filer.idleTimeout through 'weed server' and 'weed mini' too. A nil
option keeps the 10s default so embedders constructing FilerOptions
directly keep working.

* filer: apply the idle timeout to the readonly port too

The -port.readonly listener ran without any connection timeout while
the main API port timed out. Give it the same -idleTimeout so both
ports behave identically.

* webdav: make the HTTP connection idle timeout configurable

The WebDAV server used the same hardcoded 10s activity timeout.
Expose it as -idleTimeout (default 10, unchanged), including
-webdav.idleTimeout when embedded via 'weed filer -webdav',
'weed server -webdav', and 'weed mini'.

* iam: make the HTTP connection idle timeout configurable

The IAM API server used the same hardcoded 10s activity timeout.
Expose it as -idleTimeout (default 10, unchanged), including
-iam.idleTimeout when embedded via 'weed filer -iam' and
'weed server -iam'.

* sftp: make the connection idle timeout configurable

The SFTP server used the same hardcoded 10s activity timeout.
Expose it as -idleTimeout (default 10, unchanged), including
-sftp.idleTimeout when embedded via 'weed filer -sftp' and
'weed server -sftp'.

* command: clamp idle timeout seconds before converting to duration

A seconds value past time.Duration's range would overflow on
multiplication and silently disable the timeout. Bound it in one shared
helper, which also deduplicates the nil-default handling at the four
call sites.

* command: return zero for non-positive idle timeout seconds

A large negative seconds value could wrap on multiplication and come
out positive. Guard before converting so non-positive input always
disables the timeout.

* command: apply idle timeout to local unix socket listeners

The unix-socket listeners in filer, s3, and sftp served accepted
connections with no activity deadline while the TCP listeners already
wrapped conns in util.Listener. Wrap the socket listeners the same way
so a positive idleTimeout also bounds idle socket connections.

* command: clamp s3 idle timeout seconds before multiplying

s3 still multiplied the flag inline, so an out-of-range value could
overflow time.Duration. Route it through idleTimeoutSeconds like the
other commands; non-positive keeps meaning disabled.

* volume: clamp idle connection timeout seconds before converting

* command: cover idleTimeoutSeconds conversion semantics

* command: gate idleTimeoutSeconds clamp check on 64-bit int
2026-10-27 17:31:23 -07:00

229 lines
9.9 KiB
Go

package command
import (
"context"
"fmt"
"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"
"github.com/seaweedfs/seaweedfs/weed/util/grace"
"github.com/seaweedfs/seaweedfs/weed/util/version"
)
var (
sftpOptionsStandalone SftpOptions
)
// SftpOptions holds configuration options for the SFTP server.
type SftpOptions struct {
filer *string
bindIp *string
listenOnLocalhost *bool
port *int
idleTimeout *int
sshPrivateKey *string
hostKeysFolder *string
authMethods *string
maxAuthTries *int
bannerMessage *string
loginGraceTime *time.Duration
clientAliveInterval *time.Duration
clientAliveCountMax *int
userStoreFile *string
trustedUserCAKeysFile *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", "", "ip address to bind to. If empty, default to 0.0.0.0.")
sftpOptionsStandalone.port = cmdSftp.Flag.Int("port", 2022, "SFTP server listen port")
sftpOptionsStandalone.idleTimeout = cmdSftp.Flag.Int("idleTimeout", 10, "connection idle seconds")
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, certificate")
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.trustedUserCAKeysFile = cmdSftp.Flag.String("trustedUserCAKeysFile", "", "path to a file with trusted user CA public keys (OpenSSH authorized_keys format); required when 'certificate' is in -authMethods. Analogous to OpenSSH TrustedUserCAKeys and MinIO --sftp=trusted-user-ca-key")
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 {
sftpOptionsStandalone.resolvePaths()
// 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()
}
// resolvePaths expands "~" in every user-supplied path flag.
// Idempotent — safe to call from any entry point.
func (sftpOpt *SftpOptions) resolvePaths() {
*sftpOpt.sshPrivateKey = util.ResolvePath(*sftpOpt.sshPrivateKey)
*sftpOpt.hostKeysFolder = util.ResolvePath(*sftpOpt.hostKeysFolder)
*sftpOpt.userStoreFile = util.ResolvePath(*sftpOpt.userStoreFile)
*sftpOpt.trustedUserCAKeysFile = util.ResolvePath(*sftpOpt.trustedUserCAKeysFile)
}
func (sftpOpt *SftpOptions) startSftpServer() bool {
// The loopback companion listener is only kept while -ip.bind is unset.
// An explicit -ip.bind pins the port to exactly that address.
listenOnLocalhost := *sftpOpt.bindIp == ""
if sftpOpt.listenOnLocalhost != nil {
listenOnLocalhost = *sftpOpt.listenOnLocalhost
}
idleTimeout := idleTimeoutSeconds(sftpOpt.idleTimeout)
if *sftpOpt.bindIp == "" {
*sftpOpt.bindIp = "0.0.0.0"
}
util.SetOutboundLocalIP(*sftpOpt.bindIp)
filerAddress := pb.ServerAddress(*sftpOpt.filer)
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.client")
// Load JWT configuration for filer signing
v := util.GetViper()
filerSigningKey := v.GetString("jwt.filer_signing.key")
v.SetDefault("jwt.filer_signing.expires_after_seconds", 600)
filerSigningExpiresAfter := v.GetInt("jwt.filer_signing.expires_after_seconds")
// 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,
TrustedUserCAKeysFile: *sftpOpt.trustedUserCAKeysFile,
FilerSigningKey: []byte(filerSigningKey),
FilerSigningExpiresAfter: filerSigningExpiresAfter,
})
// Register reload hook for HUP signal
grace.OnReload(service.Reload)
// Set up Unix socket if on non-Windows platforms
if runtime.GOOS != "windows" {
localSocket := *sftpOpt.localSocket
explicit := localSocket != ""
if !explicit {
localSocket = fmt.Sprintf("/tmp/seaweedfs-sftp-%d.sock", *sftpOpt.port)
}
if sftpSocketListener := pb.ListenOnLocalSocket(localSocket); sftpSocketListener != nil {
sftpSocketListener = &util.Listener{Listener: sftpSocketListener, Timeout: idleTimeout}
go func() {
// start on local unix socket
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, idleTimeout, listenOnLocalhost)
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", version.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
}