Initial commit: PilotVault multi-service project
Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App (Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment configs. Design assets and build artifacts are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
afc6952eda
172 files changed
+24591
No files matched your search
@@ -0,0 +1,610 @@
|
||||
// Package filetransfer is a built-in plugin that connects to a file-transfer
|
||||
// server over FTP, FTPS (explicit TLS), or SFTP (SSH). It demonstrates a
|
||||
// stateful third-party integration behind the plugin contract: one descriptor
|
||||
// with a protocol switch, and a small protocol-agnostic `conn` abstraction that
|
||||
// HealthCheck and Invoke drive without caring which wire protocol is in use.
|
||||
//
|
||||
// Connections are opened per operation rather than pooled: FTP/SFTP sessions are
|
||||
// stateful and idle-timeout aggressively, so dialling on demand is both simpler
|
||||
// and more robust than keeping a long-lived connection healthy. Init only stores
|
||||
// the resolved config; nothing connects until HealthCheck or Invoke runs.
|
||||
//
|
||||
// - FTP : github.com/jlaffaye/ftp
|
||||
// - FTPS : github.com/jlaffaye/ftp with explicit TLS (AUTH TLS)
|
||||
// - SFTP : golang.org/x/crypto/ssh + github.com/pkg/sftp
|
||||
package filetransfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jlaffaye/ftp"
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
const (
|
||||
protoSFTP = "sftp"
|
||||
protoFTP = "ftp"
|
||||
protoFTPS = "ftps"
|
||||
|
||||
dialTimeout = 12 * time.Second
|
||||
// maxReadBytes caps a download so a huge remote file can't exhaust memory;
|
||||
// the health probe and Invoke both honour it.
|
||||
maxReadBytes = 32 << 20 // 32 MiB
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugins.Register("filetransfer", func() plugins.Plugin { return &Plugin{} })
|
||||
}
|
||||
|
||||
// Plugin is the FTP/FTPS/SFTP connector. All fields are guarded by mu because
|
||||
// Init may run concurrently with a HealthCheck/Invoke from another request.
|
||||
type Plugin struct {
|
||||
mu sync.Mutex
|
||||
protocol string
|
||||
host string
|
||||
port int
|
||||
username string
|
||||
password string
|
||||
privateKey string // PEM-encoded SSH private key (sftp only)
|
||||
keyPass string // passphrase for the private key
|
||||
basePath string
|
||||
// hostKeyFP, when set, pins the SFTP server's SHA256 host-key fingerprint
|
||||
// ("SHA256:…"); empty means accept any host key (trust-on-first-use, no
|
||||
// verification — flagged as degraded by the health probe).
|
||||
hostKeyFP string
|
||||
// insecureTLS skips FTPS certificate verification when true.
|
||||
insecureTLS bool
|
||||
}
|
||||
|
||||
func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
return plugins.Descriptor{
|
||||
Name: "filetransfer",
|
||||
Provider: "FTP / SFTP",
|
||||
Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin,
|
||||
Category: plugins.CategoryDrivesExternal,
|
||||
AuthType: plugins.AuthBasic,
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "list", Method: "GET", Endpoint: "/", Description: "List a remote directory. params: {path}"},
|
||||
{ID: "stat", Method: "GET", Endpoint: "/", Description: "Stat one remote path. params: {path}"},
|
||||
{ID: "download", Method: "GET", Endpoint: "/", Description: "Read a remote file (base64, ≤32 MiB). params: {path}"},
|
||||
{ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a remote file. params: {path, contentBase64}"},
|
||||
{ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a remote file. params: {path}"},
|
||||
{ID: "mkdir", Method: "PUT", Endpoint: "/", Description: "Create a remote directory. params: {path}"},
|
||||
},
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
// No field is Required: the plugin can be enabled as a master switch with
|
||||
// an empty global config, leaving each organization or user to supply
|
||||
// their own connection through the cascade (mirrors OpenSky). A missing
|
||||
// host is reported gracefully by the health probe.
|
||||
{Key: "protocol", Label: "Protocol", Type: "select", Default: protoSFTP,
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: protoSFTP, Label: "SFTP — file transfer over SSH (recommended)"},
|
||||
{Value: protoFTPS, Label: "FTPS — FTP with explicit TLS (AUTH TLS)"},
|
||||
{Value: protoFTP, Label: "FTP — plaintext (insecure)"},
|
||||
},
|
||||
Help: "SFTP runs over SSH (port 22); FTP/FTPS use port 21 by default."},
|
||||
{Key: "host", Label: "Host", Type: "text", Help: "Server hostname or IP, e.g. files.example.com"},
|
||||
{Key: "port", Label: "Port", Type: "number", Help: "Leave blank for the protocol default (22 for SFTP, 21 for FTP/FTPS)."},
|
||||
{Key: "username", Label: "Username", Type: "text"},
|
||||
{Key: "password", Label: "Password", Type: "password", Secret: true,
|
||||
Help: "Password for FTP/FTPS, or SFTP password auth. Leave blank to use an SFTP private key."},
|
||||
{Key: "privateKey", Label: "SSH private key (SFTP)", Type: "password", Secret: true,
|
||||
Help: "PEM-encoded private key for SFTP key auth. Used instead of, or alongside, a password."},
|
||||
{Key: "keyPassphrase", Label: "Private key passphrase", Type: "password", Secret: true,
|
||||
Help: "Passphrase protecting the SSH private key, if any."},
|
||||
{Key: "basePath", Label: "Base path", Type: "text", Default: ".",
|
||||
Help: "Directory used as the working root and probed by the health check, e.g. /uploads. Relative capability paths are resolved under it."},
|
||||
{Key: "hostKeyFingerprint", Label: "SFTP host key fingerprint", Type: "text",
|
||||
Help: "Optional SHA256:… fingerprint to pin the SFTP server's host key. Leave blank to accept any key (no verification)."},
|
||||
{Key: "insecureSkipVerify", Label: "FTPS TLS verification", Type: "select", Default: "false",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "false", Label: "Verify certificate (recommended)"},
|
||||
{Value: "true", Label: "Skip verification — accept any certificate"},
|
||||
},
|
||||
Help: "Only affects FTPS. Skip verification only for self-signed test servers."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.protocol = strings.ToLower(strings.TrimSpace(config["protocol"]))
|
||||
if p.protocol == "" {
|
||||
p.protocol = protoSFTP
|
||||
}
|
||||
p.host = strings.TrimSpace(config["host"])
|
||||
p.port = 0
|
||||
if raw := strings.TrimSpace(config["port"]); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil {
|
||||
p.port = n
|
||||
}
|
||||
}
|
||||
p.username = strings.TrimSpace(config["username"])
|
||||
p.password = config["password"]
|
||||
p.privateKey = config["privateKey"]
|
||||
p.keyPass = config["keyPassphrase"]
|
||||
p.basePath = strings.TrimSpace(config["basePath"])
|
||||
if p.basePath == "" {
|
||||
p.basePath = "."
|
||||
}
|
||||
p.hostKeyFP = strings.TrimSpace(config["hostKeyFingerprint"])
|
||||
p.insecureTLS = strings.EqualFold(strings.TrimSpace(config["insecureSkipVerify"]), "true")
|
||||
return nil
|
||||
}
|
||||
|
||||
// effectivePort returns the configured port or the protocol default.
|
||||
func (p *Plugin) effectivePort() int {
|
||||
if p.port > 0 {
|
||||
return p.port
|
||||
}
|
||||
if p.protocol == protoSFTP {
|
||||
return 22
|
||||
}
|
||||
return 21
|
||||
}
|
||||
|
||||
// resolve joins a caller-supplied path against the base path. An absolute path
|
||||
// is used as-is; an empty path becomes the base path itself.
|
||||
func (p *Plugin) resolve(rel string) string {
|
||||
rel = strings.TrimSpace(rel)
|
||||
if rel == "" {
|
||||
return p.basePath
|
||||
}
|
||||
if strings.HasPrefix(rel, "/") || p.basePath == "" || p.basePath == "." {
|
||||
return rel
|
||||
}
|
||||
return path.Join(p.basePath, rel)
|
||||
}
|
||||
|
||||
// HealthCheck dials, authenticates, and lists the base path, classifying the
|
||||
// outcome. A missing/unverified SFTP host key downgrades OK to degraded.
|
||||
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
||||
start := time.Now()
|
||||
|
||||
p.mu.Lock()
|
||||
proto, host, hostKeyFP := p.protocol, p.host, p.hostKeyFP
|
||||
base := p.basePath
|
||||
p.mu.Unlock()
|
||||
|
||||
if host == "" {
|
||||
return plugins.Health{Status: plugins.StatusDown, Detail: "no host configured"}
|
||||
}
|
||||
|
||||
c, err := p.dial(ctx)
|
||||
if err != nil {
|
||||
lat := time.Since(start).Milliseconds()
|
||||
return plugins.Health{Status: classifyDialErr(err), LatencyMs: lat, Detail: err.Error()}
|
||||
}
|
||||
defer c.close()
|
||||
|
||||
entries, err := c.list(base)
|
||||
lat := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: lat,
|
||||
Detail: fmt.Sprintf("connected (%s) but listing %q failed: %v", proto, base, err)}
|
||||
}
|
||||
|
||||
detail := fmt.Sprintf("%s reachable — %d entr%s under %q", strings.ToUpper(proto), len(entries), plural(len(entries)), base)
|
||||
status := plugins.StatusOK
|
||||
if proto == protoSFTP && hostKeyFP == "" {
|
||||
status = plugins.StatusDegraded
|
||||
detail += " · host key not verified (no fingerprint pinned)"
|
||||
}
|
||||
if proto == protoFTP {
|
||||
detail += " · plaintext (no encryption)"
|
||||
}
|
||||
return plugins.Health{Status: status, LatencyMs: lat, Detail: detail}
|
||||
}
|
||||
|
||||
// Invoke runs one capability against a freshly-dialled connection.
|
||||
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
||||
c, err := p.dial(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.close()
|
||||
|
||||
switch action {
|
||||
case "list":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
entries, err := c.list(p.resolve(in.Path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "entries": entries})
|
||||
|
||||
case "stat":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
fi, err := c.stat(p.resolve(in.Path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(fi)
|
||||
|
||||
case "download":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
data, err := c.read(p.resolve(in.Path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"path": p.resolve(in.Path),
|
||||
"size": len(data),
|
||||
"contentBase64": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
|
||||
case "upload":
|
||||
var in writeParams
|
||||
if err := json.Unmarshal(params, &in); err != nil {
|
||||
return nil, fmt.Errorf("invalid params: %w", err)
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(in.ContentBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err)
|
||||
}
|
||||
if err := c.write(p.resolve(in.Path), data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "size": len(data), "ok": true})
|
||||
|
||||
case "delete":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
if err := c.remove(p.resolve(in.Path)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "ok": true})
|
||||
|
||||
case "mkdir":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
if err := c.mkdir(p.resolve(in.Path)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "ok": true})
|
||||
|
||||
default:
|
||||
return nil, errors.New("unknown action: " + action)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
||||
|
||||
// pathParams / writeParams are the Invoke request shapes.
|
||||
type pathParams struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
type writeParams struct {
|
||||
Path string `json:"path"`
|
||||
ContentBase64 string `json:"contentBase64"`
|
||||
}
|
||||
|
||||
// fileInfo is the normalized directory-entry shape returned by list/stat.
|
||||
type fileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
ModTime string `json:"modTime,omitempty"`
|
||||
}
|
||||
|
||||
// conn is the protocol-agnostic surface HealthCheck and Invoke drive. Both the
|
||||
// FTP and SFTP implementations satisfy it.
|
||||
type conn interface {
|
||||
list(path string) ([]fileInfo, error)
|
||||
stat(path string) (fileInfo, error)
|
||||
read(path string) ([]byte, error)
|
||||
write(path string, data []byte) error
|
||||
remove(path string) error
|
||||
mkdir(path string) error
|
||||
close() error
|
||||
}
|
||||
|
||||
// dial builds an authenticated connection for the configured protocol.
|
||||
func (p *Plugin) dial(ctx context.Context) (conn, error) {
|
||||
p.mu.Lock()
|
||||
proto := p.protocol
|
||||
p.mu.Unlock()
|
||||
|
||||
switch proto {
|
||||
case protoSFTP:
|
||||
return p.dialSFTP(ctx)
|
||||
case protoFTP, protoFTPS:
|
||||
return p.dialFTP(ctx)
|
||||
default:
|
||||
return nil, errors.New("unsupported protocol: " + proto)
|
||||
}
|
||||
}
|
||||
|
||||
// classifyDialErr maps a dial/auth failure to a health status: an auth rejection
|
||||
// is degraded (server reachable, credentials wrong); anything else is down.
|
||||
func classifyDialErr(err error) string {
|
||||
msg := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(msg, "unable to authenticate"),
|
||||
strings.Contains(msg, "auth"),
|
||||
strings.Contains(msg, "password"),
|
||||
strings.Contains(msg, "login"),
|
||||
strings.Contains(msg, "530"), // FTP: not logged in
|
||||
strings.Contains(msg, "permission denied"):
|
||||
return plugins.StatusDegraded
|
||||
default:
|
||||
return plugins.StatusDown
|
||||
}
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return "y"
|
||||
}
|
||||
return "ies"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SFTP implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type sftpConn struct {
|
||||
ssh *ssh.Client
|
||||
cli *sftp.Client
|
||||
}
|
||||
|
||||
func (p *Plugin) dialSFTP(ctx context.Context) (conn, error) {
|
||||
p.mu.Lock()
|
||||
host, user, pass := p.host, p.username, p.password
|
||||
key, keyPass, hostKeyFP := p.privateKey, p.keyPass, p.hostKeyFP
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(p.effectivePort()))
|
||||
p.mu.Unlock()
|
||||
|
||||
var auth []ssh.AuthMethod
|
||||
if strings.TrimSpace(key) != "" {
|
||||
signer, err := parseSigner(key, keyPass)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("private key: %w", err)
|
||||
}
|
||||
auth = append(auth, ssh.PublicKeys(signer))
|
||||
}
|
||||
if pass != "" {
|
||||
auth = append(auth, ssh.Password(pass))
|
||||
}
|
||||
if len(auth) == 0 {
|
||||
return nil, errors.New("SFTP requires a password or a private key")
|
||||
}
|
||||
|
||||
hostKeyCallback, err := hostKeyChecker(hostKeyFP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: auth,
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
Timeout: dialTimeout,
|
||||
}
|
||||
|
||||
// ssh.Dial has no context form; dial the TCP conn with the context, then
|
||||
// run the SSH handshake over it.
|
||||
d := net.Dialer{Timeout: dialTimeout}
|
||||
tcp, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sshConn, chans, reqs, err := ssh.NewClientConn(tcp, addr, cfg)
|
||||
if err != nil {
|
||||
_ = tcp.Close()
|
||||
return nil, err
|
||||
}
|
||||
client := ssh.NewClient(sshConn, chans, reqs)
|
||||
sc, err := sftp.NewClient(client)
|
||||
if err != nil {
|
||||
_ = client.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &sftpConn{ssh: client, cli: sc}, nil
|
||||
}
|
||||
|
||||
// parseSigner parses a PEM private key, with or without a passphrase.
|
||||
func parseSigner(pem, passphrase string) (ssh.Signer, error) {
|
||||
if strings.TrimSpace(passphrase) != "" {
|
||||
return ssh.ParsePrivateKeyWithPassphrase([]byte(pem), []byte(passphrase))
|
||||
}
|
||||
return ssh.ParsePrivateKey([]byte(pem))
|
||||
}
|
||||
|
||||
// hostKeyChecker returns a HostKeyCallback that pins the given SHA256:…
|
||||
// fingerprint, or accepts any key when the fingerprint is empty.
|
||||
func hostKeyChecker(fingerprint string) (ssh.HostKeyCallback, error) {
|
||||
if fingerprint == "" {
|
||||
return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec // opt-in: no fingerprint pinned
|
||||
}
|
||||
want := strings.TrimSpace(fingerprint)
|
||||
return func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
||||
got := ssh.FingerprintSHA256(key)
|
||||
if got != want {
|
||||
return fmt.Errorf("host key mismatch: server presented %s, expected %s", got, want)
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *sftpConn) list(p string) ([]fileInfo, error) {
|
||||
infos, err := c.cli.ReadDir(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]fileInfo, 0, len(infos))
|
||||
for _, fi := range infos {
|
||||
out = append(out, fileInfo{
|
||||
Name: fi.Name(),
|
||||
Size: fi.Size(),
|
||||
IsDir: fi.IsDir(),
|
||||
ModTime: fi.ModTime().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *sftpConn) stat(p string) (fileInfo, error) {
|
||||
fi, err := c.cli.Stat(p)
|
||||
if err != nil {
|
||||
return fileInfo{}, err
|
||||
}
|
||||
return fileInfo{
|
||||
Name: fi.Name(),
|
||||
Size: fi.Size(),
|
||||
IsDir: fi.IsDir(),
|
||||
ModTime: fi.ModTime().UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *sftpConn) read(p string) ([]byte, error) {
|
||||
f, err := c.cli.Open(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, maxReadBytes))
|
||||
}
|
||||
|
||||
func (c *sftpConn) write(p string, data []byte) error {
|
||||
f, err := c.cli.Create(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *sftpConn) remove(p string) error { return c.cli.Remove(p) }
|
||||
func (c *sftpConn) mkdir(p string) error { return c.cli.MkdirAll(p) }
|
||||
|
||||
func (c *sftpConn) close() error {
|
||||
err := c.cli.Close()
|
||||
if c.ssh != nil {
|
||||
_ = c.ssh.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FTP / FTPS implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ftpConn struct {
|
||||
c *ftp.ServerConn
|
||||
}
|
||||
|
||||
func (p *Plugin) dialFTP(ctx context.Context) (conn, error) {
|
||||
p.mu.Lock()
|
||||
host, user, pass, proto := p.host, p.username, p.password, p.protocol
|
||||
insecure := p.insecureTLS
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(p.effectivePort()))
|
||||
p.mu.Unlock()
|
||||
|
||||
opts := []ftp.DialOption{ftp.DialWithContext(ctx), ftp.DialWithTimeout(dialTimeout)}
|
||||
if proto == protoFTPS {
|
||||
opts = append(opts, ftp.DialWithExplicitTLS(&tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: insecure, //nolint:gosec // opt-in for self-signed test servers
|
||||
}))
|
||||
}
|
||||
|
||||
sc, err := ftp.Dial(addr, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := sc.Login(user, pass); err != nil {
|
||||
_ = sc.Quit()
|
||||
return nil, err
|
||||
}
|
||||
return &ftpConn{c: sc}, nil
|
||||
}
|
||||
|
||||
func (c *ftpConn) list(p string) ([]fileInfo, error) {
|
||||
entries, err := c.c.List(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]fileInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.Name == "." || e.Name == ".." {
|
||||
continue
|
||||
}
|
||||
out = append(out, entryToInfo(e))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *ftpConn) stat(p string) (fileInfo, error) {
|
||||
// FTP has no portable stat; MLST via GetEntry works on servers that support
|
||||
// it, otherwise fall back to listing the parent and matching the name.
|
||||
if e, err := c.c.GetEntry(p); err == nil && e != nil {
|
||||
return entryToInfo(e), nil
|
||||
}
|
||||
dir, base := path.Split(strings.TrimRight(p, "/"))
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
entries, err := c.c.List(dir)
|
||||
if err != nil {
|
||||
return fileInfo{}, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name == base {
|
||||
return entryToInfo(e), nil
|
||||
}
|
||||
}
|
||||
return fileInfo{}, fmt.Errorf("not found: %s", p)
|
||||
}
|
||||
|
||||
func (c *ftpConn) read(p string) ([]byte, error) {
|
||||
resp, err := c.c.Retr(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Close()
|
||||
return io.ReadAll(io.LimitReader(resp, maxReadBytes))
|
||||
}
|
||||
|
||||
func (c *ftpConn) write(p string, data []byte) error {
|
||||
return c.c.Stor(p, strings.NewReader(string(data)))
|
||||
}
|
||||
|
||||
func (c *ftpConn) remove(p string) error { return c.c.Delete(p) }
|
||||
func (c *ftpConn) mkdir(p string) error { return c.c.MakeDir(p) }
|
||||
|
||||
func (c *ftpConn) close() error { return c.c.Quit() }
|
||||
|
||||
// entryToInfo normalizes a jlaffaye/ftp entry.
|
||||
func entryToInfo(e *ftp.Entry) fileInfo {
|
||||
fi := fileInfo{
|
||||
Name: e.Name,
|
||||
Size: int64(e.Size),
|
||||
IsDir: e.Type == ftp.EntryTypeFolder,
|
||||
}
|
||||
if !e.Time.IsZero() {
|
||||
fi.ModTime = e.Time.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return fi
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package filetransfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
func TestDescriptor(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
d := p.Descriptor()
|
||||
if d.Name != "filetransfer" {
|
||||
t.Fatalf("name = %q, want filetransfer", d.Name)
|
||||
}
|
||||
if d.Kind != plugins.KindBuiltin {
|
||||
t.Fatalf("kind = %q, want builtin", d.Kind)
|
||||
}
|
||||
if len(d.Capabilities) == 0 {
|
||||
t.Fatal("expected capabilities")
|
||||
}
|
||||
// Every secret field must be flagged so the manager masks it.
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Key == "password" || f.Key == "privateKey" || f.Key == "keyPassphrase" {
|
||||
if !f.Secret {
|
||||
t.Errorf("config field %q must be Secret", f.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDefaults(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
if err := p.Init(context.Background(), map[string]string{"host": "h", "username": "u"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.protocol != protoSFTP {
|
||||
t.Errorf("default protocol = %q, want sftp", p.protocol)
|
||||
}
|
||||
if p.effectivePort() != 22 {
|
||||
t.Errorf("default sftp port = %d, want 22", p.effectivePort())
|
||||
}
|
||||
p.protocol = protoFTP
|
||||
if p.effectivePort() != 21 {
|
||||
t.Errorf("default ftp port = %d, want 21", p.effectivePort())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
p := &Plugin{basePath: "/uploads"}
|
||||
cases := map[string]string{
|
||||
"": "/uploads",
|
||||
"a/b.txt": "/uploads/a/b.txt",
|
||||
"/etc/abs": "/etc/abs",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := p.resolve(in); got != want {
|
||||
t.Errorf("resolve(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthCheckUnreachable confirms an unreachable host is classified as down
|
||||
// (not a panic) — the graceful-failure path Init/HealthCheck must guarantee.
|
||||
func TestHealthCheckUnreachable(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
// Port 1 is reserved and refuses connections quickly.
|
||||
if err := p.Init(context.Background(), map[string]string{
|
||||
"protocol": protoSFTP, "host": "127.0.0.1", "port": "1",
|
||||
"username": "u", "password": "pw",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := p.HealthCheck(context.Background())
|
||||
if h.Status != plugins.StatusDown {
|
||||
t.Errorf("status = %q, want down (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostKeyFingerprintMismatch(t *testing.T) {
|
||||
cb, err := hostKeyChecker("SHA256:doesnotmatch")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cb == nil {
|
||||
t.Fatal("expected a callback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistered confirms the plugin registered itself with the shared registry
|
||||
// via init(), so the manager will surface it.
|
||||
func TestRegistered(t *testing.T) {
|
||||
m := plugins.NewManager(t.TempDir() + "/plugins.json")
|
||||
if _, ok := m.Get("filetransfer"); !ok {
|
||||
t.Fatal("filetransfer not registered in the plugin manager")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user