Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/pkg/sftp v1.13.11
github.com/sirupsen/logrus v1.9.4
github.com/urfave/cli/v2 v2.27.7
golang.org/x/sys v0.47.0
)

require (
Expand All @@ -14,5 +15,4 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sys v0.47.0 // indirect
)
47 changes: 36 additions & 11 deletions pkg/reversesshfs/reversesshfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import (
"runtime"
"strconv"
"strings"
"sync/atomic"

"github.com/lima-vm/sshocker/pkg/ssh"
"github.com/lima-vm/sshocker/pkg/util"
"github.com/pkg/sftp"
"github.com/sirupsen/logrus"
)

Expand All @@ -40,6 +40,25 @@ type ReverseSSHFS struct {
sshCmd *exec.Cmd
opensshSftpServerCmd *exec.Cmd
SSHFSAdditionalArgs []string

// ReadonlyNames makes a path read-only when any of its components is one of these names
// (compared case-insensitively). Requires DriverBuiltin on Linux, macOS, or Windows.
ReadonlyNames []string

rooted atomic.Pointer[rootedHandlers]
}

// ExpectRemove makes the next removal of hostPath requested by the remote, within a few seconds,
// succeed without touching hostPath. It is used to relay a local deletion to the remote,
// by removing the path there, which emits an inotify event on the remote.
// It returns false when unsupported, i.e., when not using DriverBuiltin on Linux, macOS, or Windows.
func (rsf *ReverseSSHFS) ExpectRemove(hostPath string) bool {
h := rsf.rooted.Load()
if h == nil {
return false
}
h.expectRemove(hostPath)
return true
}

