cachefs/pkg/provider/sftp/sftp.go

267 lines
5.2 KiB
Go

// Copyright (C) 2022 Marius Schellenberger
package sftp
import (
"cachefs/pkg/provider"
"context"
"errors"
"fmt"
"io/fs"
"net"
"net/url"
"os"
"path"
"path/filepath"
"time"
"github.com/go-logr/logr"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
var (
_ provider.FS = (*FS)(nil)
_ SFTP = (*sftp.Client)(nil)
_ SFTP = (*connError)(nil)
)
type SFTP interface {
Remove(string) error
Open(string) (*sftp.File, error)
OpenFile(string, int) (*sftp.File, error)
Stat(string) (fs.FileInfo, error)
ReadDir(string) ([]fs.FileInfo, error)
MkdirAll(string) error
Wait() error
Close() error
}
type FS struct {
log logr.Logger
root string
addr string
ctx context.Context
conn net.Conn
cfg *ssh.ClientConfig
c *ssh.Client
client SFTP
cancel func()
keepalive bool
}
func NewFS(u *url.URL, block bool, log logr.Logger) (fs *FS, err error) {
cfg, err := sshConfig(u)
if err != nil {
return
}
fs = &FS{
log: log.WithValues("host", u.Host),
root: u.Path,
addr: u.Host,
cfg: cfg,
client: connError{},
}
ctx, cancel := context.WithCancel(context.Background())
fs.cancel = cancel
go fs.connect(ctx)
if block {
for {
if _, e := fs.Stat("."); e != ErrNotConnected {
return
}
time.Sleep(time.Millisecond * 100)
}
}
return
}
func (fs *FS) dial() (err error) {
fs.conn, err = net.DialTimeout("tcp", fs.addr, fs.cfg.Timeout)
if err != nil {
return
}
c, chans, reqs, err := ssh.NewClientConn(fs.conn, fs.addr, fs.cfg)
if err != nil {
return
}
fs.c = ssh.NewClient(c, chans, reqs)
return nil
}
func (fs *FS) connect(ctx context.Context) {
log := fs.log
t := time.NewTicker(time.Second * 2)
defer t.Stop()
for {
log.V(2).Info("connecting")
err := fs.dial()
if err == nil {
log.V(2).Info("connected")
if !fs.keepalive {
go fs.keepAlive(ctx)
fs.keepalive = true
}
fs.client, err = sftp.NewClient(fs.c,
sftp.UseFstat(true),
sftp.UseConcurrentReads(true),
//sftp.UseConcurrentWrites(true),
//sftp.MaxPacket(23552),
)
select {
case <-ctx.Done():
return
case e := <-fs.wait(ctx):
log.V(2).Info("connection lost", "err", e)
fs.conn.Close()
fs.client = connError{}
fs.c = nil
}
} else {
log.Error(err, "error connecting")
}
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
func (fs *FS) wait(ctx context.Context) <-chan error {
w := make(chan error)
go func() {
select {
case <-ctx.Done():
case w <- fs.client.Wait():
}
}()
return w
}
func (fs *FS) keepAlive(ctx context.Context) {
t := time.NewTicker(time.Second * 15)
for {
if fs.c != nil {
_, _, err := fs.c.SendRequest("keepalive@cachefs", true, nil)
if err != nil {
fs.log.Error(err, "sending keep-alive request failed")
} else {
fs.conn.SetReadDeadline(time.Now().Add(time.Second * 20))
}
}
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
func (fs *FS) Remove(p string) error {
return fs.client.Remove(fs.path(p))
}
func (fs *FS) Open(p string) (provider.File, error) {
f, err := fs.client.Open(fs.path(p))
return &file{f, fs}, err
}
func (fs *FS) OpenFile(p string, flags int, _ os.FileMode) (provider.File, error) {
f, err := fs.client.OpenFile(fs.path(p), flags)
return &file{f, fs}, err
}
func (fs *FS) Fstat(_ uintptr) (int64, error) {
return 0, provider.ErrFstat
}
func (fs *FS) Stat(p string) (fs.FileInfo, error) {
return fs.client.Stat(fs.path(p))
}
func (fs *FS) Root() string {
return fs.root
}
func (fs *FS) MkdirAll(p string, _ os.FileMode) error {
return fs.client.MkdirAll(fs.path(p))
}
func (fs *FS) Close() error {
fs.cancel()
fs.client.Close()
return fs.c.Close()
}
func (fs *FS) path(p string) string {
return filepath.Join(fs.root, filepath.FromSlash(path.Clean("/"+p)))
}
type file struct {
*sftp.File
fs *FS
}
func (f *file) ReadDir(_ int) (d []fs.DirEntry, err error) {
list, err := f.Readdir(0)
if err != nil {
return
}
d = make([]fs.DirEntry, len(list))
for i, v := range list {
d[i] = fs.FileInfoToDirEntry(v)
}
return
}
func (f *file) Readdir(_ int) ([]fs.FileInfo, error) {
return f.fs.client.ReadDir(f.Name())
}
func (file) Fd() uintptr {
return 0
}
func sshConfig(u *url.URL) (c *ssh.ClientConfig, err error) {
if u.User == nil {
return nil, errors.New("missing username")
}
q, err := url.ParseQuery(u.RawQuery)
if err != nil {
return nil, err
}
c = &ssh.ClientConfig{
Config: ssh.Config{
//KeyExchanges: []string{"curve25519-sha256"},
Ciphers: []string{"aes128-ctr"},
MACs: []string{"hmac-sha2-256"},
},
User: u.User.Username(),
Timeout: time.Second * 30,
}
known := q.Get("known_hosts")
if known == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("error reading user home: %w", err)
}
known = filepath.Join(home, ".ssh", "known_hosts")
}
c.HostKeyCallback, err = knownhosts.New(known)
if err != nil {
return nil, err
}
if pw, ok := u.User.Password(); ok {
c.Auth = []ssh.AuthMethod{ssh.Password(pw)}
} else {
buf, err := os.ReadFile(q.Get("key"))
if err != nil {
return nil, err
}
signer, err := ssh.ParsePrivateKey(buf)
c.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
}
return
}