From 5969f19d7db57e4bd946f8f387d5133cdd67f60a Mon Sep 17 00:00:00 2001 From: Dmitry Lopatin Date: Wed, 3 Jun 2026 16:45:43 +0300 Subject: [PATCH] fix(cli): use local ssh client for vm access Use the local OpenSSH/SCP clients for VM access by default and remove the embedded native SSH/SCP implementation. Keep --local-ssh and --local-ssh-opts as deprecated compatibility flags, add --ssh-opts for extra client options, and map --known-hosts to OpenSSH UserKnownHostsFile. Signed-off-by: Dmitry Lopatin --- docs/USER_GUIDE.md | 10 +- docs/USER_GUIDE.ru.md | 10 +- src/cli/README.md | 4 +- src/cli/go.mod | 4 +- src/cli/go.sum | 6 - src/cli/internal/cmd/scp/native.go | 126 ----------- src/cli/internal/cmd/scp/scp.go | 11 +- src/cli/internal/cmd/ssh/knownhosts.go | 108 --------- src/cli/internal/cmd/ssh/native.go | 221 ------------------- src/cli/internal/cmd/ssh/ssh.go | 67 +++--- src/cli/internal/cmd/ssh/terminal_unix.go | 90 -------- src/cli/internal/cmd/ssh/terminal_windows.go | 97 -------- src/cli/internal/cmd/ssh/wrapped.go | 23 +- test/e2e/internal/d8/d8.go | 6 +- 14 files changed, 73 insertions(+), 710 deletions(-) delete mode 100644 src/cli/internal/cmd/scp/native.go delete mode 100644 src/cli/internal/cmd/ssh/knownhosts.go delete mode 100644 src/cli/internal/cmd/ssh/native.go delete mode 100644 src/cli/internal/cmd/ssh/terminal_unix.go delete mode 100644 src/cli/internal/cmd/ssh/terminal_windows.go diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 9d48fb623c..4ef0ee448a 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1682,7 +1682,7 @@ d8 v vnc linux-vm Example command for connecting via SSH. ```bash -d8 v ssh cloud@linux-vm --local-ssh +d8 v ssh cloud@linux-vm ``` How to connect to a virtual machine in the web interface: @@ -1807,7 +1807,7 @@ Let's consider an example of changing the configuration of a virtual machine: Suppose we want to change the number of processor cores. The virtual machine is currently running and using one core, which can be confirmed by connecting to it through the serial console and executing the `nproc` command. ```bash -d8 v ssh cloud@linux-vm --local-ssh --command "nproc" +d8 v ssh cloud@linux-vm --command "nproc" ``` Example output: @@ -1835,7 +1835,7 @@ Example output: Configuration changes have been made but not yet applied to the virtual machine. Check this by re-executing: ```bash -d8 v ssh cloud@linux-vm --local-ssh --command "nproc" +d8 v ssh cloud@linux-vm --command "nproc" ``` Example output: @@ -1889,7 +1889,7 @@ After a reboot, the changes will be applied and the `.status.restartAwaitingChan Execute the command to verify: ```bash -d8 v ssh cloud@linux-vm --local-ssh --command "nproc" +d8 v ssh cloud@linux-vm --command "nproc" ``` Example output: @@ -2296,7 +2296,7 @@ attach-blank-disk Attached linux-vm 3m7s Connect to the virtual machine and make sure the disk is connected: ```bash -d8 v ssh cloud@linux-vm --local-ssh --command "lsblk" +d8 v ssh cloud@linux-vm --command "lsblk" ``` Example output: diff --git a/docs/USER_GUIDE.ru.md b/docs/USER_GUIDE.ru.md index b5c1cfe37a..6f95ef1b12 100644 --- a/docs/USER_GUIDE.ru.md +++ b/docs/USER_GUIDE.ru.md @@ -1699,7 +1699,7 @@ d8 v vnc linux-vm Пример команды для подключения по SSH: ```bash -d8 v ssh cloud@linux-vm --local-ssh +d8 v ssh cloud@linux-vm ``` Как подключиться к виртуальной машине в веб-интерфейсе: @@ -1825,7 +1825,7 @@ d8 k edit vm linux-vm Предположим, мы хотим изменить количество ядер процессора. В данный момент виртуальная машина запущена и использует одно ядро, что можно подтвердить, подключившись к ней через серийную консоль и выполнив команду `nproc`. ```bash -d8 v ssh cloud@linux-vm --local-ssh --command "nproc" +d8 v ssh cloud@linux-vm --command "nproc" ``` Пример вывода: @@ -1853,7 +1853,7 @@ d8 k edit vm linux-vm Изменения в конфигурации внесены, но ещё не применены к виртуальной машине. Проверьте это, повторно выполнив: ```bash -d8 v ssh cloud@linux-vm --local-ssh --command "nproc" +d8 v ssh cloud@linux-vm --command "nproc" ``` Пример вывода: @@ -1907,7 +1907,7 @@ d8 v restart linux-vm Выполните команду для проверки: ```bash -d8 v ssh cloud@linux-vm --local-ssh --command "nproc" +d8 v ssh cloud@linux-vm --command "nproc" ``` Пример вывода: @@ -2315,7 +2315,7 @@ attach-blank-disk Attached linux-vm 3m7s Подключитесь к виртуальной машине и удостоверитесь, что диск подключен: ```bash -d8 v ssh cloud@linux-vm --local-ssh --command "lsblk" +d8 v ssh cloud@linux-vm --command "lsblk" ``` Пример вывода: diff --git a/src/cli/README.md b/src/cli/README.md index 8a0f278a2c..686bea8145 100644 --- a/src/cli/README.md +++ b/src/cli/README.md @@ -64,8 +64,8 @@ d8 v scp user@myvm:myfile.bin ~/myfile.bin #### ssh ```shell -d8 v --identity-file=/path/to/ssh_key ssh user@myvm.mynamespace -d8 v ssh --local-ssh=true --namespace=mynamespace --username=user myvm +d8 v ssh --identity-file=/path/to/ssh_key user@myvm.mynamespace +d8 v ssh --namespace=mynamespace --username=user myvm ``` #### vnc diff --git a/src/cli/go.mod b/src/cli/go.mod index 2acbd5b0d5..21b0a11ada 100644 --- a/src/cli/go.mod +++ b/src/cli/go.mod @@ -8,11 +8,8 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/onsi/ginkgo/v2 v2.23.3 github.com/onsi/gomega v1.37.0 - github.com/povsister/scp v0.0.0-20250504051308-e467f71ea63c github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.7 - golang.org/x/crypto v0.45.0 - golang.org/x/sys v0.38.0 golang.org/x/term v0.37.0 golang.org/x/text v0.31.0 gopkg.in/yaml.v3 v3.0.1 @@ -67,6 +64,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/time v0.9.0 // indirect golang.org/x/tools v0.38.0 // indirect google.golang.org/protobuf v1.36.5 // indirect diff --git a/src/cli/go.sum b/src/cli/go.sum index 9277f421f1..95477a899b 100644 --- a/src/cli/go.sum +++ b/src/cli/go.sum @@ -177,8 +177,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/povsister/scp v0.0.0-20250504051308-e467f71ea63c h1:1+j5JHz9mUzYSp0scuF6hzvJP28EDBFe5eBJb0xnGk4= -github.com/povsister/scp v0.0.0-20250504051308-e467f71ea63c/go.mod h1:CiJNEeV6v0tUCNul/+gTjl+FgjfImoiuptJB9AEzqjE= github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -230,9 +228,6 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -252,7 +247,6 @@ golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= diff --git a/src/cli/internal/cmd/scp/native.go b/src/cli/internal/cmd/scp/native.go deleted file mode 100644 index fe812689e9..0000000000 --- a/src/cli/internal/cmd/scp/native.go +++ /dev/null @@ -1,126 +0,0 @@ -/* -Copyright 2018 The KubeVirt Authors -Copyright 2024 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - -Initially copied from https://github.com/kubevirt/kubevirt/blob/main/pkg/virtctl/scp/native.go -*/ - -package scp - -import ( - "errors" - "fmt" - "os" - "path/filepath" - - "github.com/povsister/scp" - - "github.com/deckhouse/virtualization/api/client/kubeclient" - "github.com/deckhouse/virtualization/src/cli/internal/cmd/ssh" - "github.com/deckhouse/virtualization/src/cli/internal/templates" -) - -func (o *SCP) nativeSCP(virtClient kubeclient.Client, local templates.LocalSCPArgument, remote templates.RemoteSCPArgument, toRemote bool) error { - sshClient := ssh.NewNativeSSHConnection(virtClient, o.options) - client, err := sshClient.PrepareSSHClient(remote.Namespace, remote.Name) - if err != nil { - return err - } - - scpClient, err := scp.NewClientFromExistingSSH(client, &scp.ClientOption{}) - if err != nil { - return err - } - - if toRemote { - return o.copyToRemote(scpClient, local.Path, remote.Path) - } - return o.copyFromRemote(scpClient, local.Path, remote.Path) -} - -func (o *SCP) copyToRemote(client *scp.Client, localPath, remotePath string) error { - isFile, isDir, exists, err := stat(localPath) - if err != nil { - return fmt.Errorf("failed reading path %q: %w", localPath, err) - } - - if !exists { - return fmt.Errorf("local path %q does not exist, can't copy it", localPath) - } - - if o.recursive { - if isFile { - return fmt.Errorf("local path %q is not a directory but '--recursive' was provided", localPath) - } - - return client.CopyDirToRemote(localPath, remotePath, &scp.DirTransferOption{PreserveProp: o.preserve}) - } - - if isDir { - return fmt.Errorf("local path %q is a directory but '--recursive' was not provided", localPath) - } - - return client.CopyFileToRemote(localPath, remotePath, &scp.FileTransferOption{PreserveProp: o.preserve}) -} - -func (o *SCP) copyFromRemote(client *scp.Client, localPath, remotePath string) error { - _, isDir, exists, err := stat(localPath) - if err != nil { - return fmt.Errorf("failed reading path %q: %w", localPath, err) - } - - if o.recursive { - if exists { - if !isDir { - return fmt.Errorf("local path %q is a file but '--recursive' was provided", localPath) - } - localPath = appendRemoteBase(localPath, remotePath) - } - - if err := os.MkdirAll(localPath, os.ModePerm); err != nil { - return fmt.Errorf("failed ensuring the existence of the local target directory %q: %w", localPath, err) - } - - return client.CopyDirFromRemote(remotePath, localPath, &scp.DirTransferOption{PreserveProp: o.preserve}) - } - - if exists && isDir { - localPath = appendRemoteBase(localPath, remotePath) - } - - return client.CopyFileFromRemote(remotePath, localPath, &scp.FileTransferOption{PreserveProp: o.preserve}) -} - -func stat(path string) (isFile, isDir, exists bool, err error) { - s, err := os.Stat(path) - if errors.Is(err, os.ErrNotExist) { - return false, false, false, nil - } else if err != nil { - return false, false, false, err - } - return !s.IsDir(), s.IsDir(), true, nil -} - -func appendRemoteBase(localPath, remotePath string) string { - remoteBase := filepath.Base(remotePath) - switch remoteBase { - case "..", ".", "/", "./", "": - // no identifiable base name, let's go with the supplied local path - return localPath - default: - // we identified a base location, let's append it to the local path - return filepath.Join(localPath, remoteBase) - } -} diff --git a/src/cli/internal/cmd/scp/scp.go b/src/cli/internal/cmd/scp/scp.go index fe9b91ac0e..8a46d3e850 100644 --- a/src/cli/internal/cmd/scp/scp.go +++ b/src/cli/internal/cmd/scp/scp.go @@ -68,7 +68,7 @@ func (o *SCP) Run(cmd *cobra.Command, args []string) error { return err } - client, defaultNamespace, _, err := clientconfig.ClientAndNamespaceFromContext(cmd.Context()) + _, defaultNamespace, _, err := clientconfig.ClientAndNamespaceFromContext(cmd.Context()) if err != nil { return err } @@ -77,16 +77,15 @@ func (o *SCP) Run(cmd *cobra.Command, args []string) error { return err } - if o.options.WrapLocalSSH { - clientArgs := o.buildSCPTarget(local, remote, toRemote) - return ssh.RunLocalClient(cmd, remote.Namespace, remote.Name, &o.options, clientArgs) - } + ssh.WarnDeprecatedSSHFlags(cmd) - return o.nativeSCP(client, local, remote, toRemote) + clientArgs := o.buildSCPTarget(local, remote, toRemote) + return ssh.RunLocalClient(cmd, remote.Namespace, remote.Name, &o.options, clientArgs) } func PrepareCommand(cmd *cobra.Command, defaultNamespace string, opts *ssh.SSHOptions, args []string) (local templates.LocalSCPArgument, remote templates.RemoteSCPArgument, toRemote bool, err error) { opts.IdentityFilePathProvided = cmd.Flags().Changed(ssh.IdentityFilePathFlag) + opts.KnownHostsFilePathProvided = cmd.Flags().Changed("known-hosts") local, remote, toRemote, err = templates.ParseSCPArguments(args[0], args[1]) if err != nil { return local, remote, toRemote, err diff --git a/src/cli/internal/cmd/ssh/knownhosts.go b/src/cli/internal/cmd/ssh/knownhosts.go deleted file mode 100644 index 8681ec160f..0000000000 --- a/src/cli/internal/cmd/ssh/knownhosts.go +++ /dev/null @@ -1,108 +0,0 @@ -/* -Copyright 2018 The KubeVirt Authors -Copyright 2024 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - -Initially copied from https://github.com/kubevirt/kubevirt/blob/main/pkg/virtctl/ssh/knownhosts.go -*/ - -package ssh - -import ( - "bufio" - "errors" - "fmt" - "net" - "os" - "strings" - - "golang.org/x/crypto/ssh" - "golang.org/x/crypto/ssh/knownhosts" -) - -// InteractiveHostKeyCallback verifying the host key against known_hosts and adding the key if -// the user replies accordingly. -func InteractiveHostKeyCallback(knownHostsFilePath string) (ssh.HostKeyCallback, error) { - if _, err := os.Stat(knownHostsFilePath); errors.Is(err, os.ErrNotExist) { - f, err := os.Create(knownHostsFilePath) - if err != nil { - return nil, fmt.Errorf("failed creating known hosts file %q: %w", knownHostsFilePath, err) - } - _ = f.Close() - } else if err != nil { - return nil, fmt.Errorf("failed reading known host file %q: %w", knownHostsFilePath, err) - } - validator, err := knownhosts.New(knownHostsFilePath) - if err != nil { - return nil, err - } - - return func(hostname string, remote net.Addr, key ssh.PublicKey) error { - err := validator(hostname, remote, key) - if err == nil { - return nil - } - - var keyErr *knownhosts.KeyError - if errors.As(err, &keyErr) && len(keyErr.Want) == 0 { - shouldAdd, err := askToAddHostKey(hostname, remote, key) - if err != nil || !shouldAdd { - return err - } - if err := addHostKey(knownHostsFilePath, hostname, key); err != nil { - return err - } - return nil - } - - return err - }, nil -} - -func askToAddHostKey(hostname string, remote net.Addr, key ssh.PublicKey) (bool, error) { - reader := bufio.NewReader(os.Stdin) - fmt.Printf( - `The authenticity of host '%s (%s)' can't be established. -ECDSA key fingerprint is %s. -Are you sure you want to continue connecting (yes/no)? `, - hostname, remote, ssh.FingerprintSHA256(key), - ) - confirmation, err := reader.ReadString('\n') - if err != nil { - return false, err - } - confirmation = strings.TrimSpace(confirmation) - - if confirmation == "yes" { - return true, nil - } - if confirmation == "no" { - return false, nil - } - - fmt.Println("Please reply with either yes or no.") - return askToAddHostKey(hostname, remote, key) -} - -func addHostKey(knownHostsFilePath, hostname string, key ssh.PublicKey) error { - f, err := os.OpenFile(knownHostsFilePath, os.O_APPEND|os.O_WRONLY, 0o600) - if err != nil { - return err - } - defer f.Close() - - addresses := []string{hostname} - _, err = fmt.Fprintln(f, knownhosts.Line(addresses, key)) - return err -} diff --git a/src/cli/internal/cmd/ssh/native.go b/src/cli/internal/cmd/ssh/native.go deleted file mode 100644 index 3d7dbae11f..0000000000 --- a/src/cli/internal/cmd/ssh/native.go +++ /dev/null @@ -1,221 +0,0 @@ -/* -Copyright 2018 The KubeVirt Authors -Copyright 2024 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - -Initially copied from https://github.com/kubevirt/kubevirt/blob/main/pkg/virtctl/ssh/native.go -*/ - -package ssh - -import ( - "errors" - "fmt" - "net" - "os" - - "golang.org/x/crypto/ssh" - "golang.org/x/crypto/ssh/agent" - "golang.org/x/term" - "k8s.io/klog/v2" - - virtualizationv1alpha2 "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/typed/core/v1alpha2" - "github.com/deckhouse/virtualization/api/client/kubeclient" - subv1alpha2 "github.com/deckhouse/virtualization/api/subresources/v1alpha2" -) - -func (o *SSH) nativeSSH(namespace, name string, virtClient kubeclient.Client) error { - conn := NewNativeSSHConnection(virtClient, o.options) - client, err := conn.PrepareSSHClient(namespace, name) - if err != nil { - return err - } - return conn.StartSession(client, o.command) -} - -func NewNativeSSHConnection(virtClient kubeclient.Client, options SSHOptions) *NativeSSHConnection { - return &NativeSSHConnection{ - virtClient: virtClient, - options: options, - } -} - -type NativeSSHConnection struct { - virtClient kubeclient.Client - options SSHOptions -} - -func (o *NativeSSHConnection) PrepareSSHClient(namespace, name string) (*ssh.Client, error) { - streamer, err := o.prepareSSHTunnel(namespace, name) - if err != nil { - return nil, err - } - - conn := streamer.AsConn() - addr := fmt.Sprintf("%s.%s:%d", name, namespace, o.options.SSHPort) - authMethods := o.getAuthMethods(namespace, name) - - hostKeyCallback := ssh.InsecureIgnoreHostKey() - if len(o.options.KnownHostsFilePath) > 0 { - hostKeyCallback, err = InteractiveHostKeyCallback(o.options.KnownHostsFilePath) - if err != nil { - return nil, err - } - } else { - fmt.Println("WARNING: skipping hostkey check, provide --known-hosts to fix this") - } - - sshConn, chans, reqs, err := ssh.NewClientConn(conn, - addr, - &ssh.ClientConfig{ - HostKeyCallback: hostKeyCallback, - Auth: authMethods, - User: o.options.SSHUsername, - }, - ) - if err != nil { - return nil, err - } - - return ssh.NewClient(sshConn, chans, reqs), nil -} - -func (o *NativeSSHConnection) getAuthMethods(namespace, name string) []ssh.AuthMethod { - var methods []ssh.AuthMethod - - methods = o.trySSHAgent(methods) - methods = o.tryPrivateKey(methods) - - methods = append(methods, ssh.PasswordCallback(func() (secret string, err error) { - password, err := readPassword(fmt.Sprintf("%s@%s.%s's password: ", o.options.SSHUsername, name, namespace)) - fmt.Println() - return string(password), err - })) - - return methods -} - -func (o *NativeSSHConnection) trySSHAgent(methods []ssh.AuthMethod) []ssh.AuthMethod { - socket := os.Getenv("SSH_AUTH_SOCK") - if len(socket) < 1 { - return methods - } - conn, err := net.Dial("unix", socket) - if err != nil { - klog.Error("no connection to ssh agent, skipping agent authentication:", err) - return methods - } - agentClient := agent.NewClient(conn) - - return append(methods, ssh.PublicKeysCallback(agentClient.Signers)) -} - -func (o *NativeSSHConnection) tryPrivateKey(methods []ssh.AuthMethod) []ssh.AuthMethod { - // If the identity file at the default does not exist but was - // not explicitly provided, don't add the authentication mechanism. - if !o.options.IdentityFilePathProvided { - if _, err := os.Stat(o.options.IdentityFilePath); errors.Is(err, os.ErrNotExist) { - klog.V(3).Infof("No ssh key at the default location %q found, skipping RSA authentication.", o.options.IdentityFilePath) - return methods - } - } - - callback := ssh.PublicKeysCallback(func() (signers []ssh.Signer, err error) { - key, err := os.ReadFile(o.options.IdentityFilePath) - if err != nil { - return nil, err - } - - signer, err := ssh.ParsePrivateKey(key) - var passphraseMissingError *ssh.PassphraseMissingError - if errors.As(err, &passphraseMissingError) { - signer, err = o.parsePrivateKeyWithPassphrase(key) - } - - if err != nil { - return nil, err - } - - return []ssh.Signer{signer}, nil - }) - - return append(methods, callback) -} - -func (o *NativeSSHConnection) parsePrivateKeyWithPassphrase(key []byte) (ssh.Signer, error) { - password, err := readPassword(fmt.Sprintf("Key %s requires a password: ", o.options.IdentityFilePath)) - fmt.Println() - if err != nil { - return nil, err - } - - return ssh.ParsePrivateKeyWithPassphrase(key, password) -} - -func readPassword(reason string) ([]byte, error) { - fmt.Print(reason) - return term.ReadPassword(int(os.Stdin.Fd())) -} - -func (o *NativeSSHConnection) StartSession(client *ssh.Client, command string) error { - session, err := client.NewSession() - if err != nil { - return err - } - defer session.Close() - - session.Stdin = os.Stdin - session.Stderr = os.Stderr - session.Stdout = os.Stdout - - if command != "" { - if err := session.Run(command); err != nil { - return err - } - return nil - } - - restore, err := setupTerminal() - if err != nil { - return err - } - defer restore() - - if err := requestPty(session); err != nil { - return err - } - if err := session.Shell(); err != nil { - return err - } - - err = session.Wait() - var exitError *ssh.ExitError - if !errors.As(err, &exitError) { - return err - } - return nil -} - -func (o *NativeSSHConnection) prepareSSHTunnel(namespace, name string) (virtualizationv1alpha2.StreamInterface, error) { - opts := subv1alpha2.VirtualMachinePortForward{ - Port: o.options.SSHPort, - Protocol: "tcp", - } - stream, err := o.virtClient.VirtualMachines(namespace).PortForward(name, opts) - if err != nil { - return nil, fmt.Errorf("can't access VM %s: %w", name, err) - } - - return stream, nil -} diff --git a/src/cli/internal/cmd/ssh/ssh.go b/src/cli/internal/cmd/ssh/ssh.go index acc5d72b1e..4ab847500b 100644 --- a/src/cli/internal/cmd/ssh/ssh.go +++ b/src/cli/internal/cmd/ssh/ssh.go @@ -33,15 +33,14 @@ import ( ) const ( - KnownHostsFileName = "d8virtualization_known_hosts" portFlag, portFlagShort = "port", "p" usernameFlag, usernameFlagShort = "username", "l" IdentityFilePathFlag, identityFilePathFlagShort = "identity-file", "i" knownHostsFilePathFlag = "known-hosts" commandToExecute, commandToExecuteShort = "command", "c" - additionalOpts, additionalOptsShort = "local-ssh-opts", "t" + additionalOpts, additionalOptsShort = "ssh-opts", "t" + additionalLocalOpts = "local-ssh-opts" wrapLocalSSHFlag = "local-ssh" - wrapLocalSSHDefault = false ) type SSH struct { @@ -50,28 +49,32 @@ type SSH struct { } type SSHOptions struct { - SSHPort int - SSHUsername string - IdentityFilePath string - IdentityFilePathProvided bool - KnownHostsFilePath string - KnownHostsFilePathDefault string - AdditionalSSHLocalOptions []string - WrapLocalSSH bool - LocalClientName string + SSHPort int + SSHUsername string + IdentityFilePath string + IdentityFilePathProvided bool + KnownHostsFilePath string + KnownHostsFilePathDefault string + KnownHostsFilePathProvided bool + AdditionalSSHOptions []string + AdditionalSSHLocalOptions []string + WrapLocalSSH bool + LocalClientName string } func DefaultSSHOptions() SSHOptions { options := SSHOptions{ - SSHPort: 22, - SSHUsername: defaultUsername(), - IdentityFilePath: filepath.Join("~", ".ssh", "id_rsa"), - IdentityFilePathProvided: false, - KnownHostsFilePath: "", - KnownHostsFilePathDefault: filepath.Join("~", ".ssh", KnownHostsFileName), - AdditionalSSHLocalOptions: []string{}, - WrapLocalSSH: wrapLocalSSHDefault, - LocalClientName: "ssh", + SSHPort: 22, + SSHUsername: defaultUsername(), + IdentityFilePath: filepath.Join("~", ".ssh", "id_rsa"), + IdentityFilePathProvided: false, + KnownHostsFilePath: "", + KnownHostsFilePathDefault: "", + KnownHostsFilePathProvided: false, + AdditionalSSHOptions: []string{}, + AdditionalSSHLocalOptions: []string{}, + WrapLocalSSH: false, + LocalClientName: "ssh", } return options @@ -146,9 +149,9 @@ func NewCommand() *cobra.Command { func AddCommonSSHFlags(flagset *pflag.FlagSet, opts *SSHOptions) { flagset.StringVarP(&opts.IdentityFilePath, IdentityFilePathFlag, identityFilePathFlagShort, opts.IdentityFilePath, - "Specify a path to a private key used for authenticating to the server; If not provided, the client will try to use the local ssh-agent at $SSH_AUTH_SOCK") + "Specify a path to a private key passed to the local SSH/SCP client as -i; If not provided, OpenSSH default identity selection applies") flagset.StringVar(&opts.KnownHostsFilePath, knownHostsFilePathFlag, opts.KnownHostsFilePathDefault, - "Set a path to the known_hosts file.") + "Set a path to the known_hosts file passed to the local SSH/SCP client as UserKnownHostsFile.") flagset.IntVarP(&opts.SSHPort, portFlag, portFlagShort, opts.SSHPort, `Specify a port to connect to`) @@ -161,7 +164,7 @@ func (o *SSH) Run(cmd *cobra.Command, args []string) error { return err } - client, defaultNamespace, _, err := clientconfig.ClientAndNamespaceFromContext(cmd.Context()) + _, defaultNamespace, _, err := clientconfig.ClientAndNamespaceFromContext(cmd.Context()) if err != nil { return err } @@ -170,16 +173,15 @@ func (o *SSH) Run(cmd *cobra.Command, args []string) error { return err } - if o.options.WrapLocalSSH { - clientArgs := o.buildSSHTarget(namespace, name) - return RunLocalClient(cmd, namespace, name, &o.options, clientArgs) - } + WarnDeprecatedSSHFlags(cmd) - return o.nativeSSH(namespace, name, client) + clientArgs := o.buildSSHTarget(namespace, name) + return RunLocalClient(cmd, namespace, name, &o.options, clientArgs) } func PrepareCommand(cmd *cobra.Command, defaultNamespace string, opts *SSHOptions, args []string) (namespace, name string, err error) { opts.IdentityFilePathProvided = cmd.Flags().Changed(IdentityFilePathFlag) + opts.KnownHostsFilePathProvided = cmd.Flags().Changed(knownHostsFilePathFlag) var targetUsername string namespace, name, targetUsername, err = templates.ParseSSHTarget(args[0]) if err != nil { @@ -209,17 +211,12 @@ func usage() string { # Run command instead of opening shell: {{ProgramName}} ssh -n vms user@myvm -%s 'ls -la /' - # Connect using the local ssh binary found in $PATH: - {{ProgramName}} ssh --%s=true user@myvm - # Specify additional options for local ssh: - {{ProgramName}} ssh user@myvm --%s=true --%s='-o StrictHostKeyChecking=no' --%s='-o UserKnownHostsFile=/dev/null' + {{ProgramName}} ssh user@myvm --%s='-o StrictHostKeyChecking=no' --%s='-o UserKnownHostsFile=/dev/null' `, usernameFlag, identityFilePathFlagShort, commandToExecuteShort, - wrapLocalSSHFlag, - wrapLocalSSHFlag, additionalOpts, additionalOpts, ) diff --git a/src/cli/internal/cmd/ssh/terminal_unix.go b/src/cli/internal/cmd/ssh/terminal_unix.go deleted file mode 100644 index a455fa88ef..0000000000 --- a/src/cli/internal/cmd/ssh/terminal_unix.go +++ /dev/null @@ -1,90 +0,0 @@ -//go:build !windows - -/* -Copyright 2018 The KubeVirt Authors -Copyright 2024 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - -Initially copied from https://github.com/kubevirt/kubevirt/blob/main/pkg/virtctl/ssh/terminal_unix.go -*/ - -package ssh - -import ( - "encoding/binary" - "os" - "os/signal" - "syscall" - - "golang.org/x/crypto/ssh" - "golang.org/x/term" -) - -func setupTerminal() (func(), error) { - fd := int(os.Stdin.Fd()) - - state, err := term.MakeRaw(fd) - if err != nil { - return nil, err - } - - return func() { - _ = term.Restore(fd, state) - }, nil -} - -func requestPty(session *ssh.Session) error { - w, h, err := term.GetSize(int(os.Stdin.Fd())) - if err != nil { - return err - } - - if err := session.RequestPty( - os.Getenv("TERM"), - h, w, - ssh.TerminalModes{}, - ); err != nil { - return err - } - - go resizeSessionOnWindowChange(session, os.Stdin.Fd()) - - return nil -} - -// resizeSessionOnWindowChange watches for SIGWINCH and refreshes the session with the new window size -func resizeSessionOnWindowChange(session *ssh.Session, _ uintptr) { - sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGWINCH) - - for range sigs { - _, _ = session.SendRequest("window-change", false, windowSizePayloadFor()) - } -} - -func windowSizePayloadFor() []byte { - w, h, err := term.GetSize(int(os.Stdin.Fd())) - if err != nil { - return buildWindowSizePayload(80, 24) - } - - return buildWindowSizePayload(w, h) -} - -func buildWindowSizePayload(width, height int) []byte { - size := make([]byte, 16) - binary.BigEndian.PutUint32(size, uint32(width)) - binary.BigEndian.PutUint32(size[4:], uint32(height)) - return size -} diff --git a/src/cli/internal/cmd/ssh/terminal_windows.go b/src/cli/internal/cmd/ssh/terminal_windows.go deleted file mode 100644 index 603337934a..0000000000 --- a/src/cli/internal/cmd/ssh/terminal_windows.go +++ /dev/null @@ -1,97 +0,0 @@ -//go:build windows - -/* -Copyright 2018 The KubeVirt Authors -Copyright 2024 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - -Initially copied from https://github.com/kubevirt/kubevirt/blob/main/pkg/virtctl/ssh/terminal_windows.go -*/ - -package ssh - -import ( - "os" - - "golang.org/x/crypto/ssh" - "golang.org/x/sys/windows" - "golang.org/x/term" -) - -func setupTerminal() (func(), error) { - fdIn := int(os.Stdin.Fd()) - fdOut := int(os.Stdout.Fd()) - handleIn := windows.Handle(fdIn) - handleOut := windows.Handle(fdOut) - - modeIn := uint32(0) - if err := windows.GetConsoleMode(handleIn, &modeIn); err != nil { - return nil, err - } - - modeOut := uint32(0) - if err := windows.GetConsoleMode(handleOut, &modeOut); err != nil { - return nil, err - } - - // Set the same modes as PowerShell/openssh-portable - // See https://github.com/PowerShell/openssh-portable/blob/latestw_all/contrib/win32/win32compat/console.c#L129 - // For Windows console modes see also https://docs.microsoft.com/en-us/windows/console/setconsolemode - // Disable unwanted modes - newModeIn := modeIn &^ (windows.ENABLE_LINE_INPUT | windows.ENABLE_ECHO_INPUT | - windows.ENABLE_PROCESSED_INPUT | windows.ENABLE_MOUSE_INPUT) - // Enable wanted modes - newModeIn |= (windows.ENABLE_WINDOW_INPUT | windows.ENABLE_VIRTUAL_TERMINAL_INPUT) - newModeOut := modeOut | windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING | - windows.DISABLE_NEWLINE_AUTO_RETURN - - if err := windows.SetConsoleMode(handleIn, newModeIn); err != nil { - return nil, err - } - if err := windows.SetConsoleMode(handleOut, newModeOut); err != nil { - // Try to restore saved input modes - windows.SetConsoleMode(handleIn, modeIn) - return nil, err - } - - return func() { - // Restore to initially saved modes - windows.SetConsoleMode(handleIn, modeIn) - windows.SetConsoleMode(handleOut, modeOut) - }, nil -} - -func requestPty(session *ssh.Session) error { - w, h, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil { - return err - } - - // Do the same as PowerShell/openssh-portable - // See https://github.com/PowerShell/openssh-portable/blob/latestw_all/contrib/win32/win32compat/wmain_common.c#L58 - term := os.Getenv("TERM") - if term == "" { - term = "xterm-256color" - } - - if err := session.RequestPty( - term, - h, w, - ssh.TerminalModes{}, - ); err != nil { - return err - } - - return nil -} diff --git a/src/cli/internal/cmd/ssh/wrapped.go b/src/cli/internal/cmd/ssh/wrapped.go index 78b1d0c108..9672d40260 100644 --- a/src/cli/internal/cmd/ssh/wrapped.go +++ b/src/cli/internal/cmd/ssh/wrapped.go @@ -32,19 +32,36 @@ import ( ) func addLocalSSHClientFlags(flagset *pflag.FlagSet, opts *SSHOptions) { - flagset.StringArrayVarP(&opts.AdditionalSSHLocalOptions, additionalOpts, additionalOptsShort, opts.AdditionalSSHLocalOptions, - "Additional options to be passed to the ssh client if --local-ssh=true is set") + flagset.StringArrayVarP(&opts.AdditionalSSHOptions, additionalOpts, additionalOptsShort, opts.AdditionalSSHOptions, + "Additional options to be passed to the local SSH/SCP client") + flagset.StringArrayVar(&opts.AdditionalSSHLocalOptions, additionalLocalOpts, opts.AdditionalSSHLocalOptions, + "Deprecated: use --ssh-opts instead") flagset.BoolVar(&opts.WrapLocalSSH, wrapLocalSSHFlag, opts.WrapLocalSSH, - "Use the SSH/SCP client available on your system by using this command as ProxyCommand; Default is false: use embedded SSH client with limited capabilities") + "Deprecated: local SSH/SCP client is always used") +} + +func WarnDeprecatedSSHFlags(cmd *cobra.Command) { + if cmd.Flags().Changed(wrapLocalSSHFlag) { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: --%s is deprecated and has no effect; local SSH/SCP client is always used.\n", wrapLocalSSHFlag) + } + if cmd.Flags().Changed(additionalLocalOpts) { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: --%s is deprecated; use --%s instead.\n", additionalLocalOpts, additionalOpts) + } } func RunLocalClient(cmd *cobra.Command, namespace, name string, options *SSHOptions, clientArgs []string) error { args := []string{"-o"} args = append(args, buildProxyCommandOption(cmd, namespace, name, options.SSHPort)) + if len(options.AdditionalSSHOptions) > 0 { + args = append(args, options.AdditionalSSHOptions...) + } if len(options.AdditionalSSHLocalOptions) > 0 { args = append(args, options.AdditionalSSHLocalOptions...) } + if options.KnownHostsFilePathProvided { + args = append(args, "-o", "UserKnownHostsFile="+options.KnownHostsFilePath) + } if options.IdentityFilePathProvided { args = append(args, "-i", options.IdentityFilePath) } diff --git a/test/e2e/internal/d8/d8.go b/test/e2e/internal/d8/d8.go index d6e22450b9..1404c74bde 100644 --- a/test/e2e/internal/d8/d8.go +++ b/test/e2e/internal/d8/d8.go @@ -100,10 +100,10 @@ func (v D8VirtualizationCMD) SSHCommand(vmName, command string, opts SSHOptions) timeout = opts.Timeout } - localSSHOpts := "--local-ssh-opts='-o StrictHostKeyChecking=no' --local-ssh-opts='-o UserKnownHostsFile=/dev/null' --local-ssh-opts='-o LogLevel=ERROR'" - localSSHOpts = fmt.Sprintf("%s --local-ssh-opts='-o ServerAliveInterval=15' --local-ssh-opts='-o ServerAliveCountMax=8' --local-ssh-opts='-o ConnectTimeout=10'", localSSHOpts) + sshOpts := "--ssh-opts='-o StrictHostKeyChecking=no' --ssh-opts='-o UserKnownHostsFile=/dev/null' --ssh-opts='-o LogLevel=ERROR'" + sshOpts = fmt.Sprintf("%s --ssh-opts='-o ServerAliveInterval=15' --ssh-opts='-o ServerAliveCountMax=8' --ssh-opts='-o ConnectTimeout=10'", sshOpts) - cmd := fmt.Sprintf("%s ssh %s -c '%s' --local-ssh=true %s", v.cmd, vmName, command, localSSHOpts) + cmd := fmt.Sprintf("%s ssh %s -c '%s' %s", v.cmd, vmName, command, sshOpts) cmd = v.addNamespace(cmd, opts.Namespace) if opts.Username != "" {