implement support for FTP/SFTP server (#16952)

This commit is contained in:
Harshavardhana
2023-04-15 07:34:02 -07:00
committed by GitHub
parent e96c88e914
commit dd9ed85e22
10 changed files with 1541 additions and 11 deletions

505
cmd/ftp-server-driver.go Normal file
View File

@@ -0,0 +1,505 @@
// Copyright (c) 2015-2023 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package cmd
import (
"bytes"
"context"
"crypto/subtle"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/minio/madmin-go/v2"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/minio/minio/internal/auth"
"github.com/minio/minio/internal/logger"
ftp "goftp.io/server/v2"
)
var _ ftp.Driver = &ftpDriver{}
// ftpDriver implements ftpDriver to store files in minio
type ftpDriver struct {
endpoint string
}
// NewFTPDriver implements ftp.Driver interface
func NewFTPDriver() ftp.Driver {
return &ftpDriver{endpoint: fmt.Sprintf("127.0.0.1:%s", globalMinioPort)}
}
func buildMinioPath(p string) string {
return strings.TrimPrefix(p, SlashSeparator)
}
func buildMinioDir(p string) string {
v := buildMinioPath(p)
if !strings.HasSuffix(v, SlashSeparator) {
return v + SlashSeparator
}
return v
}
type minioFileInfo struct {
p string
info minio.ObjectInfo
isDir bool
}
func (m *minioFileInfo) Name() string {
return m.p
}
func (m *minioFileInfo) Size() int64 {
return m.info.Size
}
func (m *minioFileInfo) Mode() os.FileMode {
if m.isDir {
return os.ModeDir
}
return os.ModePerm
}
func (m *minioFileInfo) ModTime() time.Time {
return m.info.LastModified
}
func (m *minioFileInfo) IsDir() bool {
return m.isDir
}
func (m *minioFileInfo) Sys() interface{} {
return nil
}
//msgp:ignore ftpMetrics
type ftpMetrics struct{}
var globalFtpMetrics ftpMetrics
func ftpTrace(s *ftp.Context, startTime time.Time, source, path string, err error) madmin.TraceInfo {
var errStr string
if err != nil {
errStr = err.Error()
}
return madmin.TraceInfo{
TraceType: madmin.TraceFTP,
Time: startTime,
NodeName: globalLocalNodeName,
FuncName: fmt.Sprintf("ftp USER=%s COMMAND=%s PARAM=%s ISLOGIN=%t, Source=%s", s.Sess.LoginUser(), s.Cmd, s.Param, s.Sess.IsLogin(), source),
Duration: time.Since(startTime),
Path: path,
Error: errStr,
}
}
func (m *ftpMetrics) log(s *ftp.Context, paths ...string) func(err error) {
startTime := time.Now()
source := getSource(2)
return func(err error) {
globalTrace.Publish(ftpTrace(s, startTime, source, strings.Join(paths, " "), err))
}
}
// Stat implements ftpDriver
func (driver *ftpDriver) Stat(ctx *ftp.Context, path string) (fi os.FileInfo, err error) {
stopFn := globalFtpMetrics.log(ctx, path)
defer stopFn(err)
if path == SlashSeparator {
return &minioFileInfo{
p: SlashSeparator,
isDir: true,
}, nil
}
bucket, object := path2BucketObject(path)
if bucket == "" {
return nil, errors.New("bucket name cannot be empty")
}
clnt, err := driver.getMinIOClient(ctx)
if err != nil {
return nil, err
}
if object == "" {
ok, err := clnt.BucketExists(context.Background(), bucket)
if err != nil {
return nil, err
}
if !ok {
return nil, os.ErrNotExist
}
return &minioFileInfo{
p: pathClean(bucket),
info: minio.ObjectInfo{Key: bucket},
isDir: true,
}, nil
}
objInfo, err := clnt.StatObject(context.Background(), bucket, object, minio.StatObjectOptions{})
if err != nil {
if minio.ToErrorResponse(err).Code == "NoSuchKey" {
// dummy return to satisfy LIST (stat -> list) behavior.
return &minioFileInfo{
p: pathClean(object),
info: minio.ObjectInfo{Key: object},
isDir: true,
}, nil
}
return nil, err
}
isDir := strings.HasSuffix(objInfo.Key, SlashSeparator)
return &minioFileInfo{
p: pathClean(object),
info: objInfo,
isDir: isDir,
}, nil
}
// ListDir implements ftpDriver
func (driver *ftpDriver) ListDir(ctx *ftp.Context, path string, callback func(os.FileInfo) error) (err error) {
stopFn := globalFtpMetrics.log(ctx, path)
defer stopFn(err)
clnt, err := driver.getMinIOClient(ctx)
if err != nil {
return err
}
cctx, cancel := context.WithCancel(context.Background())
defer cancel()
bucket, prefix := path2BucketObject(path)
if bucket == "" {
buckets, err := clnt.ListBuckets(cctx)
if err != nil {
return err
}
for _, bucket := range buckets {
info := minioFileInfo{
p: pathClean(bucket.Name),
info: minio.ObjectInfo{Key: retainSlash(bucket.Name), LastModified: bucket.CreationDate},
isDir: true,
}
if err := callback(&info); err != nil {
return err
}
}
return nil
}
prefix = retainSlash(prefix)
for object := range clnt.ListObjects(cctx, bucket, minio.ListObjectsOptions{
Prefix: prefix,
Recursive: false,
}) {
if object.Err != nil {
return object.Err
}
if object.Key == prefix {
continue
}
isDir := strings.HasSuffix(object.Key, SlashSeparator)
info := minioFileInfo{
p: pathClean(strings.TrimPrefix(object.Key, prefix)),
info: object,
isDir: isDir,
}
if err := callback(&info); err != nil {
return err
}
}
return nil
}
func (driver *ftpDriver) CheckPasswd(c *ftp.Context, username, password string) (ok bool, err error) {
stopFn := globalFtpMetrics.log(c, username)
defer stopFn(err)
if globalIAMSys.LDAPConfig.Enabled() {
ldapUserDN, groupDistNames, err := globalIAMSys.LDAPConfig.Bind(username, password)
if err != nil {
return false, err
}
ldapPolicies, _ := globalIAMSys.PolicyDBGet(ldapUserDN, false, groupDistNames...)
if len(ldapPolicies) == 0 {
// no policy associated reject it.
return false, nil
}
return true, nil
}
ui, ok := globalIAMSys.GetUser(context.Background(), username)
if !ok {
return false, nil
}
return subtle.ConstantTimeCompare([]byte(ui.Credentials.SecretKey), []byte(password)) == 1, nil
}
func (driver *ftpDriver) getMinIOClient(ctx *ftp.Context) (*minio.Client, error) {
ui, ok := globalIAMSys.GetUser(context.Background(), ctx.Sess.LoginUser())
if !ok && !globalIAMSys.LDAPConfig.Enabled() {
return nil, errNoSuchUser
}
if !ok && globalIAMSys.LDAPConfig.Enabled() {
targetUser, targetGroups, err := globalIAMSys.LDAPConfig.LookupUserDN(ctx.Sess.LoginUser())
if err != nil {
return nil, err
}
ldapPolicies, _ := globalIAMSys.PolicyDBGet(targetUser, false, targetGroups...)
if len(ldapPolicies) == 0 {
return nil, errAuthentication
}
expiryDur, err := globalIAMSys.LDAPConfig.GetExpiryDuration("")
if err != nil {
return nil, err
}
claims := make(map[string]interface{})
claims[expClaim] = UTCNow().Add(expiryDur).Unix()
claims[ldapUser] = targetUser
claims[ldapUserN] = ctx.Sess.LoginUser()
cred, err := auth.GetNewCredentialsWithMetadata(claims, globalActiveCred.SecretKey)
if err != nil {
return nil, err
}
// Set the parent of the temporary access key, this is useful
// in obtaining service accounts by this cred.
cred.ParentUser = targetUser
// Set this value to LDAP groups, LDAP user can be part
// of large number of groups
cred.Groups = targetGroups
// Set the newly generated credentials, policyName is empty on purpose
// LDAP policies are applied automatically using their ldapUser, ldapGroups
// mapping.
updatedAt, err := globalIAMSys.SetTempUser(context.Background(), cred.AccessKey, cred, "")
if err != nil {
return nil, err
}
// Call hook for site replication.
logger.LogIf(context.Background(), globalSiteReplicationSys.IAMChangeHook(context.Background(), madmin.SRIAMItem{
Type: madmin.SRIAMItemSTSAcc,
STSCredential: &madmin.SRSTSCredential{
AccessKey: cred.AccessKey,
SecretKey: cred.SecretKey,
SessionToken: cred.SessionToken,
ParentUser: cred.ParentUser,
},
UpdatedAt: updatedAt,
}))
return minio.New(driver.endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cred.AccessKey, cred.SecretKey, cred.SessionToken),
Secure: globalIsTLS,
Transport: globalRemoteTargetTransport,
})
}
// ok == true - at this point
if ui.Credentials.IsTemp() {
// Temporary credentials are not allowed.
return nil, errAuthentication
}
return minio.New(driver.endpoint, &minio.Options{
Creds: credentials.NewStaticV4(ui.Credentials.AccessKey, ui.Credentials.SecretKey, ""),
Secure: globalIsTLS,
Transport: globalRemoteTargetTransport,
})
}
// DeleteDir implements ftpDriver
func (driver *ftpDriver) DeleteDir(ctx *ftp.Context, path string) (err error) {
stopFn := globalFtpMetrics.log(ctx, path)
defer stopFn(err)
bucket, prefix := path2BucketObject(path)
if bucket == "" {
return errors.New("deleting all buckets not allowed")
}
clnt, err := driver.getMinIOClient(ctx)
if err != nil {
return err
}
cctx, cancel := context.WithCancel(context.Background())
defer cancel()
objectsCh := make(chan minio.ObjectInfo)
// Send object names that are needed to be removed to objectsCh
go func() {
defer close(objectsCh)
opts := minio.ListObjectsOptions{Prefix: prefix, Recursive: true}
for object := range clnt.ListObjects(cctx, bucket, opts) {
if object.Err != nil {
return
}
objectsCh <- object
}
}()
// Call RemoveObjects API
for err := range clnt.RemoveObjects(context.Background(), bucket, objectsCh, minio.RemoveObjectsOptions{}) {
if err.Err != nil {
return err.Err
}
}
return nil
}
// DeleteFile implements ftpDriver
func (driver *ftpDriver) DeleteFile(ctx *ftp.Context, path string) (err error) {
stopFn := globalFtpMetrics.log(ctx, path)
defer stopFn(err)
bucket, object := path2BucketObject(path)
if bucket == "" {
return errors.New("bucket name cannot be empty")
}
clnt, err := driver.getMinIOClient(ctx)
if err != nil {
return err
}
return clnt.RemoveObject(context.Background(), bucket, object, minio.RemoveObjectOptions{})
}
// Rename implements ftpDriver
func (driver *ftpDriver) Rename(ctx *ftp.Context, fromPath string, toPath string) (err error) {
stopFn := globalFtpMetrics.log(ctx, fromPath, toPath)
defer stopFn(err)
return NotImplemented{}
}
// MakeDir implements ftpDriver
func (driver *ftpDriver) MakeDir(ctx *ftp.Context, path string) (err error) {
stopFn := globalFtpMetrics.log(ctx, path)
defer stopFn(err)
bucket, prefix := path2BucketObject(path)
if bucket == "" {
return errors.New("bucket name cannot be empty")
}
clnt, err := driver.getMinIOClient(ctx)
if err != nil {
return err
}
dirPath := buildMinioDir(prefix)
_, err = clnt.PutObject(context.Background(), bucket, dirPath, bytes.NewReader([]byte("")), 0,
// Always send Content-MD5 to succeed with bucket with
// locking enabled. There is no performance hit since
// this is always an empty object
minio.PutObjectOptions{SendContentMd5: true},
)
return err
}
// GetFile implements ftpDriver
func (driver *ftpDriver) GetFile(ctx *ftp.Context, path string, offset int64) (n int64, rc io.ReadCloser, err error) {
stopFn := globalFtpMetrics.log(ctx, path)
defer stopFn(err)
bucket, object := path2BucketObject(path)
if bucket == "" {
return 0, nil, errors.New("bucket name cannot be empty")
}
clnt, err := driver.getMinIOClient(ctx)
if err != nil {
return 0, nil, err
}
opts := minio.GetObjectOptions{}
obj, err := clnt.GetObject(context.Background(), bucket, object, opts)
if err != nil {
return 0, nil, err
}
defer func() {
if err != nil && obj != nil {
obj.Close()
}
}()
_, err = obj.Seek(offset, io.SeekStart)
if err != nil {
return 0, nil, err
}
info, err := obj.Stat()
if err != nil {
return 0, nil, err
}
return info.Size - offset, obj, nil
}
// PutFile implements ftpDriver
func (driver *ftpDriver) PutFile(ctx *ftp.Context, path string, data io.Reader, offset int64) (n int64, err error) {
stopFn := globalFtpMetrics.log(ctx, path)
defer stopFn(err)
bucket, object := path2BucketObject(path)
if bucket == "" {
return 0, errors.New("bucket name cannot be empty")
}
if offset != -1 {
// FTP - APPEND not implemented
return 0, NotImplemented{}
}
clnt, err := driver.getMinIOClient(ctx)
if err != nil {
return 0, err
}
info, err := clnt.PutObject(context.Background(), bucket, object, data, -1, minio.PutObjectOptions{
ContentType: "application/octet-stream",
SendContentMd5: true,
})
return info.Size, err
}

