mirror of
https://github.com/mickael-kerjean/filestash.git
synced 2025-10-29 00:55:51 +08:00
Azure SFTP only hold the connection for 2 minutes .... So we got to flush our cache before it occurs, otherwise we end up with a "Connection lost" error
434 lines
9.8 KiB
Go
434 lines
9.8 KiB
Go
package plg_backend_sftp
|
|
|
|
import (
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
|
|
. "github.com/mickael-kerjean/filestash/server/common"
|
|
"github.com/pkg/sftp"
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
var SftpCache AppCache
|
|
|
|
type Sftp struct {
|
|
SSHClient *ssh.Client
|
|
SFTPClient *sftp.Client
|
|
wg *sync.WaitGroup
|
|
}
|
|
|
|
func init() {
|
|
Backend.Register("sftp", Sftp{})
|
|
|
|
SftpCache = NewAppCache(1, 1)
|
|
SftpCache.OnEvict(func(key string, value interface{}) {
|
|
c := value.(*Sftp)
|
|
if c == nil {
|
|
Log.Warning("plg_backend_sftp::sftp is nil on close")
|
|
return
|
|
} else if c.wg == nil {
|
|
c.Close()
|
|
Log.Warning("plg_backend_sftp::wg is nil on close")
|
|
return
|
|
}
|
|
c.wg.Wait()
|
|
Log.Debug("plg_backend_sftp::vacuum")
|
|
c.Close()
|
|
})
|
|
}
|
|
|
|
func (s Sftp) Init(params map[string]string, app *App) (IBackend, error) {
|
|
p := struct {
|
|
hostname string
|
|
port string
|
|
username string
|
|
password string
|
|
passphrase string
|
|
}{
|
|
params["hostname"],
|
|
params["port"],
|
|
params["username"],
|
|
params["password"],
|
|
params["passphrase"],
|
|
}
|
|
if p.port == "" {
|
|
p.port = "22"
|
|
}
|
|
|
|
c := SftpCache.Get(params)
|
|
if c != nil {
|
|
d := c.(*Sftp)
|
|
if d == nil {
|
|
Log.Warning("plg_backend_sftp::sftp is nil on get")
|
|
return nil, ErrInternal
|
|
} else if d.wg == nil {
|
|
Log.Warning("plg_backend_sftp::wg is nil on get")
|
|
return nil, ErrInternal
|
|
}
|
|
d.wg.Add(1)
|
|
go func() {
|
|
<-app.Context.Done()
|
|
d.wg.Done()
|
|
}()
|
|
return d, nil
|
|
}
|
|
|
|
addr := p.hostname + ":" + p.port
|
|
|
|
keyStartMatcher := regexp.MustCompile(`^-----BEGIN [A-Z\ ]+-----`)
|
|
keyEndMatcher := regexp.MustCompile(`-----END [A-Z\ ]+-----$`)
|
|
keyContentMatcher := regexp.MustCompile(`^[a-zA-Z0-9\+\/\=\n]+$`)
|
|
isPrivateKey := func(pass string) bool {
|
|
p := strings.TrimSpace(pass)
|
|
|
|
// match private key beginning
|
|
if keyStartMatcher.FindStringIndex(p) == nil {
|
|
return false
|
|
}
|
|
p = keyStartMatcher.ReplaceAllString(p, "")
|
|
// match private key ending
|
|
if keyEndMatcher.FindStringIndex(p) == nil {
|
|
return false
|
|
}
|
|
p = keyEndMatcher.ReplaceAllString(p, "")
|
|
p = strings.Replace(p, " ", "", -1)
|
|
// match private key content
|
|
if keyContentMatcher.FindStringIndex(p) == nil {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
restorePrivateKeyLineBreaks := func(pass string) string {
|
|
p := strings.TrimSpace(pass)
|
|
|
|
keyStartString := keyStartMatcher.FindString(p)
|
|
p = keyStartMatcher.ReplaceAllString(p, "")
|
|
keyEndString := keyEndMatcher.FindString(p)
|
|
p = keyEndMatcher.ReplaceAllString(p, "")
|
|
p = strings.Replace(p, " ", "", -1)
|
|
keyContentString := keyContentMatcher.FindString(p)
|
|
|
|
return keyStartString + "\n" + keyContentString + "\n" + keyEndString
|
|
}
|
|
|
|
/*
|
|
* SSH has a range of authentication methods available: publickey, password,
|
|
* keyboard-interactive, password-callback, publickey-callback, gss, .... Typical sftp
|
|
* servers only have those 2: 'publickey' and 'password' but some exotic ones we've seen
|
|
* have 'publickey' and 'keyboard-interactive'. If you're unlucky enough to have to work
|
|
* with something else than those 3 we provide support for, either create a PR here
|
|
* or contact us
|
|
*/
|
|
var auth []ssh.AuthMethod
|
|
if isPrivateKey(p.password) {
|
|
privateKey := restorePrivateKeyLineBreaks(p.password)
|
|
signer, err := func() (ssh.Signer, error) {
|
|
if p.passphrase == "" {
|
|
return ssh.ParsePrivateKey([]byte(privateKey))
|
|
}
|
|
return ssh.ParsePrivateKeyWithPassphrase([]byte(privateKey), []byte(p.passphrase))
|
|
}()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
|
|
} else {
|
|
auth = []ssh.AuthMethod{
|
|
ssh.Password(p.password),
|
|
ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) {
|
|
answers := make([]string, len(questions))
|
|
for i, _ := range answers {
|
|
answers[i] = p.password
|
|
}
|
|
return answers, nil
|
|
}),
|
|
}
|
|
}
|
|
|
|
config := &ssh.ClientConfig{
|
|
User: p.username,
|
|
Auth: auth,
|
|
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
|
if params["hostkey"] == "" {
|
|
return nil
|
|
}
|
|
fsha := ssh.FingerprintSHA256(key)
|
|
if fsha == params["hostkey"] {
|
|
return nil
|
|
}
|
|
fmd := ssh.FingerprintLegacyMD5(key)
|
|
if fmd == params["hostkey"] {
|
|
return nil
|
|
}
|
|
Log.Debug("plg_backend_sftp::fingerprint host key isn't correct on %s => '%s'", hostname, fsha)
|
|
return ErrNotValid
|
|
},
|
|
}
|
|
|
|
client, err := ssh.Dial("tcp", addr, config)
|
|
if err != nil {
|
|
config.User = strings.ToLower(p.username)
|
|
client, err = ssh.Dial("tcp", addr, config)
|
|
if err != nil {
|
|
return &s, ErrAuthenticationFailed
|
|
}
|
|
}
|
|
s.SSHClient = client
|
|
|
|
session, err := sftp.NewClient(s.SSHClient)
|
|
if err != nil {
|
|
return &s, err
|
|
}
|
|
s.SFTPClient = session
|
|
s.wg = new(sync.WaitGroup)
|
|
s.wg.Add(1)
|
|
go func() {
|
|
<-app.Context.Done()
|
|
s.wg.Done()
|
|
}()
|
|
SftpCache.Set(params, &s)
|
|
return &s, nil
|
|
}
|
|
|
|
func (b Sftp) LoginForm() Form {
|
|
return Form{
|
|
Elmnts: []FormElement{
|
|
FormElement{
|
|
Name: "type",
|
|
Type: "hidden",
|
|
Value: "sftp",
|
|
},
|
|
FormElement{
|
|
Name: "hostname",
|
|
Type: "text",
|
|
Placeholder: "Hostname*",
|
|
},
|
|
FormElement{
|
|
Name: "username",
|
|
Type: "text",
|
|
Placeholder: "Username",
|
|
},
|
|
FormElement{
|
|
Name: "password",
|
|
Type: "password",
|
|
Placeholder: "Password",
|
|
},
|
|
FormElement{
|
|
Name: "advanced",
|
|
Type: "enable",
|
|
Placeholder: "Advanced",
|
|
Target: []string{"sftp_path", "sftp_port", "sftp_passphrase", "sftp_hostkey"},
|
|
},
|
|
FormElement{
|
|
Id: "sftp_path",
|
|
Name: "path",
|
|
Type: "text",
|
|
Placeholder: "Path",
|
|
},
|
|
FormElement{
|
|
Id: "sftp_port",
|
|
Name: "port",
|
|
Type: "number",
|
|
Placeholder: "Port",
|
|
},
|
|
FormElement{
|
|
Id: "sftp_passphrase",
|
|
Name: "passphrase",
|
|
Type: "password",
|
|
Placeholder: "Passphrase",
|
|
},
|
|
FormElement{
|
|
Id: "sftp_hostkey",
|
|
Name: "hostkey",
|
|
Type: "text",
|
|
Placeholder: "Host key",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (b Sftp) Home() (string, error) {
|
|
cwd, err := b.SFTPClient.Getwd()
|
|
if err != nil {
|
|
return "", b.err(err)
|
|
}
|
|
length := len(cwd)
|
|
if length > 0 && cwd[length-1:] != "/" {
|
|
return cwd + "/", nil
|
|
}
|
|
return cwd, nil
|
|
}
|
|
|
|
func (b Sftp) Ls(path string) ([]os.FileInfo, error) {
|
|
files, err := b.SFTPClient.ReadDir(path)
|
|
return files, b.err(err)
|
|
}
|
|
|
|
func (b Sftp) Cat(path string) (io.ReadCloser, error) {
|
|
remoteFile, err := b.SFTPClient.OpenFile(path, os.O_RDONLY)
|
|
if err != nil {
|
|
return nil, b.err(err)
|
|
}
|
|
return remoteFile, nil
|
|
}
|
|
|
|
func (b Sftp) Mkdir(path string) error {
|
|
err := b.SFTPClient.Mkdir(path)
|
|
return b.err(err)
|
|
}
|
|
|
|
func (b Sftp) Rm(path string) error {
|
|
if IsDirectory(path) {
|
|
list, err := b.SFTPClient.ReadDir(path)
|
|
if err != nil {
|
|
return b.err(err)
|
|
}
|
|
for _, entry := range list {
|
|
p := path + entry.Name()
|
|
if entry.IsDir() {
|
|
p += "/"
|
|
err := b.Rm(p)
|
|
if err != nil {
|
|
return b.err(err)
|
|
}
|
|
} else {
|
|
err := b.SFTPClient.Remove(p)
|
|
if err != nil {
|
|
return b.err(err)
|
|
}
|
|
}
|
|
}
|
|
err = b.SFTPClient.RemoveDirectory(path)
|
|
if err != nil {
|
|
return b.err(err)
|
|
}
|
|
} else {
|
|
err := b.SFTPClient.Remove(path)
|
|
return b.err(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b Sftp) Mv(from string, to string) error {
|
|
err := b.SFTPClient.Rename(from, to)
|
|
return b.err(err)
|
|
}
|
|
|
|
func (b Sftp) Touch(path string) error {
|
|
file, err := b.SFTPClient.OpenFile(path, os.O_WRONLY|os.O_CREATE)
|
|
if err != nil {
|
|
return b.err(err)
|
|
}
|
|
_, err = file.ReadFrom(strings.NewReader(""))
|
|
file.Close()
|
|
return b.err(err)
|
|
}
|
|
|
|
func (b Sftp) Save(path string, file io.Reader) error {
|
|
remoteFile, err := b.SFTPClient.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
|
|
if err != nil {
|
|
return b.err(err)
|
|
}
|
|
_, err = io.Copy(remoteFile, file)
|
|
remoteFile.Close()
|
|
return b.err(err)
|
|
}
|
|
|
|
func (b Sftp) Stat(path string) (os.FileInfo, error) {
|
|
f, err := b.SFTPClient.Stat(path)
|
|
return f, b.err(err)
|
|
}
|
|
|
|
func (b Sftp) Close() error {
|
|
err0 := b.SFTPClient.Close()
|
|
err1 := b.SSHClient.Close()
|
|
|
|
if err0 != nil {
|
|
return err0
|
|
}
|
|
return err1
|
|
}
|
|
|
|
func (b Sftp) err(e error) error {
|
|
f, ok := e.(*sftp.StatusError)
|
|
if ok == false {
|
|
if e == os.ErrNotExist {
|
|
return ErrNotFound
|
|
}
|
|
return e
|
|
}
|
|
switch f.Code {
|
|
case 0:
|
|
return nil
|
|
case 1:
|
|
return NewError("There's nothing more to see", 404)
|
|
case 2:
|
|
return NewError("Does not exist", 404)
|
|
case 3:
|
|
return NewError("Permission denied", 403)
|
|
case 4:
|
|
return NewError("Failure", 409)
|
|
case 5:
|
|
return NewError("Not Compatible", 400)
|
|
case 6:
|
|
return NewError("No Connection", 503)
|
|
case 7:
|
|
return NewError("Connection Lost", 503)
|
|
case 8:
|
|
return NewError("Operation not supported", 501)
|
|
case 9:
|
|
return NewError("Not valid", 400)
|
|
case 10:
|
|
return NewError("No such path", 404)
|
|
case 11:
|
|
return NewError("File already exists", 409)
|
|
case 12:
|
|
return NewError("Write protected", 403)
|
|
case 13:
|
|
return NewError("No media", 404)
|
|
case 14:
|
|
return NewError("No space left", 400)
|
|
case 15:
|
|
return NewError("Quota exceeded", 400)
|
|
case 16:
|
|
return NewError("Unknown", 400)
|
|
case 17:
|
|
return NewError("Lock conflict", 409)
|
|
case 18:
|
|
return NewError("Directory not empty", 400)
|
|
case 19:
|
|
return NewError("Not a directory", 400)
|
|
case 20:
|
|
return NewError("Invalid filename", 400)
|
|
case 21:
|
|
return NewError("Link loop", 508)
|
|
case 22:
|
|
return NewError("Cannot delete", 400)
|
|
case 23:
|
|
return NewError("Invalid query", 400)
|
|
case 24:
|
|
return NewError("File is a directory", 400)
|
|
case 25:
|
|
return NewError("Lock conflict", 409)
|
|
case 26:
|
|
return NewError("Lock refused", 400)
|
|
case 27:
|
|
return NewError("Delete pending", 400)
|
|
case 28:
|
|
return NewError("File corrupt", 400)
|
|
case 29:
|
|
return NewError("Invalid owner", 400)
|
|
case 30:
|
|
return NewError("Invalid group", 400)
|
|
case 31:
|
|
return NewError("Lock wasn't granted", 400)
|
|
default:
|
|
return NewError("Oops! Something went wrong", 500)
|
|
}
|
|
}
|