func (rsf *ReverseSSHFS) Prepare() error {
Expand Down Expand Up @@ -158,6 +177,10 @@ func (rsf *ReverseSSHFS) Start() error {
case DriverBuiltin, DriverOpensshSftpServer:
// NOP
case "", DriverAuto:
if len(rsf.ReadonlyNames) > 0 {
driver = DriverBuiltin
break
}
var err error
driver, opensshSftpServerBinary, err = DetectDriver(opensshSftpServerBinary)
if err != nil {
Expand All @@ -167,7 +190,13 @@ func (rsf *ReverseSSHFS) Start() error {
default:
return fmt.Errorf("unknown driver %q", driver)
}
var builtinSftpServer *sftp.Server
if len(rsf.ReadonlyNames) > 0 && driver != DriverBuiltin {
return fmt.Errorf("ReadonlyNames requires driver %q, got %q", DriverBuiltin, driver)
}
var (
builtinSftpServer interface{ Serve() error }
rooted *rootedHandlers
)
switch driver {
case DriverBuiltin:
stdinPipe, err := rsf.sshCmd.StdinPipe()
Expand All @@ -182,15 +211,8 @@ func (rsf *ReverseSSHFS) Start() error {
ReadCloser: stdoutPipe,
WriteCloser: stdinPipe,
}
var sftpOpts []sftp.ServerOption
if rsf.Readonly {
sftpOpts = append(sftpOpts, sftp.ReadOnly())
}
// NOTE: sftp.NewServer doesn't support specifying the root.
// https://github.com/pkg/sftp/pull/238
//
// TODO: use sftp.NewRequestServer with custom handlers to mitigate potential vulnerabilities.
builtinSftpServer, err = sftp.NewServer(stdio, sftpOpts...)
builtinSftpServer, rooted, err = newRootedServer(stdio, rsf.LocalPath, rsf.Readonly, rsf.ReadonlyNames)
rsf.rooted.Store(rooted)
if err != nil {
return err
}
Expand Down Expand Up @@ -234,6 +256,9 @@ func (rsf *ReverseSSHFS) Start() error {
switch driver {
case DriverBuiltin:
go func() {
if rooted != nil {
defer rooted.Close()
}
if srvErr := builtinSftpServer.Serve(); srvErr != nil {
if errors.Is(srvErr, io.EOF) {
logrus.WithError(srvErr).Debugf("sftp server for %v exited with EOF (negligible)", rsf.LocalPath)
Expand Down
293 changes: 293 additions & 0 deletions pkg/reversesshfs/rooted.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
//go:build linux || darwin || windows

package reversesshfs

import (
"errors"
"io"
"os"
"path"
"strings"
"sync"
"time"

"github.com/pkg/sftp"
)

// rootedHandlers serves only the files under rootPath.
//
// Reads go through os.Root, which follows symlinks but never outside the root.
// Writes open the parent directory without following any symlink,
// and are denied when any path component matches readonlyNames.
// Following no symlink on writes is what prevents the client from
// swapping a directory for a symlink into a read-only one.
// The OS-specific part is in rootedSys.
type rootedHandlers struct {
rootPath string // slash-separated, cleaned
root *os.Root
readonly bool
readonlyNames []string
rootedSys

mu sync.Mutex
noopRemovals map[string]time.Time // expiry, keyed by request path
}

// noopRemovalTTL bounds how long an ExpectRemove token waits for the guest.
const noopRemovalTTL = 5 * time.Second

func newRootedServer(rwc io.ReadWriteCloser, localPath string, readonly bool, readonlyNames []string) (*sftp.RequestServer, *rootedHandlers, error) {
root, err := os.OpenRoot(localPath)
if err != nil {
return nil, nil, err
}
sys, err := openRootedSys(localPath)
if err != nil {
root.Close()
return nil, nil, err
}
h := &rootedHandlers{
rootPath: slashPath(localPath),
root: root,
readonly: readonly,
readonlyNames: readonlyNames,
rootedSys: sys,
noopRemovals: make(map[string]time.Time),
}
handlers := sftp.Handlers{FileGet: h, FilePut: h, FileCmd: h, FileList: h}
srv := sftp.NewRequestServer(rwc, handlers, sftp.WithStartDirectory(startDirectory(h.rootPath)))
return srv, h, nil
}

func (h *rootedHandlers) Close() error {
return errors.Join(h.root.Close(), h.rootedSys.close())
}

// expectRemove makes the next Remove or Rmdir request for p, within noopRemovalTTL,
// succeed without touching the host. p is a host path under the root.
func (h *rootedHandlers) expectRemove(p string) {
p = slashPath(p)
now := time.Now()
h.mu.Lock()
defer h.mu.Unlock()
for k, expiry := range h.noopRemovals {
if now.After(expiry) {
delete(h.noopRemovals, k)
}
}
h.noopRemovals[p] = now.Add(noopRemovalTTL)
}

func (h *rootedHandlers) consumeNoopRemoval(p string) bool {
p = path.Clean(p)
h.mu.Lock()
defer h.mu.Unlock()
expiry, ok := h.noopRemovals[p]
if !ok {
return false
}
delete(h.noopRemovals, p)
return time.Now().Before(expiry)
}

// rel maps a request path to a path relative to the root.
// The result has no "." or ".." component, except "." for the root itself.
func (h *rootedHandlers) rel(p string) (string, error) {
if !path.IsAbs(p) {
p = path.Join(h.rootPath, p)
}
p = path.Clean(p)
if p == h.rootPath {
return ".", nil
}
prefix := h.rootPath
if prefix != "/" {
prefix += "/"
}
if r, ok := strings.CutPrefix(p, prefix); ok {
return r, nil
}
return "", errDenied
}

func (h *rootedHandlers) writableRel(p string) (string, error) {
if h.readonly {
return "", errDenied
}
r, err := h.rel(p)
if err != nil {
return "", err
}
if h.isReadonlyName(r) || !writableName(r) {
return "", errDenied
}
return r, nil
}

func (h *rootedHandlers) isReadonlyName(rel string) bool {
for _, c := range strings.Split(rel, "/") {
for _, name := range h.readonlyNames {
if sameName(c, name) {
return true
}
}
}
return false
}

// sameName reports whether a file name may refer to the same entry as name
// on a case-insensitive (APFS, HFS+, NTFS) file system.
// The ignored code points are the ones listed in next_hfs_char() of git's utf8.c.
func sameName(s, name string) bool {
s = strings.Map(func(r rune) rune {
switch {
case r >= 0x200c && r <= 0x200f, r >= 0x202a && r <= 0x202e, r >= 0x206a && r <= 0x206f, r == 0xfeff:
return -1
}
return r
}, s)
return strings.EqualFold(s, name)
}

// Fileread implements sftp.FileReader.
func (h *rootedHandlers) Fileread(r *sftp.Request) (io.ReaderAt, error) {
rel, err := h.rel(r.Filepath)
if err != nil {
return nil, err
}
return h.root.Open(rel)
}

// Filewrite implements sftp.FileWriter.
func (h *rootedHandlers) Filewrite(r *sftp.Request) (io.WriterAt, error) {
return h.openFile(r)
}

// OpenFile implements sftp.OpenFileWriter.
func (h *rootedHandlers) OpenFile(r *sftp.Request) (sftp.WriterAtReaderAt, error) {
return h.openFile(r)
}

// Filecmd implements sftp.FileCmder.
func (h *rootedHandlers) Filecmd(r *sftp.Request) error {
switch r.Method {
case "Setstat":
if h.isNoopTimes(r) {
return nil
}
return h.setstat(r)
case "Rename":
return h.rename(r, true)
case "Link":
return h.link(r)
case "Remove", "Rmdir":
// The guest agent removes a path deleted on the host, so that the guest emits IN_DELETE.
// The path may have been created again on the host since, so it must not be removed.
if h.consumeNoopRemoval(r.Filepath) {
return nil
}
return h.remove(r)
case "Mkdir":
return h.mkdir(r)
case "Symlink":
return h.symlink(r)
}
return sftp.ErrSSHFxOpUnsupported
}

// PosixRename implements sftp.PosixRenameFileCmder.
func (h *rootedHandlers) PosixRename(r *sftp.Request) error {
return h.rename(r, false)
}

// isNoopTimes reports whether r only sets the access and modification times of a
// read-only name to its current modification time. Such a request is answered
// without touching the file, so that the guest kernel still emits IN_ATTRIB:
// this is how the guest agent relays host inotify events (mountInotify).
func (h *rootedHandlers) isNoopTimes(r *sftp.Request) bool {
flags := r.AttrFlags()
if h.readonly || flags.Size || flags.UidGid || flags.Permissions || !flags.Acmodtime {
return false
}
rel, err := h.rel(r.Filepath)
if err != nil || !h.isReadonlyName(rel) {
return false
}
fi, err := h.root.Lstat(rel)
if err != nil {
return false
}
// SFTP v3 times are in seconds.
mtime := uint32(fi.ModTime().Unix())
attrs := r.Attributes()
return attrs.Atime == mtime && attrs.Mtime == mtime
}

// Filelist implements sftp.FileLister.
func (h *rootedHandlers) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
rel, err := h.rel(r.Filepath)
if err != nil {
return nil, err
}
switch r.Method {
case "List":
f, err := h.root.Open(rel)
if err != nil {
return nil, err
}
defer f.Close()
fis, err := f.Readdir(-1)
if err != nil {
return nil, err
}
return listerAt(fis), nil
case "Stat":
fi, err := h.root.Stat(rel)
if err != nil {
return nil, err
}
return listerAt{fi}, nil
}
return nil, sftp.ErrSSHFxOpUnsupported
}

// Lstat implements sftp.LstatFileLister.
func (h *rootedHandlers) Lstat(r *sftp.Request) (sftp.ListerAt, error) {
rel, err := h.rel(r.Filepath)
if err != nil {
return nil, err
}
fi, err := h.root.Lstat(rel)
if err != nil {
return nil, err
}
return listerAt{fi}, nil
}

// Readlink implements sftp.ReadlinkFileLister.
func (h *rootedHandlers) Readlink(p string) (string, error) {
rel, err := h.rel(p)
if err != nil {
return "", err
}
return h.root.Readlink(rel)
}

// RealPath implements sftp.RealPathFileLister.
// It does not resolve symlinks, and does not access the file system.
func (h *rootedHandlers) RealPath(p string) (string, error) {
return realPath(h.rootPath, p), nil
}

type listerAt []os.FileInfo

func (l listerAt) ListAt(ls []os.FileInfo, offset int64) (int, error) {
if offset >= int64(len(l)) {
return 0, io.EOF
}
n := copy(ls, l[offset:])
if n < len(ls) {
return n, io.EOF
}
return n, nil
}
Loading