300
cmd/ftp-server.go Normal file
View File

@@ -0,0 +1,300 @@
// Copyright (c) 2015-2023 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package cmd
import (
"context"
"crypto/subtle"
"fmt"
"io"
"net"
"strconv"
"strings"
"github.com/minio/cli"
"github.com/minio/minio/internal/ioutil"
"github.com/minio/minio/internal/logger"
"github.com/pkg/sftp"
ftp "goftp.io/server/v2"
"golang.org/x/crypto/ssh"
)
// minioLogger use an instance of this to log in a standard format
type minioLogger struct{}
// Print implement Logger
func (log *minioLogger) Print(sessionID string, message interface{}) {
if serverDebugLog {
logger.Info("%s %s", sessionID, message)
}
}
// Printf implement Logger
func (log *minioLogger) Printf(sessionID string, format string, v ...interface{}) {
if serverDebugLog {
if sessionID != "" {
logger.Info("%s %s", sessionID, fmt.Sprintf(format, v...))
} else {
logger.Info(format, v...)
}
}
}
// PrintCommand impelment Logger
func (log *minioLogger) PrintCommand(sessionID string, command string, params string) {
if serverDebugLog {
if command == "PASS" {
logger.Info("%s > PASS ****", sessionID)
} else {
logger.Info("%s > %s %s", sessionID, command, params)
}
}
}
// PrintResponse impelment Logger
func (log *minioLogger) PrintResponse(sessionID string, code int, message string) {
if serverDebugLog {
logger.Info("%s < %d %s", sessionID, code, message)
}
}
func startSFTPServer(c *cli.Context) {
args := c.StringSlice("sftp")
var (
port int
publicIP string
sshPrivateKey string
)
var err error
for _, arg := range args {
tokens := strings.SplitN(arg, "=", 2)
if len(tokens) != 2 {
logger.Fatal(fmt.Errorf("invalid arguments passed to --sftp=%s", arg), "unable to start SFTP server")
}
switch tokens[0] {
case "address":
host, portStr, err := net.SplitHostPort(tokens[1])
if err != nil {
logger.Fatal(fmt.Errorf("invalid arguments passed to --sftp=%s (%v)", arg, err), "unable to start SFTP server")
}
port, err = strconv.Atoi(portStr)
if err != nil {
logger.Fatal(fmt.Errorf("invalid arguments passed to --sftp=%s (%v)", arg, err), "unable to start SFTP server")
}
if port < 1 || port > 65535 {
logger.Fatal(fmt.Errorf("invalid arguments passed to --sftp=%s, (port number must be between 1 to 65535)", arg), "unable to start SFTP server")
}
publicIP = host
case "ssh-private-key":
sshPrivateKey = tokens[1]
}
}
if port == 0 {
port = 8022 // Default SFTP port, since no port was given.
}
if sshPrivateKey == "" {
logger.Fatal(fmt.Errorf("invalid arguments passed, private key file is mandatory for --sftp='ssh-private-key=path/to/id_ecdsa'"), "unable to start SFTP server")
}
privateBytes, err := ioutil.ReadFile(sshPrivateKey)
if err != nil {
logger.Fatal(fmt.Errorf("invalid arguments passed, private key file is not accessible: %v", err), "unable to start SFTP server")
}
private, err := ssh.ParsePrivateKey(privateBytes)
if err != nil {
logger.Fatal(fmt.Errorf("invalid arguments passed, private key file is not parseable: %v", err), "unable to start SFTP server")
}
// An SSH server is represented by a ServerConfig, which holds
// certificate details and handles authentication of ServerConns.
config := &ssh.ServerConfig{
PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
ui, ok := globalIAMSys.GetUser(context.Background(), c.User())
if !ok {
return nil, errNoSuchUser
}
if subtle.ConstantTimeCompare([]byte(ui.Credentials.SecretKey), pass) == 1 {
return &ssh.Permissions{
CriticalOptions: map[string]string{
"accessKey": c.User(),
},
Extensions: make(map[string]string),
}, nil
}
return nil, errAuthentication
},
}
config.AddHostKey(private)
// Once a ServerConfig has been configured, connections can be accepted.
listener, err := net.Listen("tcp", net.JoinHostPort(publicIP, strconv.Itoa(port)))
if err != nil {
logger.Fatal(err, "unable to start listening on --sftp='port=%d'", port)
}
logger.Info(fmt.Sprintf("MinIO SFTP Server listening on %s", net.JoinHostPort(publicIP, strconv.Itoa(port))))
for {
nConn, err := listener.Accept()
if err != nil {
logger.LogIf(context.Background(), err)
continue
}
// Before use, a handshake must be performed on the incoming net.Conn.
sconn, chans, reqs, err := ssh.NewServerConn(nConn, config)
if err != nil {
logger.LogIf(context.Background(), err)
continue
}
// The incoming Request channel must be serviced.
go ssh.DiscardRequests(reqs)
// Service the incoming Channel channel.
for newChannel := range chans {
// Channels have a type, depending on the application level
// protocol intended. In the case of an SFTP session, this is "subsystem"
// with a payload string of "<length=4>sftp"
if newChannel.ChannelType() != "session" {
newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
continue
}
channel, requests, err := newChannel.Accept()
if err != nil {
logger.Fatal(err, "unable to accept the connection requests channel")
}
// Sessions have out-of-band requests such as "shell",
// "pty-req" and "env". Here we handle only the
// "subsystem" request.
go func(in <-chan *ssh.Request) {
for req := range in {
// We only reply to SSH packets that have `sftp` payload.
req.Reply(req.Type == "subsystem" && string(req.Payload[4:]) == "sftp", nil)
}
}(requests)
server := sftp.NewRequestServer(channel, NewSFTPDriver(sconn.Permissions))
if err := server.Serve(); err == io.EOF {
server.Close()
} else if err != nil {
logger.Fatal(err, "unable to start SFTP server")
}
}
}
}
func startFTPServer(c *cli.Context) {
args := c.StringSlice("ftp")
var (
port int
publicIP string
portRange string
tlsPrivateKey string
tlsPublicCert string
)
var err error
for _, arg := range args {
tokens := strings.SplitN(arg, "=", 2)
if len(tokens) != 2 {
logger.Fatal(fmt.Errorf("invalid arguments passed to --ftp=%s", arg), "unable to start FTP server")
}
switch tokens[0] {
case "address":
host, portStr, err := net.SplitHostPort(tokens[1])
if err != nil {
logger.Fatal(fmt.Errorf("invalid arguments passed to --ftp=%s (%v)", arg, err), "unable to start FTP server")
}
port, err = strconv.Atoi(portStr)
if err != nil {
logger.Fatal(fmt.Errorf("invalid arguments passed to --ftp=%s (%v)", arg, err), "unable to start FTP server")
}
if port < 1 || port > 65535 {
logger.Fatal(fmt.Errorf("invalid arguments passed to --ftp=%s, (port number must be between 1 to 65535)", arg), "unable to start FTP server")
}
publicIP = host
case "passive-port-range":
portRange = tokens[1]
case "tls-private-key":
tlsPrivateKey = tokens[1]
case "tls-public-cert":
tlsPublicCert = tokens[1]
}
}
// Verify if only partial inputs are given for FTP(secure)
{
if tlsPrivateKey == "" && tlsPublicCert != "" {
logger.Fatal(fmt.Errorf("invalid TLS arguments provided missing private key --ftp=\"tls-private-key=path/to/private.key\""), "unable to start FTP server")
}
if tlsPrivateKey != "" && tlsPublicCert == "" {
logger.Fatal(fmt.Errorf("invalid TLS arguments provided missing public cert --ftp=\"tls-public-cert=path/to/public.crt\""), "unable to start FTP server")
}
if port == 0 {
port = 8021 // Default FTP port, since no port was given.
}
}
// If no TLS certs were provided, server is running in TLS for S3 API
// we automatically make FTP also run under TLS mode.
if globalIsTLS && tlsPrivateKey == "" && tlsPublicCert == "" {
tlsPrivateKey = getPrivateKeyFile()
tlsPublicCert = getPublicCertFile()
}
tls := tlsPrivateKey != "" && tlsPublicCert != ""
name := "MinIO FTP Server"
if tls {
name = "MinIO FTP(Secure) Server"
}
ftpServer, err := ftp.NewServer(&ftp.Options{
Name: name,
WelcomeMessage: fmt.Sprintf("Welcome to MinIO FTP Server Version='%s' License='GNU AGPLv3'", Version),
Driver: NewFTPDriver(),
Port: port,
Perm: ftp.NewSimplePerm("nobody", "nobody"),
TLS: tls,
KeyFile: tlsPrivateKey,
CertFile: tlsPublicCert,
ExplicitFTPS: tls,
Logger: &minioLogger{},
PassivePorts: portRange,
PublicIP: publicIP,
})
if err != nil {
logger.Fatal(err, "unable to initialize FTP server")
}
logger.Info(fmt.Sprintf("%s listening on %s", name, net.JoinHostPort(publicIP, strconv.Itoa(port))))
if err = ftpServer.ListenAndServe(); err != nil {
logger.Fatal(err, "unable to start FTP server")
}
}

