Files
MickaelK f8956178d8 fix (sftp): azure connection timeout
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
2024-09-20 22:55:54 +10:00

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)
}
}