mirror of
https://github.com/lxc/incus
synced 2026-08-02 05:26:46 +00:00
Remote instance and volume paths are always POSIX, so use "path" rather than "path/filepath" when manipulating them. Closes #3614 Signed-off-by: Stéphane Graber <stgraber@stgraber.org>
762 lines
20 KiB
Go
762 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path"
|
|
"reflect"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
incus "github.com/lxc/incus/v7/client"
|
|
u "github.com/lxc/incus/v7/cmd/incus/usage"
|
|
"github.com/lxc/incus/v7/internal/i18n"
|
|
"github.com/lxc/incus/v7/shared/api"
|
|
config "github.com/lxc/incus/v7/shared/cliconfig"
|
|
"github.com/lxc/incus/v7/shared/logger"
|
|
"github.com/lxc/incus/v7/shared/termios"
|
|
localtls "github.com/lxc/incus/v7/shared/tls"
|
|
"github.com/lxc/incus/v7/shared/util"
|
|
)
|
|
|
|
// Date layout to be used throughout the client.
|
|
const dateLayout = "2006/01/02 15:04 MST"
|
|
|
|
// bufferedWriter accumulates writes until Flush is called, then becomes a
|
|
// direct pass-through to the underlying writer.
|
|
type bufferedWriter struct {
|
|
mu sync.Mutex
|
|
buf []byte
|
|
out io.Writer
|
|
flushed bool
|
|
}
|
|
|
|
func (b *bufferedWriter) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if b.flushed {
|
|
return b.out.Write(p)
|
|
}
|
|
|
|
b.buf = append(b.buf, p...)
|
|
return len(p), nil
|
|
}
|
|
|
|
func (b *bufferedWriter) Flush() error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if b.flushed {
|
|
return nil
|
|
}
|
|
|
|
b.flushed = true
|
|
|
|
_, err := b.out.Write(b.buf)
|
|
b.buf = nil
|
|
return err
|
|
}
|
|
|
|
func (b *bufferedWriter) Close() error {
|
|
return nil
|
|
}
|
|
|
|
// Add a device to an instance.
|
|
func instanceDeviceAdd(client incus.InstanceServer, name string, devName string, dev map[string]string) error {
|
|
// Get the instance entry
|
|
inst, etag, err := client.GetInstance(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check if the device already exists
|
|
_, ok := inst.Devices[devName]
|
|
if ok {
|
|
return fmt.Errorf(i18n.G("Device already exists: %s"), devName)
|
|
}
|
|
|
|
inst.Devices[devName] = dev
|
|
|
|
op, err := client.UpdateInstance(name, inst.Writable(), etag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return op.Wait()
|
|
}
|
|
|
|
// Add a device to a profile.
|
|
func profileDeviceAdd(client incus.InstanceServer, name string, devName string, dev map[string]string) error {
|
|
// Get the profile entry
|
|
profile, profileEtag, err := client.GetProfile(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check if the device already exists
|
|
_, ok := profile.Devices[devName]
|
|
if ok {
|
|
return fmt.Errorf(i18n.G("Device already exists: %s"), devName)
|
|
}
|
|
|
|
// Add the device to the instance
|
|
profile.Devices[devName] = dev
|
|
|
|
err = client.UpdateProfile(name, profile.Writable(), profileEtag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseDeviceOverrides parses device overrides of the form "<deviceName>,<key>=<value>" into a device map.
|
|
// The resulting device map is unlikely to contain valid devices as these are simply values to be overridden.
|
|
func parseDeviceOverrides(deviceOverrideArgs []string) (map[string]map[string]string, error) {
|
|
deviceMap := map[string]map[string]string{}
|
|
for _, entry := range deviceOverrideArgs {
|
|
if !strings.Contains(entry, "=") || !strings.Contains(entry, ",") {
|
|
return nil, fmt.Errorf(i18n.G("Bad device override syntax, expecting <device>,<key>=<value>: %s"), entry)
|
|
}
|
|
|
|
deviceFields := strings.SplitN(entry, ",", 2)
|
|
keyFields := strings.SplitN(deviceFields[1], "=", 2)
|
|
|
|
if deviceMap[deviceFields[0]] == nil {
|
|
deviceMap[deviceFields[0]] = map[string]string{}
|
|
}
|
|
|
|
deviceMap[deviceFields[0]][keyFields[0]] = keyFields[1]
|
|
}
|
|
|
|
return deviceMap, nil
|
|
}
|
|
|
|
// isAliasesSubset returns true if the first array is completely contained in the second array.
|
|
func isAliasesSubset(a1 []api.ImageAlias, a2 []api.ImageAlias) bool {
|
|
set := make(map[string]any)
|
|
for _, alias := range a2 {
|
|
set[alias.Name] = nil
|
|
}
|
|
|
|
for _, alias := range a1 {
|
|
_, found := set[alias.Name]
|
|
if !found {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// getCommonAliases returns the common aliases between a list of aliases and all the existing ones.
|
|
func getCommonAliases(client incus.InstanceServer, aliases ...api.ImageAlias) ([]api.ImageAliasesEntry, error) {
|
|
if len(aliases) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
names := make([]string, len(aliases))
|
|
for i, alias := range aliases {
|
|
names[i] = alias.Name
|
|
}
|
|
|
|
// 'getExistingAliases' which is using 'sort.SearchStrings' requires sorted slice
|
|
sort.Strings(names)
|
|
|
|
resp, err := client.GetImageAliases()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return getExistingAliases(names, resp), nil
|
|
}
|
|
|
|
// Create the specified image aliases, updating those that already exist.
|
|
func ensureImageAliases(client incus.InstanceServer, aliases []api.ImageAlias, fingerprint string) error {
|
|
if len(aliases) == 0 {
|
|
return nil
|
|
}
|
|
|
|
names := make([]string, len(aliases))
|
|
for i, alias := range aliases {
|
|
names[i] = alias.Name
|
|
}
|
|
|
|
sort.Strings(names)
|
|
|
|
resp, err := client.GetImageAliases()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Delete existing aliases that match provided ones
|
|
for _, alias := range getExistingAliases(names, resp) {
|
|
err := client.DeleteImageAlias(alias.Name)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.G("Failed to remove alias %s: %w"), alias.Name, err)
|
|
}
|
|
}
|
|
|
|
// Create new aliases.
|
|
for _, alias := range aliases {
|
|
aliasPost := api.ImageAliasesPost{}
|
|
aliasPost.Name = alias.Name
|
|
aliasPost.Target = fingerprint
|
|
err := client.CreateImageAlias(aliasPost)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.G("Failed to create alias %s: %w"), alias.Name, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getExistingAliases returns the intersection between a list of aliases and all the existing ones.
|
|
func getExistingAliases(aliases []string, allAliases []api.ImageAliasesEntry) []api.ImageAliasesEntry {
|
|
existing := []api.ImageAliasesEntry{}
|
|
for _, alias := range allAliases {
|
|
name := alias.Name
|
|
pos := sort.SearchStrings(aliases, name)
|
|
if pos < len(aliases) && aliases[pos] == name {
|
|
existing = append(existing, alias)
|
|
}
|
|
}
|
|
return existing
|
|
}
|
|
|
|
// deleteImagesByAliases deletes images based on provided aliases. E.g.
|
|
// aliases=[a1], image aliases=[a1] - image will be deleted
|
|
// aliases=[a1, a2], image aliases=[a1] - image will be deleted
|
|
// aliases=[a1], image aliases=[a1, a2] - image will be preserved.
|
|
func deleteImagesByAliases(client incus.InstanceServer, aliases []api.ImageAlias) error {
|
|
existingAliases, err := getCommonAliases(client, aliases...)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.G("Error retrieving aliases: %w"), err)
|
|
}
|
|
|
|
// Nothing to do. Just return.
|
|
if len(existingAliases) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Delete images if necessary
|
|
visitedImages := make(map[string]any)
|
|
for _, alias := range existingAliases {
|
|
image, _, _ := client.GetImage(alias.Target)
|
|
|
|
// If the image has already been visited then continue
|
|
if image != nil {
|
|
_, found := visitedImages[image.Fingerprint]
|
|
if found {
|
|
continue
|
|
}
|
|
|
|
visitedImages[image.Fingerprint] = nil
|
|
}
|
|
|
|
// An image can have multiple aliases. If an image being published
|
|
// reuses all the aliases from an existing image then that existing image is removed.
|
|
// In other case only specific aliases should be removed. E.g.
|
|
// 1. If image with 'foo' and 'bar' aliases already exists and new image is published
|
|
// with aliases 'foo' and 'bar'. Old image should be removed.
|
|
// 2. If image with 'foo' and 'bar' aliases already exists and new image is published
|
|
// with alias 'foo'. Old image should be kept with alias 'bar'
|
|
// and new image will have 'foo' alias.
|
|
if image != nil && isAliasesSubset(image.Aliases, aliases) {
|
|
op, err := client.DeleteImage(alias.Target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = op.Wait()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// kvToMap converts a parsed KV list to a KV map.
|
|
func kvToMap(p *u.Parsed) (map[string]string, error) {
|
|
values := map[string]string{}
|
|
|
|
stdinRead := false
|
|
for _, kv := range p.List {
|
|
key := kv.StringList[0]
|
|
value := kv.StringList[1]
|
|
if value == "-" && !termios.IsTerminal(getStdinFd()) {
|
|
if stdinRead {
|
|
return nil, errors.New(i18n.G("Cannot read the stdin twice"))
|
|
}
|
|
|
|
buf, err := io.ReadAll(os.Stdin)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(i18n.G("Can't read from stdin: %w"), err)
|
|
}
|
|
|
|
value = string(buf[:])
|
|
}
|
|
|
|
values[key] = value
|
|
}
|
|
|
|
return values, nil
|
|
}
|
|
|
|
// settable abstracts commands that set something.
|
|
type settable interface {
|
|
set(cmd *cobra.Command, parsed []*u.Parsed) error
|
|
}
|
|
|
|
// unsetKey reparses the last argument passed to an `unset` command to make it suitable for `set`
|
|
// commands.
|
|
func unsetKey(s settable, cmd *cobra.Command, parsed []*u.Parsed) error {
|
|
last := len(parsed) - 1
|
|
args := make([]string, len(parsed[last].StringList))
|
|
for i, arg := range parsed[last].StringList {
|
|
args[i] = arg + "="
|
|
}
|
|
|
|
parsed[last], _ = u.KV.List(1).Parse(u.Config{}, nil, &args)
|
|
return s.set(cmd, parsed)
|
|
}
|
|
|
|
func readEnvironmentFile(p string) (map[string]string, error) {
|
|
content, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(i18n.G("Can't read from environment file: %w"), err)
|
|
}
|
|
|
|
// Split the file into lines.
|
|
lines := strings.Split(string(content), "\n")
|
|
|
|
// Create a map to store the key value pairs.
|
|
envMap := make(map[string]string)
|
|
|
|
// Iterate over the lines.
|
|
for _, line := range lines {
|
|
// Strip surrounding whitespace and skip empty lines and comments.
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
key, value, _ := strings.Cut(line, "=")
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
|
|
// Strip a single pair of matching surrounding quotes from the value.
|
|
value = strings.TrimSpace(value)
|
|
if len(value) >= 2 {
|
|
first := value[0]
|
|
last := value[len(value)-1]
|
|
if (first == '"' || first == '\'') && first == last {
|
|
value = value[1 : len(value)-1]
|
|
}
|
|
}
|
|
|
|
envMap[key] = value
|
|
}
|
|
|
|
return envMap, nil
|
|
}
|
|
|
|
// structHasField checks if specified struct includes field with given name.
|
|
func structHasField(typ reflect.Type, field string) bool {
|
|
var parent reflect.Type
|
|
|
|
for i := range typ.NumField() {
|
|
fieldType := typ.Field(i)
|
|
yaml := fieldType.Tag.Get("yaml")
|
|
|
|
if yaml == ",inline" {
|
|
parent = fieldType.Type
|
|
}
|
|
|
|
if yaml == field {
|
|
return true
|
|
}
|
|
}
|
|
|
|
if parent != nil {
|
|
return structHasField(parent, field)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// getServerSupportedFilters returns two lists: one with filters supported by server and second one with not supported.
|
|
func getServerSupportedFilters(filters []string, clientFilters []string, singleValueServerSupport bool) ([]string, []string) {
|
|
supportedFilters := []string{}
|
|
unsupportedFilters := []string{}
|
|
|
|
for _, filter := range filters {
|
|
membs := strings.SplitN(filter, "=", 2)
|
|
|
|
if len(membs) == 1 && singleValueServerSupport {
|
|
supportedFilters = append(supportedFilters, filter)
|
|
continue
|
|
} else if len(membs) == 1 && !singleValueServerSupport {
|
|
unsupportedFilters = append(unsupportedFilters, filter)
|
|
continue
|
|
}
|
|
|
|
found := false
|
|
if slices.Contains(clientFilters, membs[0]) {
|
|
found = true
|
|
unsupportedFilters = append(unsupportedFilters, filter)
|
|
}
|
|
|
|
if found {
|
|
continue
|
|
}
|
|
|
|
supportedFilters = append(supportedFilters, filter)
|
|
}
|
|
|
|
return supportedFilters, unsupportedFilters
|
|
}
|
|
|
|
// getImgInfo returns an image server and image info for the given image name (given by a user)
|
|
// an image remote and an instance remote.
|
|
func getImgInfo(d incus.InstanceServer, conf *config.Config, imgRemote string, instRemote string, imageRef string, source *api.InstanceSource) (incus.ImageServer, *api.Image, error) {
|
|
var imgRemoteServer incus.ImageServer
|
|
var imgInfo *api.Image
|
|
var err error
|
|
|
|
// Connect to the image server
|
|
if imgRemote == instRemote {
|
|
imgRemoteServer = d
|
|
} else {
|
|
imgRemoteServer, err = conf.GetImageServer(imgRemote)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
|
|
// Optimisation for public image servers.
|
|
if conf.Remotes[imgRemote].Protocol != "incus" {
|
|
imgInfo = &api.Image{}
|
|
imgInfo.Fingerprint = imageRef
|
|
imgInfo.Public = true
|
|
source.Alias = imageRef
|
|
} else {
|
|
// Attempt to resolve an image alias
|
|
alias, _, err := imgRemoteServer.GetImageAlias(imageRef)
|
|
if err == nil {
|
|
source.Alias = imageRef
|
|
imageRef = alias.Target
|
|
}
|
|
|
|
// Get the image info
|
|
imgInfo, _, err = imgRemoteServer.GetImage(imageRef)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
|
|
return imgRemoteServer, imgInfo, nil
|
|
}
|
|
|
|
// removeElementsFromSlice returns a slice equivalent to removing the given elements from the given list.
|
|
// Elements not present in the list are ignored.
|
|
func removeElementsFromSlice[T comparable](list []T, elements ...T) []T {
|
|
for i, element := range slices.Backward(elements) {
|
|
match := false
|
|
for j, l := range slices.Backward(list) {
|
|
if element == l {
|
|
match = true
|
|
list = slices.Delete(list, j, j+1)
|
|
break
|
|
}
|
|
}
|
|
|
|
if match {
|
|
elements = slices.Delete(elements, i, i+1)
|
|
}
|
|
}
|
|
|
|
return list
|
|
}
|
|
|
|
// sshfsMount mounts the instance's filesystem using sshfs by piping the instance's SFTP connection to sshfs.
|
|
func sshfsMount(ctx context.Context, sftpConn net.Conn, entity string, relPath string, targetPath string) error {
|
|
// Use the format "incus.<instance_name>" as the source "host" (although not used for communication)
|
|
// so that the mount can be seen to be associated with Incus and the instance in the local mount table.
|
|
sourceURL := fmt.Sprintf("incus.%s:%s", entity, relPath)
|
|
|
|
sshfsCmd := exec.Command("sshfs", "-o", "slave", sourceURL, targetPath)
|
|
|
|
// Setup pipes.
|
|
stdin, err := sshfsCmd.StdinPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
stdout, err := sshfsCmd.StdoutPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sshfsCmd.Stderr = os.Stderr
|
|
|
|
err = sshfsCmd.Start()
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.G("Failed starting sshfs: %w"), err)
|
|
}
|
|
|
|
fmt.Printf(i18n.G("sshfs mounting %q on %q")+"\n", fmt.Sprintf("%s%s", entity, relPath), targetPath)
|
|
fmt.Println(i18n.G("Press ctrl+c to finish"))
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
chSignal := make(chan os.Signal, 1)
|
|
signal.Notify(chSignal, os.Interrupt)
|
|
go func() {
|
|
select {
|
|
case <-chSignal:
|
|
case <-ctx.Done():
|
|
}
|
|
|
|
cancel() // Prevents error output when the util.SafeCopy functions finish.
|
|
_ = sshfsCmd.Process.Signal(os.Interrupt) // This will cause sshfs to unmount.
|
|
_ = stdin.Close()
|
|
}()
|
|
|
|
go func() {
|
|
_, err := util.SafeCopy(stdin, sftpConn)
|
|
if ctx.Err() == nil {
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, i18n.G("I/O copy from instance to sshfs failed: %v")+"\n", err)
|
|
} else {
|
|
fmt.Println(i18n.G("Instance disconnected"))
|
|
}
|
|
}
|
|
cancel() // Ask sshfs to end.
|
|
}()
|
|
|
|
_, err = util.SafeCopy(sftpConn, stdout)
|
|
if err != nil && ctx.Err() == nil {
|
|
fmt.Fprintf(os.Stderr, i18n.G("I/O copy from sshfs to instance failed: %v")+"\n", err)
|
|
}
|
|
|
|
cancel() // Ask sshfs to end.
|
|
|
|
err = sshfsCmd.Wait()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Println(i18n.G("sshfs has stopped"))
|
|
|
|
return sftpConn.Close()
|
|
}
|
|
|
|
// sshSFTPServer runs an SSH server listening on a random port of 127.0.0.1.
|
|
// It provides an unauthenticated SFTP server connected to the instance's filesystem.
|
|
func sshSFTPServer(ctx context.Context, sftpConn func() (net.Conn, error), authNone bool, authUser string, listenAddr string) error {
|
|
randString := func(length int) string {
|
|
chars := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0987654321")
|
|
randStr := make([]rune, length)
|
|
for i := range randStr {
|
|
randStr[i] = chars[rand.Intn(len(chars))]
|
|
}
|
|
|
|
return string(randStr)
|
|
}
|
|
|
|
// Setup an SSH SFTP server.
|
|
sshConfig := &ssh.ServerConfig{}
|
|
|
|
var authPass string
|
|
|
|
if authNone {
|
|
sshConfig.NoClientAuth = true
|
|
} else {
|
|
if authUser == "" {
|
|
authUser = randString(8)
|
|
}
|
|
|
|
authPass = randString(8)
|
|
sshConfig.PasswordCallback = func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
|
|
if c.User() == authUser && string(pass) == authPass {
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf(i18n.G("Password rejected for %q"), c.User())
|
|
}
|
|
}
|
|
|
|
// Generate random host key.
|
|
_, privKey, err := localtls.GenerateMemCert(false, false)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.G("Failed generating SSH host key: %w"), err)
|
|
}
|
|
|
|
private, err := ssh.ParsePrivateKey(privKey)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.G("Failed parsing SSH host key: %w"), err)
|
|
}
|
|
|
|
sshConfig.AddHostKey(private)
|
|
|
|
if listenAddr == "" {
|
|
listenAddr = "127.0.0.1:0" // Listen on a random local port if not specified.
|
|
}
|
|
|
|
listener, err := net.Listen("tcp", listenAddr)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.G("Failed to listen for connection: %w"), err)
|
|
}
|
|
|
|
fmt.Printf(i18n.G("SSH SFTP listening on %v")+"\n", listener.Addr())
|
|
|
|
if sshConfig.PasswordCallback != nil {
|
|
fmt.Printf(i18n.G("Login with username %q and password %q")+"\n", authUser, authPass)
|
|
} else {
|
|
fmt.Println(i18n.G("Login without username and password"))
|
|
}
|
|
|
|
for {
|
|
// Wait for new SSH connections.
|
|
nConn, err := listener.Accept()
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.G("Failed to accept incoming connection: %w"), err)
|
|
}
|
|
|
|
// Handle each SSH connection in its own go routine.
|
|
go func() {
|
|
fmt.Printf(i18n.G("SSH client connected %q")+"\n", nConn.RemoteAddr())
|
|
defer fmt.Printf(i18n.G("SSH client disconnected %q")+"\n", nConn.RemoteAddr())
|
|
defer logger.WarnOnError(nConn.Close, "Failed to close connection")
|
|
|
|
// Before use, a handshake must be performed on the incoming net.Conn.
|
|
_, chans, reqs, err := ssh.NewServerConn(nConn, sshConfig)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, i18n.G("Failed SSH handshake with client %q: %v")+"\n", nConn.RemoteAddr(), err)
|
|
return
|
|
}
|
|
|
|
// The incoming Request channel must be serviced.
|
|
go ssh.DiscardRequests(reqs)
|
|
|
|
// Service the incoming Channel requests.
|
|
for newChannel := range chans {
|
|
localChannel := newChannel
|
|
|
|
// Channels have a type, depending on the application level protocol intended.
|
|
// In the case of an SFTP session, this is "subsystem" with a payload string of
|
|
// "<length=4>sftp"
|
|
if localChannel.ChannelType() != "session" {
|
|
_ = localChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
|
|
fmt.Fprintf(os.Stderr, i18n.G("Unknown channel type for client %q: %s")+"\n", nConn.RemoteAddr(), localChannel.ChannelType())
|
|
continue
|
|
}
|
|
|
|
// Accept incoming channel request.
|
|
channel, requests, err := localChannel.Accept()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, i18n.G("Failed accepting channel client %q: %v")+"\n", err)
|
|
return
|
|
}
|
|
|
|
// Sessions have out-of-band requests such as "shell", "pty-req" and "env".
|
|
// Here we handle only the "subsystem" request.
|
|
go func(in <-chan *ssh.Request) {
|
|
for req := range in {
|
|
ok := false
|
|
switch req.Type {
|
|
case "subsystem":
|
|
if string(req.Payload[4:]) == "sftp" {
|
|
ok = true
|
|
}
|
|
}
|
|
|
|
_ = req.Reply(ok, nil)
|
|
}
|
|
}(requests)
|
|
|
|
// Handle each channel in its own go routine.
|
|
go func() {
|
|
defer logger.WarnOnError(channel.Close, "Failed to close channel")
|
|
|
|
// Connect to the instance's SFTP server.
|
|
sftpConn, err := sftpConn()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, i18n.G("Failed connecting to instance SFTP for client %q: %v")+"\n", nConn.RemoteAddr(), err)
|
|
return
|
|
}
|
|
|
|
defer logger.WarnOnError(sftpConn.Close, "Failed to close SFTP connection")
|
|
|
|
// Copy SFTP data between client and remote instance.
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
go func() {
|
|
_, err := util.SafeCopy(channel, sftpConn)
|
|
if ctx.Err() == nil {
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, i18n.G("I/O copy from instance to SSH failed: %v")+"\n", err)
|
|
} else {
|
|
fmt.Printf(i18n.G("Instance disconnected for client %q")+"\n", nConn.RemoteAddr())
|
|
}
|
|
}
|
|
cancel() // Prevents error output when other util.SafeCopy finishes.
|
|
_ = channel.Close()
|
|
}()
|
|
|
|
_, err = util.SafeCopy(sftpConn, channel)
|
|
if err != nil && ctx.Err() == nil {
|
|
fmt.Fprintf(os.Stderr, i18n.G("I/O copy from SSH to instance failed: %v")+"\n", err)
|
|
}
|
|
|
|
cancel() // Prevents error output when other util.SafeCopy finishes.
|
|
_ = sftpConn.Close()
|
|
}()
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// formatRemote formats a remote object.
|
|
func formatRemote(conf *config.Config, p *u.Parsed) string {
|
|
if p.RemoteName == conf.DefaultRemote {
|
|
return p.RemoteObject.String
|
|
}
|
|
|
|
return p.RemoteName + ":" + p.RemoteObject.String
|
|
}
|
|
|
|
// normalizePath normalizes a path and return whether it looks like a directory.
|
|
func normalizePath(p string) (string, bool) {
|
|
// The SFTP server expects a `/` separated path starting with `/`, so use
|
|
// the slash-only "path" logic regardless of the client platform.
|
|
p = "/" + p
|
|
return path.Clean(p), strings.HasSuffix(p, "/")
|
|
}
|
|
|
|
// isStdin returns whether the provided path looks like stdin.
|
|
func isStdin(p string) bool {
|
|
return slices.Contains([]string{"-", "/dev/stdin", "/dev/fd/0"}, p)
|
|
}
|
|
|
|
// isStdout returns whether the provided path looks like stdout.
|
|
func isStdout(p string) bool {
|
|
return slices.Contains([]string{"-", "/dev/stdout", "/dev/fd/1"}, p)
|
|
}
|