View File

@@ -1451,6 +1451,11 @@ func (sys *IAMSys) GetUser(ctx context.Context, accessKey string) (u UserIdentit
u, ok = sys.store.GetUser(accessKey)
}
if !ok {
if accessKey == globalActiveCred.AccessKey {
return newUserIdentity(globalActiveCred), true
}
}
return u, ok && u.Credentials.IsValid()
}

View File

@@ -105,6 +105,14 @@ var ServerFlags = []cli.Flag{
Value: 10 * time.Minute,
EnvVar: "MINIO_CONN_WRITE_DEADLINE",
},
cli.StringSliceFlag{
Name: "ftp",
Usage: "enable and configure an FTP(Secure) server",
},
cli.StringSliceFlag{
Name: "sftp",
Usage: "enable and configure an SFTP server",
},
}
var gatewayCmd = cli.Command{
@@ -145,22 +153,23 @@ FLAGS:
{{range .VisibleFlags}}{{.}}
{{end}}{{end}}
EXAMPLES:
1. Start minio server on "/home/shared" directory.
1. Start MinIO server on "/home/shared" directory.
{{.Prompt}} {{.HelpName}} /home/shared
2. Start single node server with 64 local drives "/mnt/data1" to "/mnt/data64".
{{.Prompt}} {{.HelpName}} /mnt/data{1...64}
3. Start distributed minio server on an 32 node setup with 32 drives each, run following command on all the nodes
{{.Prompt}} {{.EnvVarSetCommand}} MINIO_ROOT_USER{{.AssignmentOperator}}minio
{{.Prompt}} {{.EnvVarSetCommand}} MINIO_ROOT_PASSWORD{{.AssignmentOperator}}miniostorage
3. Start distributed MinIO server on an 32 node setup with 32 drives each, run following command on all the nodes
{{.Prompt}} {{.HelpName}} http://node{1...32}.example.com/mnt/export{1...32}
4. Start distributed minio server in an expanded setup, run the following command on all the nodes
{{.Prompt}} {{.EnvVarSetCommand}} MINIO_ROOT_USER{{.AssignmentOperator}}minio
{{.Prompt}} {{.EnvVarSetCommand}} MINIO_ROOT_PASSWORD{{.AssignmentOperator}}miniostorage
4. Start distributed MinIO server in an expanded setup, run the following command on all the nodes
{{.Prompt}} {{.HelpName}} http://node{1...16}.example.com/mnt/export{1...32} \
http://node{17...64}.example.com/mnt/export{1...64}
5. Start distributed MinIO server, with FTP and SFTP servers on all interfaces via port 8021, 8022 respectively
{{.Prompt}} {{.HelpName}} http://node{1...4}.example.com/mnt/export{1...4} \
--ftp="address=:8021" --ftp="passive-port-range=30000-40000" \
--sftp="address=:8022" --sftp="ssh-private-key=${HOME}/.ssh/id_rsa"
`,
}
@@ -667,6 +676,16 @@ func serverMain(ctx *cli.Context) {
logger.FatalIf(newConsoleServerFn().Serve(), "Unable to initialize console server")
}()
}
// if we see FTP args, start FTP if possible
if len(ctx.StringSlice("ftp")) > 0 {
go startFTPServer(ctx)
}
// If we see SFTP args, start SFTP if possible
if len(ctx.StringSlice("sftp")) > 0 {
go startSFTPServer(ctx)
}
}()
// Background all other operations such as initializing bucket metadata etc.

View File

@@ -127,16 +127,14 @@ func printServerCommonMsg(apiEndpoints []string) {
apiEndpointStr := strings.Join(apiEndpoints, " ")
// Colorize the message and print.
logger.Info(color.Blue("API: ") + color.Bold(fmt.Sprintf("%s ", apiEndpointStr)))
logger.Info(color.Blue("S3-API: ") + color.Bold(fmt.Sprintf("%s ", apiEndpointStr)))
if color.IsTerminal() && (!globalCLIContext.Anonymous && !globalCLIContext.JSON) {
logger.Info(color.Blue("RootUser: ") + color.Bold(fmt.Sprintf("%s ", cred.AccessKey)))
logger.Info(color.Blue("RootPass: ") + color.Bold(fmt.Sprintf("%s ", cred.SecretKey)))
logger.Info(color.Blue("RootPass: ") + color.Bold(fmt.Sprintf("%s \n", cred.SecretKey)))
if region != "" {
logger.Info(color.Blue("Region: ") + color.Bold(fmt.Sprintf(getFormatStr(len(region), 2), region)))
}
}
printEventNotifiers()
printLambdaTargets()
if globalBrowserEnabled {
consoleEndpointStr := strings.Join(stripStandardPorts(getConsoleEndpoints(), globalMinioConsoleHost), " ")
@@ -146,6 +144,9 @@ func printServerCommonMsg(apiEndpoints []string) {
logger.Info(color.Blue("RootPass: ") + color.Bold(fmt.Sprintf("%s ", cred.SecretKey)))
}
}
printEventNotifiers()
printLambdaTargets()
}
// Prints startup message for Object API access, prints link to our SDK documentation.

442
cmd/sftp-server-driver.go Normal file
View File

@@ -0,0 +1,442 @@
// Copyright (c) 2015-2023 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package cmd
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"sync"
"time"
"github.com/minio/madmin-go/v2"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/minio/minio/internal/auth"
"github.com/minio/minio/internal/logger"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
type sftpDriver struct {
permissions *ssh.Permissions
endpoint string
}
//msgp:ignore sftpMetrics
type sftpMetrics struct{}
var globalSftpMetrics sftpMetrics
func sftpTrace(s *sftp.Request, startTime time.Time, source string, user string, err error) madmin.TraceInfo {
var errStr string
if err != nil {
errStr = err.Error()
}
return madmin.TraceInfo{
TraceType: madmin.TraceFTP,
Time: startTime,
NodeName: globalLocalNodeName,
FuncName: fmt.Sprintf("sftp USER=%s COMMAND=%s PARAM=%s, Source=%s", user, s.Method, s.Filepath, source),
Duration: time.Since(startTime),
Path: s.Filepath,
Error: errStr,
}
}
func (m *sftpMetrics) log(s *sftp.Request, user string) func(err error) {
startTime := time.Now()
source := getSource(2)
return func(err error) {
globalTrace.Publish(sftpTrace(s, startTime, source, user, err))
}
}
// NewSFTPDriver initializes sftp.Handlers implementation of following interfaces
//
// - sftp.Fileread
// - sftp.Filewrite
// - sftp.Filelist
// - sftp.Filecmd
func NewSFTPDriver(perms *ssh.Permissions) sftp.Handlers {
handler := &sftpDriver{endpoint: fmt.Sprintf("127.0.0.1:%s", globalMinioPort), permissions: perms}
return sftp.Handlers{
FileGet: handler,
FilePut: handler,
FileCmd: handler,
FileList: handler,
}
}
func (f *sftpDriver) getMinIOClient() (*minio.Client, error) {
ui, ok := globalIAMSys.GetUser(context.Background(), f.AccessKey())
if !ok && !globalIAMSys.LDAPConfig.Enabled() {
return nil, errNoSuchUser
}
if !ok && globalIAMSys.LDAPConfig.Enabled() {
targetUser, targetGroups, err := globalIAMSys.LDAPConfig.LookupUserDN(f.AccessKey())
if err != nil {
return nil, err
}
ldapPolicies, _ := globalIAMSys.PolicyDBGet(targetUser, false, targetGroups...)
if len(ldapPolicies) == 0 {
return nil, errAuthentication
}
expiryDur, err := globalIAMSys.LDAPConfig.GetExpiryDuration("")
if err != nil {
return nil, err
}
claims := make(map[string]interface{})
claims[expClaim] = UTCNow().Add(expiryDur).Unix()
claims[ldapUser] = targetUser
claims[ldapUserN] = f.AccessKey()
cred, err := auth.GetNewCredentialsWithMetadata(claims, globalActiveCred.SecretKey)
if err != nil {
return nil, err
}
// Set the parent of the temporary access key, this is useful
// in obtaining service accounts by this cred.
cred.ParentUser = targetUser
// Set this value to LDAP groups, LDAP user can be part
// of large number of groups
cred.Groups = targetGroups
// Set the newly generated credentials, policyName is empty on purpose
// LDAP policies are applied automatically using their ldapUser, ldapGroups
// mapping.
updatedAt, err := globalIAMSys.SetTempUser(context.Background(), cred.AccessKey, cred, "")
if err != nil {
return nil, err
}
// Call hook for site replication.
logger.LogIf(context.Background(), globalSiteReplicationSys.IAMChangeHook(context.Background(), madmin.SRIAMItem{
Type: madmin.SRIAMItemSTSAcc,
STSCredential: &madmin.SRSTSCredential{
AccessKey: cred.AccessKey,
SecretKey: cred.SecretKey,
SessionToken: cred.SessionToken,
ParentUser: cred.ParentUser,
},
UpdatedAt: updatedAt,
}))
return minio.New(f.endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cred.AccessKey, cred.SecretKey, cred.SessionToken),
Secure: globalIsTLS,
Transport: globalRemoteTargetTransport,
})
}
// ok == true - at this point
if ui.Credentials.IsTemp() {
// Temporary credentials are not allowed.
return nil, errAuthentication
}
return minio.New(f.endpoint, &minio.Options{
Creds: credentials.NewStaticV4(ui.Credentials.AccessKey, ui.Credentials.SecretKey, ""),
Secure: globalIsTLS,
Transport: globalRemoteTargetTransport,
})
}
func (f *sftpDriver) AccessKey() string {
return f.permissions.CriticalOptions["accessKey"]
}
func (f *sftpDriver) Fileread(r *sftp.Request) (ra io.ReaderAt, err error) {
stopFn := globalSftpMetrics.log(r, f.AccessKey())
defer stopFn(err)
flags := r.Pflags()
if !flags.Read {
// sanity check
return nil, os.ErrInvalid
}
bucket, object := path2BucketObject(r.Filepath)
if bucket == "" {
return nil, errors.New("bucket name cannot be empty")
}
clnt, err := f.getMinIOClient()
if err != nil {
return nil, err
}
obj, err := clnt.GetObject(context.Background(), bucket, object, minio.GetObjectOptions{})
if err != nil {
return nil, err
}
_, err = obj.Stat()
if err != nil {
return nil, err
}
return obj, nil
}
type writerAt struct {
w *io.PipeWriter
wg *sync.WaitGroup
}
func (w *writerAt) Close() error {
err := w.w.Close()
w.wg.Wait()
return err
}
func (w *writerAt) WriteAt(b []byte, offset int64) (n int, err error) {
return w.w.Write(b)
}
func (f *sftpDriver) Filewrite(r *sftp.Request) (w io.WriterAt, err error) {
stopFn := globalSftpMetrics.log(r, f.AccessKey())
defer stopFn(err)
flags := r.Pflags()
if !flags.Write {
// sanity check
return nil, os.ErrInvalid
}
bucket, object := path2BucketObject(r.Filepath)
if bucket == "" {
return nil, errors.New("bucket name cannot be empty")
}
clnt, err := f.getMinIOClient()
if err != nil {
return nil, err
}
pr, pw := io.Pipe()
wa := &writerAt{w: pw, wg: &sync.WaitGroup{}}
wa.wg.Add(1)
go func() {
_, err := clnt.PutObject(r.Context(), bucket, object, pr, -1, minio.PutObjectOptions{SendContentMd5: true})
pr.CloseWithError(err)
wa.wg.Done()
}()
return wa, nil
}
func (f *sftpDriver) Filecmd(r *sftp.Request) (err error) {
stopFn := globalSftpMetrics.log(r, f.AccessKey())
defer stopFn(err)
clnt, err := f.getMinIOClient()
if err != nil {
return err
}
switch r.Method {
case "Setstat", "Rename", "Link", "Symlink":
return NotImplemented{}
case "Rmdir":
bucket, prefix := path2BucketObject(r.Filepath)
if bucket == "" {
return errors.New("deleting all buckets not allowed")
}
cctx, cancel := context.WithCancel(context.Background())
defer cancel()
objectsCh := make(chan minio.ObjectInfo)
// Send object names that are needed to be removed to objectsCh
go func() {
defer close(objectsCh)
opts := minio.ListObjectsOptions{Prefix: prefix, Recursive: true}
for object := range clnt.ListObjects(cctx, bucket, opts) {
if object.Err != nil {
return
}
objectsCh <- object
}
}()
// Call RemoveObjects API
for err := range clnt.RemoveObjects(context.Background(), bucket, objectsCh, minio.RemoveObjectsOptions{}) {
if err.Err != nil {
return err.Err
}
}
case "Remove":
bucket, object := path2BucketObject(r.Filepath)
if bucket == "" {
return errors.New("bucket name cannot be empty")
}
return clnt.RemoveObject(context.Background(), bucket, object, minio.RemoveObjectOptions{})
case "Mkdir":
bucket, prefix := path2BucketObject(r.Filepath)
if bucket == "" {
return errors.New("bucket name cannot be empty")
}
dirPath := buildMinioDir(prefix)
_, err = clnt.PutObject(context.Background(), bucket, dirPath, bytes.NewReader([]byte("")), 0,
// Always send Content-MD5 to succeed with bucket with
// locking enabled. There is no performance hit since
// this is always an empty object
minio.PutObjectOptions{SendContentMd5: true},
)
return err
}
return NotImplemented{}
}
type listerAt []os.FileInfo
// Modeled after strings.Reader's ReadAt() implementation
func (f listerAt) ListAt(ls []os.FileInfo, offset int64) (int, error) {
var n int
if offset >= int64(len(f)) {
return 0, io.EOF
}
n = copy(ls, f[offset:])
if n < len(ls) {
return n, io.EOF
}
return n, nil
}
func (f *sftpDriver) Filelist(r *sftp.Request) (la sftp.ListerAt, err error) {
stopFn := globalSftpMetrics.log(r, f.AccessKey())
defer stopFn(err)
clnt, err := f.getMinIOClient()
if err != nil {
return nil, err
}
switch r.Method {
case "List":
var files []os.FileInfo
bucket, prefix := path2BucketObject(r.Filepath)
if bucket == "" {
buckets, err := clnt.ListBuckets(r.Context())
if err != nil {
return nil, err
}
for _, bucket := range buckets {
files = append(files, &minioFileInfo{
p: bucket.Name,
info: minio.ObjectInfo{Key: bucket.Name, LastModified: bucket.CreationDate},
isDir: true,
})
}
return listerAt(files), nil
}
prefix = retainSlash(prefix)
for object := range clnt.ListObjects(r.Context(), bucket, minio.ListObjectsOptions{
Prefix: prefix,
Recursive: false,
}) {
if object.Err != nil {
return nil, object.Err
}
if object.Key == prefix {
continue
}
isDir := strings.HasSuffix(object.Key, SlashSeparator)
files = append(files, &minioFileInfo{
p: pathClean(strings.TrimPrefix(object.Key, prefix)),
info: object,
isDir: isDir,
})
}
return listerAt(files), nil
case "Stat":
if r.Filepath == SlashSeparator {
return listerAt{&minioFileInfo{
p: r.Filepath,
isDir: true,
}}, nil
}
bucket, object := path2BucketObject(r.Filepath)
if bucket == "" {
return nil, errors.New("bucket name cannot be empty")
}
if object == "" {
ok, err := clnt.BucketExists(context.Background(), bucket)
if err != nil {
return nil, err
}
if !ok {
return nil, os.ErrNotExist
}
return listerAt{&minioFileInfo{
p: pathClean(bucket),
info: minio.ObjectInfo{Key: bucket},
isDir: true,
}}, nil
}
objInfo, err := clnt.StatObject(context.Background(), bucket, object, minio.StatObjectOptions{})
if err != nil {
if minio.ToErrorResponse(err).Code == "NoSuchKey" {
// dummy return to satisfy LIST (stat -> list) behavior.
return listerAt{&minioFileInfo{
p: pathClean(object),
info: minio.ObjectInfo{Key: object},
isDir: true,
}}, nil
}
return nil, err
}
isDir := strings.HasSuffix(objInfo.Key, SlashSeparator)
return listerAt{&minioFileInfo{
p: pathClean(object),
info: objInfo,
isDir: isDir,
}}, nil
}
return nil, NotImplemented{}
}