diff options
| author | Shulhan <ms@kilabit.info> | 2021-07-08 16:50:06 +0700 |
|---|---|---|
| committer | Shulhan <ms@kilabit.info> | 2021-07-12 02:23:47 +0700 |
| commit | 2f6bdb4e2cce7b5bdf4255b21f2588b1d6b7444f (patch) | |
| tree | 732cfe84c677475f4b43508c3dc102d819209ecb /lib/ssh/sftp | |
| parent | a15f96aae0574de614b8ab33cfbb09fd70f00182 (diff) | |
| download | pakakeh.go-2f6bdb4e2cce7b5bdf4255b21f2588b1d6b7444f.tar.xz | |
ssh/sftp: new package that implement SSH File Transport Protocol v3
The sftp package extend the golang.org/x/crypto/ssh package by
implementing "sftp" subsystem using the ssh.Client connection.
Diffstat (limited to 'lib/ssh/sftp')
| -rw-r--r-- | lib/ssh/sftp/client.go | 726 | ||||
| -rw-r--r-- | lib/ssh/sftp/client_test.go | 281 | ||||
| -rw-r--r-- | lib/ssh/sftp/extensions.go | 29 | ||||
| -rw-r--r-- | lib/ssh/sftp/file_attrs.go | 247 | ||||
| -rw-r--r-- | lib/ssh/sftp/file_handle.go | 9 | ||||
| -rw-r--r-- | lib/ssh/sftp/node.go | 14 | ||||
| -rw-r--r-- | lib/ssh/sftp/packet.go | 389 | ||||
| -rw-r--r-- | lib/ssh/sftp/sftp.go | 47 | ||||
| -rw-r--r-- | lib/ssh/sftp/sftp_test.go | 57 | ||||
| -rw-r--r-- | lib/ssh/sftp/testdata/id_ed25519 | 7 | ||||
| -rw-r--r-- | lib/ssh/sftp/testdata/id_ed25519.pub | 1 |
11 files changed, 1807 insertions, 0 deletions
diff --git a/lib/ssh/sftp/client.go b/lib/ssh/sftp/client.go new file mode 100644 index 00000000..babd1dfa --- /dev/null +++ b/lib/ssh/sftp/client.go @@ -0,0 +1,726 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package sftp + +import ( + "errors" + "fmt" + "io" + "os" + "sync" + "time" + + "golang.org/x/crypto/ssh" +) + +// +// Client for SFTP. +// +type Client struct { + sess *ssh.Session + version uint32 + exts extensions + pipeIn io.WriteCloser + pipeOut io.Reader + pipeErr io.Reader + + // The requestId is unique number that will be incremented by client, + // to prevent the same ID generated on concurrent operations. + mtxId sync.Mutex + requestId uint32 +} + +// +// NewClient create and initialize new client for SSH file transfer protocol. +// +func NewClient(sshc *ssh.Client) (cl *Client, err error) { + logp := "New" + cl = &Client{} + + cl.sess, err = sshc.NewSession() + if err != nil { + return nil, fmt.Errorf("%s: NewSession: %w", logp, err) + } + + cl.pipeIn, err = cl.sess.StdinPipe() + if err != nil { + return nil, fmt.Errorf("%s: StdinPipe: %w", logp, err) + } + cl.pipeOut, err = cl.sess.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("%s: StdoutPipe: %w", logp, err) + } + cl.pipeErr, err = cl.sess.StderrPipe() + if err != nil { + return nil, fmt.Errorf("%s: StderrPipe: %w", logp, err) + } + + err = cl.sess.RequestSubsystem(subsystemNameSftp) + if err != nil { + return nil, fmt.Errorf("%s: RequestSubsystem: %w", logp, err) + } + + cl.requestId = uint32(time.Now().Unix()) + + err = cl.init() + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + + return cl, nil +} + +// +// Close the remote file handle. +// +func (cl *Client) Close(fh *FileHandle) (err error) { + if fh == nil { + return nil + } + var ( + logp = "Close" + req = cl.generatePacket() + payload = req.fxpClose(fh) + ) + + res, err := cl.send(payload) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + + if res.kind != packetKindFxpStatus { + return ErrUnexpectedResponse(packetKindFxpStatus, res.kind) + } + if res.code != ssh_FX_OK { + return fmt.Errorf("%s: %d %d %s", logp, res.kind, res.code, res.message) + } + + return nil +} + +// +// Create creates or truncates the named file. +// If the remote file does not exist, it will be created. +// If the remote file already exists, it will be truncated. +// On success, it will return the remote FileHandle ready for write only. +// +func (cl *Client) Create(remoteFile string, fa *FileAttrs) (*FileHandle, error) { + pflags := ssh_FXF_WRITE | ssh_FXF_CREAT | ssh_FXF_TRUNC + return cl.open(remoteFile, pflags, fa) +} + +// +// Fsetstat set the file attributes based on the opened remote file handle. +// +func (cl *Client) Fsetstat(fh *FileHandle, fa *FileAttrs) (err error) { + var ( + logp = "Fsetstat" + req = cl.generatePacket() + ) + if fh == nil || fa == nil { + return nil + } + + payload := req.fxpFsetstat(fh, fa) + + res, err := cl.send(payload) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + if res.kind != packetKindFxpStatus { + return ErrUnexpectedResponse(packetKindFxpStatus, res.kind) + } + if res.code != ssh_FX_OK { + return fmt.Errorf("%s: %d %s", logp, res.code, res.message) + } + return nil +} + +// +// Fstat get the file attributes based on the opened remote file handle. +// +func (cl *Client) Fstat(fh *FileHandle) (fa *FileAttrs, err error) { + var ( + logp = "Fstat" + req = cl.generatePacket() + payload = req.fxpFstat(fh) + ) + res, err := cl.send(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + if res.kind == packetKindFxpStatus { + return nil, fmt.Errorf("%s: %d %d %s", logp, res.kind, res.code, res.message) + } + if res.kind != packetKindFxpAttrs { + return nil, ErrUnexpectedResponse(packetKindFxpAttrs, res.kind) + } + + return res.fa, nil +} + +// +// Get copy remote file to local. +// The local file will be created if its not exist; otherwise it will +// truncated. +// +func (cl *Client) Get(remoteFile, localFile string) (err error) { + var ( + logp = "Get" + offset uint64 + ) + + fin, err := cl.Open(remoteFile) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + + fout, err := os.Create(localFile) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + + for { + data, err := cl.Read(fin, offset) + if len(data) > 0 { + _, err = fout.Write(data) + if err != nil { + break + } + } + if err != nil { + break + } + offset += uint64(len(data)) + } + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("%s: %w", logp, err) + } + + err = fout.Close() + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + + err = cl.Close(fin) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + + return err +} + +// +// Lstat get the file attributes based on the remote file path. +// Unlike Stat(), the Lstat method does not follow symbolic links. +// +func (cl *Client) Lstat(remoteFile string) (fa *FileAttrs, err error) { + var ( + logp = "Lstat" + req = cl.generatePacket() + payload = req.fxpLstat(remoteFile) + ) + res, err := cl.send(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + if res.kind == packetKindFxpStatus { + return nil, fmt.Errorf("%s: %d %s", logp, res.code, res.message) + } + if res.kind != packetKindFxpAttrs { + return nil, ErrUnexpectedResponse(packetKindFxpAttrs, res.kind) + } + + return res.fa, nil +} + +// +// Mkdir create new directory on the server. +// +func (cl *Client) Mkdir(path string, fa *FileAttrs) (err error) { + var ( + logp = "Mkdir" + req = cl.generatePacket() + ) + if fa == nil { + fa = newFileAttrs() + } + payload := req.fxpMkdir(path, fa) + res, err := cl.send(payload) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + if res.kind != packetKindFxpStatus { + return ErrUnexpectedResponse(packetKindFxpStatus, res.kind) + } + if res.code != ssh_FX_OK { + return fmt.Errorf("%s: %d %s", logp, res.code, res.message) + } + return nil +} + +// +// Open the remote file for read only. +// +func (cl *Client) Open(remoteFile string) (fh *FileHandle, err error) { + pflags := ssh_FXF_READ + return cl.open(remoteFile, pflags, nil) +} + +// +// Opendir open the directory on the server. +// +func (cl *Client) Opendir(path string) (fh *FileHandle, err error) { + var ( + logp = "Opendir" + req = cl.generatePacket() + ) + payload := req.fxpOpendir(path) + res, err := cl.send(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + if res.kind == packetKindFxpStatus { + return nil, fmt.Errorf("%s: %d %s", logp, res.code, res.message) + } + if res.kind != packetKindFxpHandle { + return nil, ErrUnexpectedResponse(packetKindFxpHandle, res.kind) + } + fh = res.fh + res.fh = nil + return fh, nil +} + +// +// Put local file to remote file. +// +func (cl *Client) Put(localFile, remoteFile string) (err error) { + logp := "Put" + + fin, err := os.Open(localFile) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + + finfo, err := fin.Stat() + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + + fa := NewFileAttrs(finfo) + + fout, err := cl.Create(remoteFile, fa) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + + var ( + offset uint64 + data = make([]byte, 32000) + n int + ) + + n, err = fin.Read(data) + for n > 0 { + if err != nil { + break + } + err = cl.Write(fout, offset, data[:n]) + if err != nil { + break + } + if n < len(data) { + break + } + + offset += uint64(n) + n, err = fin.Read(data) + } + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + + err = cl.Close(fout) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + + return nil +} + +// +// Read the remote file using handle on specific offset. +// On end-of-file it will return empty data with io.EOF. +// +func (cl *Client) Read(fh *FileHandle, offset uint64) (data []byte, err error) { + var ( + logp = "Read" + req = cl.generatePacket() + payload = req.fxpRead(fh, offset, maxPacketRead) + ) + + res, err := cl.send(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + + if res.kind == packetKindFxpStatus { + if res.code == ssh_FX_EOF { + return nil, io.EOF + } + return nil, fmt.Errorf("%s: %d %s", logp, res.code, res.message) + } + if res.kind != packetKindFxpData { + return nil, ErrUnexpectedResponse(packetKindFxpData, res.kind) + } + + return res.data, nil +} + +// +// Readdir list files and/or directories inside the handle. +// +func (cl *Client) Readdir(fh *FileHandle) (nodes []*Node, err error) { + var ( + logp = "Readdir" + req = cl.generatePacket() + payload = req.fxpReaddir(fh) + ) + + res, err := cl.send(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + + if res.kind == packetKindFxpStatus { + return nil, fmt.Errorf("%s: %d %s", logp, res.code, res.message) + } + if res.kind != packetKindFxpName { + return nil, ErrUnexpectedResponse(packetKindFxpName, res.kind) + } + nodes = res.nodes + res.nodes = nil + return nodes, nil +} + +// +// Readlink read the target of a symbolic link. +// +func (cl *Client) Readlink(linkPath string) (node *Node, err error) { + var ( + logp = "Readlink" + req = cl.generatePacket() + payload = req.fxpReadlink(linkPath) + ) + + res, err := cl.send(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + if res.kind != packetKindFxpName { + return nil, ErrUnexpectedResponse(packetKindFxpName, res.kind) + } + + return res.nodes[0], nil +} + +// +// Realpath canonicalize any given path name to an absolute path. +// This is useful for converting path names containing ".." components or +// relative pathnames without a leading slash into absolute paths. +// +func (cl *Client) Realpath(path string) (node *Node, err error) { + var ( + logp = "Realpath" + req = cl.generatePacket() + payload = req.fxpRealpath(path) + ) + + res, err := cl.send(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + if res.kind != packetKindFxpName { + return nil, ErrUnexpectedResponse(packetKindFxpName, res.kind) + } + + return res.nodes[0], nil +} + +// +// Remove the remote file. +// +func (cl *Client) Remove(remoteFile string) (err error) { + var ( + logp = "Remove" + req = cl.generatePacket() + payload = req.fxpRemove(remoteFile) + ) + + res, err := cl.send(payload) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + if res.kind != packetKindFxpStatus { + return ErrUnexpectedResponse(packetKindFxpStatus, res.kind) + } + if res.code != ssh_FX_OK { + return fmt.Errorf("%s: %d %s", logp, res.code, res.message) + } + + return nil +} + +// +// Rename the file, or move the file, from old path to new path. +// +func (cl *Client) Rename(oldPath, newPath string) (err error) { + var ( + logp = "Rename" + req = cl.generatePacket() + payload = req.fxpRename(oldPath, newPath) + ) + + res, err := cl.send(payload) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + if res.kind != packetKindFxpStatus { + return ErrUnexpectedResponse(packetKindFxpStatus, res.kind) + } + if res.code != ssh_FX_OK { + return fmt.Errorf("%s: %d %s", logp, res.code, res.message) + } + + return nil +} + +// +// Rmdir remove the directory on the server. +// +func (cl *Client) Rmdir(path string) (err error) { + var ( + logp = "Rmdir" + req = cl.generatePacket() + payload = req.fxpRmdir(path) + ) + + res, err := cl.send(payload) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + if res.kind != packetKindFxpStatus { + return ErrUnexpectedResponse(packetKindFxpStatus, res.kind) + } + if res.code != ssh_FX_OK { + return fmt.Errorf("%s: %d %s", logp, res.code, res.message) + } + + return nil +} + +// +// Setstat change the file attributes on remote file or directory. +// These request can be used for operations such as changing the ownership, +// permissions or access times, as well as for truncating a file. +// +func (cl *Client) Setstat(remoteFile string, fa *FileAttrs) (err error) { + var ( + logp = "Setstat" + req = cl.generatePacket() + ) + if len(remoteFile) == 0 || fa == nil { + return nil + } + + payload := req.fxpSetstat(remoteFile, fa) + + res, err := cl.send(payload) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + + if res.kind != packetKindFxpStatus { + return ErrUnexpectedResponse(packetKindFxpStatus, res.kind) + } + if res.code != ssh_FX_OK { + return fmt.Errorf("%s: %d %s", logp, res.code, res.message) + } + + return nil +} + +// +// Stat get the file attributes based on the remote file path. +// This method follow symbolic links. +// +func (cl *Client) Stat(remoteFile string) (fa *FileAttrs, err error) { + var ( + logp = "Stat" + req = cl.generatePacket() + payload = req.fxpStat(remoteFile) + ) + res, err := cl.send(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + if res.kind == packetKindFxpStatus { + return nil, fmt.Errorf("%s: %d %d %s", logp, res.kind, res.code, res.message) + } + if res.kind != packetKindFxpAttrs { + return nil, ErrUnexpectedResponse(packetKindFxpAttrs, res.kind) + } + + return res.fa, nil +} + +// +// Symlink create a symbolic link on the server. +// The `linkpath' specifies the path name of the symlink to be created and +// `targetpath' specifies the target of the symlink. +// +func (cl *Client) Symlink(targetPath, linkPath string) (err error) { + var ( + logp = "Symlink" + req = cl.generatePacket() + payload = req.fxpSymlink(targetPath, linkPath) + ) + if len(targetPath) == 0 || len(linkPath) == 0 { + return nil + } + + res, err := cl.send(payload) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + if res.kind != packetKindFxpStatus { + return ErrUnexpectedResponse(packetKindFxpStatus, res.kind) + } + if res.code != ssh_FX_OK { + return fmt.Errorf("%s: %d %s", logp, res.code, res.message) + } + return nil +} + +// +// Write write the data into remote file at specific offset. +// +func (cl *Client) Write(fh *FileHandle, offset uint64, data []byte) (err error) { + var ( + logp = "Write" + req = cl.generatePacket() + payload = req.fxpWrite(fh, offset, data) + ) + + res, err := cl.send(payload) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + if res.kind != packetKindFxpStatus { + return ErrUnexpectedResponse(packetKindFxpStatus, res.kind) + } + if res.code != ssh_FX_OK { + return fmt.Errorf("%s: %d %d %s", logp, res.kind, res.code, res.message) + } + return nil +} + +func (cl *Client) generatePacket() (pac *packet) { + cl.mtxId.Lock() + cl.requestId++ + pac = &packet{ + requestId: cl.requestId, + } + cl.mtxId.Unlock() + return pac +} + +func (cl *Client) init() (err error) { + logp := "init" + + req := cl.generatePacket() + payload := req.fxpInit(defFxpVersion) + + res, err := cl.send(payload) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + + cl.version = res.version + cl.exts = res.exts + + return nil +} + +func (cl *Client) open(remoteFile string, pflags uint32, fa *FileAttrs) (h *FileHandle, err error) { + var ( + logp = "open" + req = cl.generatePacket() + ) + if fa == nil { + fa = newFileAttrs() + } + + payload := req.fxpOpen(remoteFile, pflags, fa) + + res, err := cl.send(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + + if res.kind != packetKindFxpHandle { + err = fmt.Errorf("%s: %d %d %s", logp, res.kind, res.code, res.message) + return nil, err + } + + return res.fh, nil +} + +func (cl *Client) read() (res []byte, err error) { + var ( + logp = "read" + block = make([]byte, 1024) + ) + + n, err := cl.pipeOut.Read(block) + for n > 0 { + res = append(res, block[:n]...) + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("%s: %w", logp, err) + } + if n < len(block) { + break + } + n, err = cl.pipeOut.Read(block) + } + return res, nil +} + +func (cl *Client) send(payload []byte) (res *packet, err error) { + var ( + logp = "send" + ) + + _, err = cl.pipeIn.Write(payload) + if err != nil { + return nil, fmt.Errorf("%s: Write: %w", logp, err) + } + + payload, err = cl.read() + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + + // TODO: check the response ID. + res, err = unpackPacket(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + + return res, nil +} diff --git a/lib/ssh/sftp/client_test.go b/lib/ssh/sftp/client_test.go new file mode 100644 index 00000000..aed7ddd7 --- /dev/null +++ b/lib/ssh/sftp/client_test.go @@ -0,0 +1,281 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package sftp + +import ( + "testing" + + "github.com/shuLhan/share/lib/test" +) + +func TestClient_Fsetstat(t *testing.T) { + if !isTestManual { + t.Skipf("%s not set", envNameTestManual) + } + + remoteFile := "/tmp/lib-ssh-sftp-fsetstat.test" + + fa := newFileAttrs() + fa.SetPermissions(0766) + + fh, err := testClient.Create(remoteFile, fa) + if err != nil { + t.Fatal(err) + } + + fa, err = testClient.Fstat(fh) + if err != nil { + t.Fatal(err) + } + + exp := uint32(0o100600) + fa.SetPermissions(exp) + + err = testClient.Fsetstat(fh, fa) + if err != nil { + t.Fatal(err) + } + + fa, err = testClient.Fstat(fh) + if err != nil { + t.Fatal(err) + } + + test.Assert(t, "Fsetstat", exp, fa.Permissions()) +} + +func TestClient_Fstat(t *testing.T) { + if !isTestManual { + t.Skipf("%s not set", envNameTestManual) + } + + remoteFile := "/etc/hosts" + fh, err := testClient.Open(remoteFile) + if err != nil { + t.Fatal(err) + } + + fa, err := testClient.Fstat(fh) + if err != nil { + t.Fatal(err) + } + + t.Logf("Fstat %s: %+v", remoteFile, fa) +} + +func TestClient_Get(t *testing.T) { + if !isTestManual { + t.Skipf("%s not set", envNameTestManual) + } + + err := testClient.Get("/tmp/id_ed25519.pub", "testdata/id_ed25519.pub.get") + if err != nil { + t.Fatal(err) + } +} + +func TestClient_Lstat(t *testing.T) { + if !isTestManual { + t.Skipf("%s not set", envNameTestManual) + } + + remoteFile := "/etc/hosts" + fa, err := testClient.Lstat(remoteFile) + if err != nil { + t.Fatal(err) + } + + t.Logf("Lstat: %s: %+v", remoteFile, fa) +} + +func TestClient_Mkdir(t *testing.T) { + if !isTestManual { + t.Skipf("%s not set", envNameTestManual) + } + + path := "/tmp/lib-ssh-sftp-mkdir" + err := testClient.Mkdir(path, nil) + if err != nil { + t.Fatal(err) + } + + err = testClient.Rmdir(path) + if err != nil { + t.Fatal(err) + } +} + +func TestClient_Put(t *testing.T) { + if !isTestManual { + t.Skipf("%s not set", envNameTestManual) + } + + err := testClient.Put("testdata/id_ed25519.pub", "/tmp/id_ed25519.pub") + if err != nil { + t.Fatal(err) + } +} + +func TestClient_Readdir(t *testing.T) { + if !isTestManual { + t.Skipf("%s not set", envNameTestManual) + } + + path := "/tmp" + fh, err := testClient.Opendir(path) + if err != nil { + t.Fatal(err) + } + + nodes, err := testClient.Readdir(fh) + if err != nil { + t.Fatal(err) + } + + t.Logf("List of files inside the %s:\n", path) + for x, node := range nodes { + t.Logf("%02d: %+v\n", x, node.LongName) + } +} + +func TestClient_Realpath(t *testing.T) { + if !isTestManual { + t.Skipf("%s not set", envNameTestManual) + } + + node, err := testClient.Realpath("../../etc/hosts") + if err != nil { + t.Fatal(err) + } + + exp := "/etc/hosts" + test.Assert(t, "Realpath", exp, node.FileName) +} + +func TestClient_Rename(t *testing.T) { + if !isTestManual { + t.Skipf("%s not set", envNameTestManual) + } + + oldPath := "/tmp/lib-ssh-sftp-rename-old" + newPath := "/tmp/lib-ssh-sftp-rename-new" + + _ = testClient.Remove(newPath) + + fh, err := testClient.Create(oldPath, nil) + if err != nil { + t.Fatal(err) + } + + expAttrs, err := testClient.Fstat(fh) + if err != nil { + t.Fatal(err) + } + + err = testClient.Close(fh) + if err != nil { + t.Fatal(err) + } + + err = testClient.Rename(oldPath, newPath) + if err != nil { + t.Fatal(err) + } + + gotAttrs, err := testClient.Stat(newPath) + if err != nil { + t.Fatal(err) + } + + test.Assert(t, "Rename", expAttrs, gotAttrs) +} + +func TestClient_Setstat(t *testing.T) { + if !isTestManual { + t.Skipf("%s not set", envNameTestManual) + } + + remoteFile := "/tmp/lib-ssh-sftp-setstat.test" + + fa := newFileAttrs() + fa.SetPermissions(0766) + + fh, err := testClient.Create(remoteFile, fa) + if err != nil { + t.Fatal(err) + } + + fa, err = testClient.Fstat(fh) + if err != nil { + t.Fatal(err) + } + + exp := uint32(0o100600) + fa.SetPermissions(exp) + + err = testClient.Setstat(remoteFile, fa) + if err != nil { + t.Fatal(err) + } + + fa, err = testClient.Fstat(fh) + if err != nil { + t.Fatal(err) + } + + test.Assert(t, "Setstat", exp, fa.Permissions()) +} + +func TestClient_Stat(t *testing.T) { + if !isTestManual { + t.Skipf("%s not set", envNameTestManual) + } + + remoteFile := "/etc/hosts" + fh, err := testClient.Open(remoteFile) + if err != nil { + t.Fatal(err) + } + + fa, err := testClient.Fstat(fh) + if err != nil { + t.Fatal(err) + } + + t.Logf("Stat: %s: %+v", remoteFile, fa) +} + +func TestClient_Symlink(t *testing.T) { + if !isTestManual { + t.Skipf("%s not set", envNameTestManual) + } + + targetPath := "/tmp/lib-ssh-sftp-symlink-targetpath" + linkPath := "/tmp/lib-ssh-sftp-symlink-linkpath" + + _ = testClient.Remove(linkPath) + _ = testClient.Remove(targetPath) + + fh, err := testClient.Create(targetPath, nil) + if err != nil { + t.Fatal(err) + } + err = testClient.Close(fh) + if err != nil { + t.Fatal(err) + } + + err = testClient.Symlink(targetPath, linkPath) + if err != nil { + t.Fatal(err) + } + + node, err := testClient.Readlink(linkPath) + if err != nil { + t.Fatal(err) + } + + test.Assert(t, "Readlink", targetPath, node.FileName) +} diff --git a/lib/ssh/sftp/extensions.go b/lib/ssh/sftp/extensions.go new file mode 100644 index 00000000..0fb1b247 --- /dev/null +++ b/lib/ssh/sftp/extensions.go @@ -0,0 +1,29 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package sftp + +import "encoding/binary" + +// +// extensions contains mapping of extension-pair name and data, as defined in +// #section-4.2. +// +type extensions map[string]string + +func unpackExtensions(payload []byte) (exts extensions) { + exts = extensions{} + for len(payload) > 0 { + v := binary.BigEndian.Uint32(payload[:4]) + payload = payload[4:] + name := string(payload[:v]) + payload = payload[v:] + + v = binary.BigEndian.Uint32(payload[:4]) + payload = payload[4:] + exts[name] = string(payload[:v]) + payload = payload[v:] + } + return exts +} diff --git a/lib/ssh/sftp/file_attrs.go b/lib/ssh/sftp/file_attrs.go new file mode 100644 index 00000000..a7c14e21 --- /dev/null +++ b/lib/ssh/sftp/file_attrs.go @@ -0,0 +1,247 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package sftp + +import ( + "encoding/binary" + "io" + "io/fs" +) + +// List of valid values for FileAttrs.flags. +const ( + attr_SIZE uint32 = 0x00000001 + attr_UIDGID uint32 = 0x00000002 + attr_PERMISSIONS uint32 = 0x00000004 + attr_ACMODTIME uint32 = 0x00000008 + attr_EXTENDED uint32 = 0x80000000 +) + +// +// FileAttrs define the attributes for opening or creating file on the remote. +// +type FileAttrs struct { + flags uint32 + size uint64 // attr_SIZE + uid uint32 // attr_UIDGID + gid uint32 // attr_UIDGID + permissions uint32 // attr_PERMISSIONS + atime uint32 // attr_ACMODTIME + mtime uint32 // attr_ACMODTIME + exts extensions // attr_EXTENDED +} + +// +// NewFileAttrs create and initialize FileAttrs from FileInfo. +// +func NewFileAttrs(fi fs.FileInfo) (fa *FileAttrs) { + fa = &FileAttrs{} + + mode := fi.Mode() + mtime := fi.ModTime() + + fa.SetSize(uint64(fi.Size())) + fa.SetPermissions(uint32(mode.Perm())) + fa.SetModifiedTime(uint32(mtime.Unix())) + + return fa +} + +func newFileAttrs() (fa *FileAttrs) { + return &FileAttrs{} +} + +func unpackFileAttrs(payload []byte) (fa *FileAttrs, length int) { + fa = &FileAttrs{} + + fa.flags = binary.BigEndian.Uint32(payload) + payload = payload[4:] + length += 4 + + if fa.flags&attr_SIZE != 0 { + fa.size = binary.BigEndian.Uint64(payload) + payload = payload[8:] + length += 8 + } + if fa.flags&attr_UIDGID != 0 { + fa.uid = binary.BigEndian.Uint32(payload) + payload = payload[4:] + length += 4 + fa.gid = binary.BigEndian.Uint32(payload) + payload = payload[4:] + length += 4 + } + if fa.flags&attr_PERMISSIONS != 0 { + fa.permissions = binary.BigEndian.Uint32(payload) + payload = payload[4:] + length += 4 + } + if fa.flags&attr_ACMODTIME != 0 { + fa.atime = binary.BigEndian.Uint32(payload) + payload = payload[4:] + length += 4 + fa.mtime = binary.BigEndian.Uint32(payload) + payload = payload[4:] + length += 4 + } + if fa.flags&attr_EXTENDED != 0 { + n := binary.BigEndian.Uint32(payload) + payload = payload[4:] + length += 4 + + fa.exts = make(extensions, n) + for x := uint32(0); x < n; x++ { + size := binary.BigEndian.Uint32(payload) + payload = payload[4:] + length += 4 + + name := string(payload[:size]) + payload = payload[size:] + length += int(size) + + size = binary.BigEndian.Uint32(payload) + payload = payload[4:] + length += 4 + + data := string(payload[:size]) + payload = payload[size:] + length += int(size) + + fa.exts[name] = data + } + } + return fa, length +} + +func (fa *FileAttrs) pack(w io.Writer) { + _ = binary.Write(w, binary.BigEndian, fa.flags) + + if fa.flags&attr_SIZE != 0 { + _ = binary.Write(w, binary.BigEndian, fa.size) + } + if fa.flags&attr_UIDGID != 0 { + _ = binary.Write(w, binary.BigEndian, fa.uid) + _ = binary.Write(w, binary.BigEndian, fa.gid) + } + if fa.flags&attr_PERMISSIONS != 0 { + _ = binary.Write(w, binary.BigEndian, fa.permissions) + } + if fa.flags&attr_ACMODTIME != 0 { + _ = binary.Write(w, binary.BigEndian, fa.atime) + _ = binary.Write(w, binary.BigEndian, fa.mtime) + } + if fa.flags&attr_EXTENDED != 0 { + n := uint32(len(fa.exts)) + _ = binary.Write(w, binary.BigEndian, n) + for k, v := range fa.exts { + _ = binary.Write(w, binary.BigEndian, uint32(len(k))) + _, _ = w.Write([]byte(k)) + _ = binary.Write(w, binary.BigEndian, uint32(len(v))) + _, _ = w.Write([]byte(v)) + } + } +} + +// +// AccessTime return the remote file access time. +// +func (fa *FileAttrs) AccessTime() uint32 { + return fa.atime +} + +// +// Extensions return the remote file attribute extensions as map of type and +// data. +// +func (fa *FileAttrs) Extensions() map[string]string { + return map[string]string(fa.exts) +} + +// +// Gid return the group ID attribute of file. +// +func (fa *FileAttrs) Gid() uint32 { + return fa.gid +} + +// +// AccessTime return the remote file modified time. +// +func (fa *FileAttrs) ModifiedTime() uint32 { + return fa.mtime +} + +// +// Permissions return the remote file permissions. +// +func (fa *FileAttrs) Permissions() uint32 { + return fa.permissions +} + +// +// SetAccessTime set the file attribute access time. +// +func (fa *FileAttrs) SetAccessTime(v uint32) { + fa.flags |= attr_ACMODTIME + fa.atime = v +} + +// +// SetExtension set the file attribute extension. +// +func (fa *FileAttrs) SetExtension(name, data string) { + if fa.exts == nil { + fa.exts = extensions{} + } + fa.flags |= attr_EXTENDED + fa.exts[name] = data +} + +// +// SetGid set the file attribute group ID. +// +func (fa *FileAttrs) SetGid(gid uint32) { + fa.flags |= attr_UIDGID + fa.gid = gid +} + +// +// SetModifiedTime set the file attribute modified time. +// +func (fa *FileAttrs) SetModifiedTime(v uint32) { + fa.flags |= attr_ACMODTIME + fa.mtime = v +} + +// +// SetPermissions set the remote file permission. +// +func (fa *FileAttrs) SetPermissions(v uint32) { + fa.flags |= attr_PERMISSIONS + fa.permissions = v +} + +// +// SetSize set the remote file size. +// +func (fa *FileAttrs) SetSize(v uint64) { + fa.flags |= attr_SIZE + fa.size = v +} + +// +// SetUid set the file attribute user ID. +// +func (fa *FileAttrs) SetUid(uid uint32) { + fa.flags |= attr_UIDGID + fa.uid = uid +} + +// +// Uid return the user ID of file. +// +func (fa *FileAttrs) Uid() uint32 { + return fa.uid +} diff --git a/lib/ssh/sftp/file_handle.go b/lib/ssh/sftp/file_handle.go new file mode 100644 index 00000000..36467188 --- /dev/null +++ b/lib/ssh/sftp/file_handle.go @@ -0,0 +1,9 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package sftp + +type FileHandle struct { + v []byte +} diff --git a/lib/ssh/sftp/node.go b/lib/ssh/sftp/node.go new file mode 100644 index 00000000..dc038d9b --- /dev/null +++ b/lib/ssh/sftp/node.go @@ -0,0 +1,14 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package sftp + +// +// Node represent the data returned from Readlink. +// +type Node struct { + FileName string + LongName string + Attrs *FileAttrs +} diff --git a/lib/ssh/sftp/packet.go b/lib/ssh/sftp/packet.go new file mode 100644 index 00000000..93a658d8 --- /dev/null +++ b/lib/ssh/sftp/packet.go @@ -0,0 +1,389 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package sftp + +import ( + "bytes" + "encoding/binary" + "fmt" +) + +const ( + packetKindFxpInit byte = 1 + iota + packetKindFxpVersion + packetKindFxpOpen + packetKindFxpClose + packetKindFxpRead // 5 + packetKindFxpWrite + packetKindFxpLstat + packetKindFxpFstat + packetKindFxpSetstat + packetKindFxpFsetstat // 10 + packetKindFxpOpendir + packetKindFxpReaddir + packetKindFxpRemove + packetKindFxpMkdir + packetKindFxpRmdir // 15 + packetKindFxpRealpath + packetKindFxpStat + packetKindFxpRename + packetKindFxpReadlink + packetKindFxpSymlink // 20 +) +const ( + packetKindFxpStatus = 101 + iota + packetKindFxpHandle + packetKindFxpData + packetKindFxpName + packetKindFxpAttrs +) +const ( + packetKindFxpExtended = 200 + iota + packetKindFxpExtendedReply +) + +type packet struct { + length uint32 + kind byte + requestId uint32 + + // FxpVersion. + version uint32 + exts extensions + + // FxpStatus + code uint32 + message string + languageTag string + + // FxpHandle + fh *FileHandle + + // FxpData + data []byte + + // FxpName + nodes []*Node + + // FxpAttrs + fa *FileAttrs +} + +func unpackPacket(payload []byte) (pac *packet, err error) { + logp := "packetUnpack" + gotSize := uint32(len(payload)) + if gotSize < 9 { + return nil, fmt.Errorf("%s: packet size too small %d", logp, gotSize) + } + + pac = &packet{} + + pac.length = binary.BigEndian.Uint32(payload[:4]) + expSize := pac.length + 4 + if expSize != gotSize { + return nil, fmt.Errorf("%s: expecting packet size %d, got %d", logp, expSize, gotSize) + } + pac.kind = payload[4] + + v := binary.BigEndian.Uint32(payload[5:]) + payload = payload[9:] + if pac.kind == packetKindFxpVersion { + pac.version = v + pac.exts = unpackExtensions(payload) + return pac, nil + } + + pac.requestId = v + + switch pac.kind { + case packetKindFxpStatus: + pac.code = binary.BigEndian.Uint32(payload) + payload = payload[4:] + + v = binary.BigEndian.Uint32(payload) + payload = payload[4:] + pac.message = string(payload[:v]) + payload = payload[v:] + + v = binary.BigEndian.Uint32(payload) + payload = payload[4:] + pac.languageTag = string(payload[:v]) + + case packetKindFxpHandle: + pac.fh = &FileHandle{} + v = binary.BigEndian.Uint32(payload) + payload = payload[4:] + pac.fh.v = make([]byte, v) + copy(pac.fh.v, payload[:v]) + + case packetKindFxpData: + v = binary.BigEndian.Uint32(payload) + payload = payload[4:] + pac.data = payload[:v] + payload = payload[v:] + + case packetKindFxpName: + var ( + length int + ) + n := binary.BigEndian.Uint32(payload) + payload = payload[4:] + for x := uint32(0); x < n; x++ { + node := &Node{} + + v = binary.BigEndian.Uint32(payload) + payload = payload[4:] + node.FileName = string(payload[:v]) + payload = payload[v:] + + v = binary.BigEndian.Uint32(payload) + payload = payload[4:] + node.LongName = string(payload[:v]) + payload = payload[v:] + + node.Attrs, length = unpackFileAttrs(payload) + payload = payload[length:] + + pac.nodes = append(pac.nodes, node) + } + + case packetKindFxpAttrs: + pac.fa, _ = unpackFileAttrs(payload) + } + + return pac, nil +} + +func (pac *packet) fxpClose(fh *FileHandle) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpClose) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(fh.v))) + _ = binary.Write(&buf, binary.BigEndian, fh.v) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpFsetstat(fh *FileHandle, fa *FileAttrs) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpFsetstat) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(fh.v))) + _ = binary.Write(&buf, binary.BigEndian, fh.v) + fa.pack(&buf) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpFstat(fh *FileHandle) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpFstat) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(fh.v))) + _ = binary.Write(&buf, binary.BigEndian, fh.v) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpInit(version uint32) []byte { + var buf bytes.Buffer + + if version == 0 { + version = defFxpVersion + } + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpInit) + _ = binary.Write(&buf, binary.BigEndian, version) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpLstat(remoteFile string) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpLstat) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(remoteFile))) + _ = binary.Write(&buf, binary.BigEndian, []byte(remoteFile)) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpMkdir(path string, fa *FileAttrs) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpMkdir) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(path))) + _ = binary.Write(&buf, binary.BigEndian, []byte(path)) + fa.pack(&buf) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpOpen(filename string, pflags uint32, fa *FileAttrs) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpOpen) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(filename))) + _ = binary.Write(&buf, binary.BigEndian, []byte(filename)) + _ = binary.Write(&buf, binary.BigEndian, pflags) + fa.pack(&buf) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpOpendir(path string) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpOpendir) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(path))) + _ = binary.Write(&buf, binary.BigEndian, []byte(path)) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpRead(fh *FileHandle, offset uint64, length uint32) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpRead) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(fh.v))) + _ = binary.Write(&buf, binary.BigEndian, []byte(fh.v)) + _ = binary.Write(&buf, binary.BigEndian, offset) + _ = binary.Write(&buf, binary.BigEndian, length) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpReaddir(fh *FileHandle) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpReaddir) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(fh.v))) + _ = binary.Write(&buf, binary.BigEndian, []byte(fh.v)) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpReadlink(linkPath string) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpReadlink) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(linkPath))) + _ = binary.Write(&buf, binary.BigEndian, []byte(linkPath)) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpRealpath(path string) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpRealpath) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(path))) + _ = binary.Write(&buf, binary.BigEndian, []byte(path)) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpRemove(remoteFile string) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpRemove) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(remoteFile))) + _ = binary.Write(&buf, binary.BigEndian, []byte(remoteFile)) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpRename(oldPath, newPath string) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpRename) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(oldPath))) + _ = binary.Write(&buf, binary.BigEndian, []byte(oldPath)) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(newPath))) + _ = binary.Write(&buf, binary.BigEndian, []byte(newPath)) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpRmdir(path string) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpRmdir) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(path))) + _ = binary.Write(&buf, binary.BigEndian, []byte(path)) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpSetstat(remoteFile string, fa *FileAttrs) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpSetstat) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(remoteFile))) + _ = binary.Write(&buf, binary.BigEndian, []byte(remoteFile)) + fa.pack(&buf) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpStat(remoteFile string) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpStat) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(remoteFile))) + _ = binary.Write(&buf, binary.BigEndian, []byte(remoteFile)) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpSymlink(linkPath, targetPath string) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpSymlink) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(linkPath))) + _ = binary.Write(&buf, binary.BigEndian, []byte(linkPath)) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(targetPath))) + _ = binary.Write(&buf, binary.BigEndian, []byte(targetPath)) + + return sealPacket(buf.Bytes()) +} + +func (pac *packet) fxpWrite(fh *FileHandle, offset uint64, data []byte) []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.BigEndian, packetKindFxpWrite) + _ = binary.Write(&buf, binary.BigEndian, pac.requestId) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(fh.v))) + _ = binary.Write(&buf, binary.BigEndian, fh.v) + _ = binary.Write(&buf, binary.BigEndian, offset) + _ = binary.Write(&buf, binary.BigEndian, uint32(len(data))) + _ = binary.Write(&buf, binary.BigEndian, data) + + return sealPacket(buf.Bytes()) +} + +func sealPacket(in []byte) (out []byte) { + lin := uint32(len(in)) + out = make([]byte, lin+4) + binary.BigEndian.PutUint32(out, lin) + copy(out[4:], in) + return out +} diff --git a/lib/ssh/sftp/sftp.go b/lib/ssh/sftp/sftp.go new file mode 100644 index 00000000..fb9fa32b --- /dev/null +++ b/lib/ssh/sftp/sftp.go @@ -0,0 +1,47 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +// Package sftp implement SSH File Transfer Protocol v3 as defined in +// draft-ietf-secsh-filexfer-02.txt. +// +// The sftp package extend the golang.org/x/crypto/ssh package by +// implementing "sftp" subsystem using the ssh.Client connection. +// +package sftp + +import "fmt" + +const ( + subsystemNameSftp = "sftp" + defFxpVersion uint32 = 3 + maxPacketRead uint32 = 32000 +) + +// List of values for FXP_OPEN pflags. +const ( + ssh_FXF_READ uint32 = 0x00000001 + ssh_FXF_WRITE uint32 = 0x00000002 + ssh_FXF_APPEND uint32 = 0x00000004 + ssh_FXF_CREAT uint32 = 0x00000008 + ssh_FXF_TRUNC uint32 = 0x00000010 + ssh_FXF_EXCL uint32 = 0x00000020 +) + +// List of values for FXP_STATUS code. +const ( + ssh_FX_OK uint32 = iota + ssh_FX_EOF + ssh_FX_NO_SUCH_FILE + ssh_FX_PERMISSION_DENIED + ssh_FX_FAILURE + ssh_FX_BAD_MESSAGE + ssh_FX_NO_CONNECTION + ssh_FX_CONNECTION_LOST + ssh_FX_OP_UNSUPPORTED +) + +func ErrUnexpectedResponse(exp, got byte) error { + return fmt.Errorf("sftp: expecting packet type %d, got %d", exp, got) +} diff --git a/lib/ssh/sftp/sftp_test.go b/lib/ssh/sftp/sftp_test.go new file mode 100644 index 00000000..f048d982 --- /dev/null +++ b/lib/ssh/sftp/sftp_test.go @@ -0,0 +1,57 @@ +package sftp + +import ( + "fmt" + "log" + "os" + "testing" + + "github.com/shuLhan/share/lib/ssh" + "github.com/shuLhan/share/lib/ssh/config" +) + +const ( + envNameTestManual = "LIB_SFTP_TEST_MANUAL" +) + +var ( + // testClient the sftp Client to test all exported functionalities, + // and also to test concurrent packet communication. + testClient *Client + + // Flag to run the unit test that require SSH server. + // This flag is set through environment variable defined on + // envNameTestManual. + isTestManual bool +) + +func TestMain(m *testing.M) { + isTestManual = len(os.Getenv(envNameTestManual)) > 0 + if !isTestManual { + return + } + + cfg := &config.Section{ + User: "ms", + Hostname: "127.0.0.1", + Port: "22", + IdentityFile: []string{ + "./testdata/id_ed25519", + }, + } + + sshClient, err := ssh.NewClientFromConfig(cfg) + if err != nil { + log.Fatal(err) + } + + testClient, err = NewClient(sshClient.Client) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Server version: %d\n", testClient.version) + fmt.Printf("Server extensions: %v\n", testClient.exts) + + os.Exit(m.Run()) +} diff --git a/lib/ssh/sftp/testdata/id_ed25519 b/lib/ssh/sftp/testdata/id_ed25519 new file mode 100644 index 00000000..579c8496 --- /dev/null +++ b/lib/ssh/sftp/testdata/id_ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACCF4+ftKdECJKLWS6V1BO2Vc1VyJQR3mWvWTwPSVoMh5AAAAKBoVsczaFbH +MwAAAAtzc2gtZWQyNTUxOQAAACCF4+ftKdECJKLWS6V1BO2Vc1VyJQR3mWvWTwPSVoMh5A +AAAEA1l0ukNaCkCedaY1Mi1/c32SEdtfmZ5QfBS6cgUhnpM4Xj5+0p0QIkotZLpXUE7ZVz +VXIlBHeZa9ZPA9JWgyHkAAAAFnNzaC10ZXN0QHNodWxoYW4ubG9jYWwBAgMEBQYH +-----END OPENSSH PRIVATE KEY----- diff --git a/lib/ssh/sftp/testdata/id_ed25519.pub b/lib/ssh/sftp/testdata/id_ed25519.pub new file mode 100644 index 00000000..f27a1ed7 --- /dev/null +++ b/lib/ssh/sftp/testdata/id_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIXj5+0p0QIkotZLpXUE7ZVzVXIlBHeZa9ZPA9JWgyHk ssh-test@shulhan.local |
