From e1e059a123a8f206596e3a14a23a5a911ec09bc9 Mon Sep 17 00:00:00 2001 From: Dmitry Lopatin Date: Wed, 3 Jun 2026 16:45:43 +0300 Subject: [PATCH 1/2] 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 | 18 +- src/cli/internal/cmd/scp/scp_test.go | 39 ++++ src/cli/internal/cmd/ssh/knownhosts.go | 108 --------- src/cli/internal/cmd/ssh/native.go | 221 ------------------- src/cli/internal/cmd/ssh/ssh.go | 112 ++++++---- src/cli/internal/cmd/ssh/ssh_test.go | 184 +++++++++++++++ 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 | 38 +++- src/cli/internal/templates/templates.go | 21 ++ 16 files changed, 370 insertions(+), 718 deletions(-) delete mode 100644 src/cli/internal/cmd/scp/native.go create mode 100644 src/cli/internal/cmd/scp/scp_test.go delete mode 100644 src/cli/internal/cmd/ssh/knownhosts.go delete mode 100644 src/cli/internal/cmd/ssh/native.go create mode 100644 src/cli/internal/cmd/ssh/ssh_test.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 7500d47a4d..ac6378d332 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: @@ -2338,7 +2338,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 acd5131a8a..76db47948e 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" ``` Пример вывода: @@ -2355,7 +2355,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 1312feb4d5..10531a98cd 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..72c19b7963 100644 --- a/src/cli/internal/cmd/scp/scp.go +++ b/src/cli/internal/cmd/scp/scp.go @@ -22,7 +22,6 @@ package scp import ( "github.com/spf13/cobra" - "github.com/deckhouse/virtualization/src/cli/internal/clientconfig" "github.com/deckhouse/virtualization/src/cli/internal/cmd/ssh" "github.com/deckhouse/virtualization/src/cli/internal/templates" ) @@ -68,25 +67,24 @@ func (o *SCP) Run(cmd *cobra.Command, args []string) error { return err } - client, defaultNamespace, _, err := clientconfig.ClientAndNamespaceFromContext(cmd.Context()) - if err != nil { - return err - } + // The Kubernetes client is not required: see SSH.Run for rationale. + // If the API server is reachable we honour the configured namespace, + // otherwise we fall back to "default". + defaultNamespace := ssh.ResolveDefaultNamespace(cmd) local, remote, toRemote, err := PrepareCommand(cmd, defaultNamespace, &o.options, args) if err != nil { 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/scp/scp_test.go b/src/cli/internal/cmd/scp/scp_test.go new file mode 100644 index 0000000000..1472ac9af8 --- /dev/null +++ b/src/cli/internal/cmd/scp/scp_test.go @@ -0,0 +1,39 @@ +/* +Copyright 2026 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. +*/ + +package scp + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSCP(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "SCP Command Suite") +} + +var _ = Describe("SCP", func() { + Describe("NewCommand", func() { + It("registers the new --ssh-args flag inherited from ssh.AddCommonSSHFlags", func() { + cmd := NewCommand() + Expect(cmd.Flags().Lookup("ssh-args")).NotTo(BeNil()) + Expect(cmd.Flags().Lookup("ssh-opts")).To(BeNil()) + }) + }) +}) 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..6c04699293 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 = "ssh-args" + 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 @@ -127,10 +130,10 @@ func NewCommand() *cobra.Command { } cmd := &cobra.Command{ - Use: "ssh [-n|--namespace NAMESPACE] VIRTUAL-MACHINE-NAME", + Use: "ssh [-n|--namespace NAMESPACE] VIRTUAL-MACHINE-NAME [-- COMMAND [ARGS]...]", Short: "Open a SSH connection to a virtual machine.", Example: usage(), - Args: templates.ExactArgs("ssh", 1), + Args: templates.MinimumArgs("ssh", 1), RunE: c.Run, } @@ -139,16 +142,16 @@ func NewCommand() *cobra.Command { cmd.Flags().StringVarP(&c.options.SSHUsername, usernameFlag, usernameFlagShort, c.options.SSHUsername, "Specify user to log into virtual machine; If unassigned, this will be empty and the SSH default will apply") cmd.Flags().StringVarP(&c.command, commandToExecute, commandToExecuteShort, c.command, - "Specify a command to execute in the VM.") + "Specify a command to execute in the VM. Equivalent to passing the command after --.") cmd.SetUsageTemplate(templates.UsageTemplate()) return cmd } 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,25 +164,52 @@ func (o *SSH) Run(cmd *cobra.Command, args []string) error { return err } - client, defaultNamespace, _, err := clientconfig.ClientAndNamespaceFromContext(cmd.Context()) - if err != nil { - return err + // The Kubernetes client is not required for the local SSH path: + // the local `ssh` binary reaches the VM through `d8 v port-forward` + // running as a ProxyCommand, which establishes its own client + // connection from inside the command. If the API server is reachable, + // use the configured namespace; otherwise fall back to "default" so + // the user can still point at a VM that has the namespace embedded + // in its target (e.g. `user@myvm.test`). + defaultNamespace := ResolveDefaultNamespace(cmd) + + // Anything passed after `--` is the command to execute on the VM. + // ArgsLenAtDash returns the number of positional args that appeared + // before the `--` separator; everything after it is appended to args. + if dashIdx := cmd.ArgsLenAtDash(); dashIdx != -1 && dashIdx < len(args) { + o.command = strings.Join(args[dashIdx:], " ") + args = args[:dashIdx] } + namespace, name, err := PrepareCommand(cmd, defaultNamespace, &o.options, args) if err != nil { 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) +} + +// clientAndNamespaceFromContext is overridden in tests. +var clientAndNamespaceFromContext = clientconfig.ClientAndNamespaceFromContext + +// ResolveDefaultNamespace returns the namespace from the active +// kubeconfig context if the API server is reachable, and "default" +// otherwise. A warning is emitted to stderr on fallback. +func ResolveDefaultNamespace(cmd *cobra.Command) string { + if _, ns, _, err := clientAndNamespaceFromContext(cmd.Context()); err == nil { + return ns + } + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), + "Warning: could not determine namespace from kubeconfig, using \"default\".\n") + return "default" } 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 { @@ -206,20 +236,20 @@ func usage() string { # Specify identity file: {{ProgramName}} ssh -n vms user@myvm -%s /some/path/id_rsa_for_myvm - # Run command instead of opening shell: + # Run a command on the VM (either via -c or by passing it after --): {{ProgramName}} ssh -n vms user@myvm -%s 'ls -la /' + {{ProgramName}} ssh -n vms user@myvm -- 'ls -la /' - # Connect using the local ssh binary found in $PATH: - {{ProgramName}} ssh --%s=true user@myvm + # Pass several options to the local ssh client in one go: + {{ProgramName}} ssh user@myvm --%s='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR' - # Specify additional options for local ssh: - {{ProgramName}} ssh user@myvm --%s=true --%s='-o StrictHostKeyChecking=no' --%s='-o UserKnownHostsFile=/dev/null' + # ...or repeat the flag for each option: + {{ProgramName}} ssh user@myvm --%s='-o StrictHostKeyChecking=no' --%s='-o UserKnownHostsFile=/dev/null' `, usernameFlag, identityFilePathFlagShort, commandToExecuteShort, - wrapLocalSSHFlag, - wrapLocalSSHFlag, + additionalOpts, additionalOpts, additionalOpts, ) diff --git a/src/cli/internal/cmd/ssh/ssh_test.go b/src/cli/internal/cmd/ssh/ssh_test.go new file mode 100644 index 0000000000..b92b046a2f --- /dev/null +++ b/src/cli/internal/cmd/ssh/ssh_test.go @@ -0,0 +1,184 @@ +/* +Copyright 2026 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. +*/ + +package ssh + +import ( + "bytes" + "context" + "errors" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" + + "github.com/deckhouse/virtualization/api/client/kubeclient" +) + +func TestSSH(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "SSH Command Suite") +} + +var _ = Describe("SSH", func() { + Describe("flattenSSHArgs", func() { + It("returns an empty slice for empty input", func() { + Expect(flattenSSHArgs(nil)).To(BeEmpty()) + Expect(flattenSSHArgs([]string{})).To(BeEmpty()) + }) + + It("splits a single flag value by whitespace", func() { + Expect(flattenSSHArgs([]string{ + "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR", + })).To(Equal([]string{ + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + })) + }) + + It("preserves each repeated flag as a separate entry", func() { + Expect(flattenSSHArgs([]string{ + "-o StrictHostKeyChecking=no", + "-o UserKnownHostsFile=/dev/null", + })).To(Equal([]string{ + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + })) + }) + + It("mixes single-value and repeated flags keeping order", func() { + Expect(flattenSSHArgs([]string{ + "-o X -o Y", + "-o Z", + "-o A -o B", + })).To(Equal([]string{ + "-o", "X", "-o", "Y", + "-o", "Z", + "-o", "A", "-o", "B", + })) + }) + + It("collapses runs of spaces and tabs", func() { + Expect(flattenSSHArgs([]string{" -o X \t -o Y "})).To(Equal([]string{ + "-o", "X", "-o", "Y", + })) + }) + }) + + Describe("WarnDeprecatedSSHFlags", func() { + var captured *bytes.Buffer + + BeforeEach(func() { + captured = &bytes.Buffer{} + }) + + runWith := func(setup func(*cobra.Command)) string { + cmd := NewCommand() + cmd.SetErr(captured) + setup(cmd) + WarnDeprecatedSSHFlags(cmd) + return captured.String() + } + + It("emits a warning when --local-ssh is set", func() { + out := runWith(func(cmd *cobra.Command) { + Expect(cmd.Flags().Set("local-ssh", "true")).To(Succeed()) + }) + Expect(out).To(ContainSubstring("--local-ssh is deprecated")) + }) + + It("emits a warning when --local-ssh-opts is set and points users to --ssh-args", func() { + out := runWith(func(cmd *cobra.Command) { + Expect(cmd.Flags().Set("local-ssh-opts", "-o X")).To(Succeed()) + }) + Expect(out).To(ContainSubstring("--local-ssh-opts is deprecated")) + Expect(out).To(ContainSubstring("--ssh-args")) + }) + + It("stays silent for --command and -c, since they remain supported", func() { + out := runWith(func(cmd *cobra.Command) { + Expect(cmd.Flags().Set("command", "ls")).To(Succeed()) + }) + Expect(out).To(BeEmpty()) + }) + + It("stays silent for the new --ssh-args flag", func() { + out := runWith(func(cmd *cobra.Command) { + Expect(cmd.Flags().Set("ssh-args", "-o StrictHostKeyChecking=no")).To(Succeed()) + }) + Expect(out).To(BeEmpty()) + }) + + It("stays silent when no flags are changed", func() { + out := runWith(func(_ *cobra.Command) {}) + Expect(out).To(BeEmpty()) + }) + }) + + Describe("NewCommand", func() { + It("registers the new --ssh-args flag and drops the old --ssh-opts", func() { + cmd := NewCommand() + Expect(cmd.Flags().Lookup("ssh-args")).NotTo(BeNil()) + Expect(cmd.Flags().Lookup("ssh-opts")).To(BeNil()) + }) + + It("exposes --command and its -c short alias as supported flags", func() { + cmd := NewCommand() + Expect(cmd.Flags().Lookup("command")).NotTo(BeNil()) + Expect(cmd.Flags().ShorthandLookup("c")).NotTo(BeNil()) + }) + }) + + Describe("ResolveDefaultNamespace", func() { + var ( + original func(context.Context) (kubeclient.Client, string, bool, error) + cmdStderr *bytes.Buffer + ) + + BeforeEach(func() { + original = clientAndNamespaceFromContext + cmdStderr = &bytes.Buffer{} + }) + + AfterEach(func() { + clientAndNamespaceFromContext = original + }) + + newCmd := func() *cobra.Command { + cmd := NewCommand() + cmd.SetErr(cmdStderr) + return cmd + } + + It("uses the namespace from the kubeconfig context when the API is reachable", func() { + clientAndNamespaceFromContext = func(_ context.Context) (kubeclient.Client, string, bool, error) { + return nil, "vm-team", false, nil + } + Expect(ResolveDefaultNamespace(newCmd())).To(Equal("vm-team")) + Expect(cmdStderr.String()).To(BeEmpty()) + }) + + It("falls back to \"default\" and warns when the client cannot be obtained", func() { + clientAndNamespaceFromContext = func(_ context.Context) (kubeclient.Client, string, bool, error) { + return nil, "", false, errors.New("no kubeconfig") + } + Expect(ResolveDefaultNamespace(newCmd())).To(Equal("default")) + Expect(cmdStderr.String()).To(ContainSubstring(`using "default"`)) + }) + }) +}) 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..cdfeb86fbe 100644 --- a/src/cli/internal/cmd/ssh/wrapped.go +++ b/src/cli/internal/cmd/ssh/wrapped.go @@ -32,18 +32,48 @@ 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.StringArrayVar(&opts.AdditionalSSHOptions, additionalOpts, opts.AdditionalSSHOptions, + "Additional options to be passed to the local SSH/SCP client. "+ + "May be repeated, or a single value may contain several options separated by spaces "+ + "(e.g. --ssh-args='-o X -o Y').") + flagset.StringArrayVar(&opts.AdditionalSSHLocalOptions, additionalLocalOpts, opts.AdditionalSSHLocalOptions, + "Deprecated: use --ssh-args 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) + } +} + +// flattenSSHArgs splits each entry of additionalArgs by whitespace and +// returns a single flat slice. This allows values such as +// `--ssh-args='-o X -o Y'` to be passed as a single flag occurrence. +func flattenSSHArgs(additionalArgs []string) []string { + var flat []string + for _, entry := range additionalArgs { + flat = append(flat, strings.Fields(entry)...) + } + return flat } 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, flattenSSHArgs(options.AdditionalSSHOptions)...) + } if len(options.AdditionalSSHLocalOptions) > 0 { - args = append(args, options.AdditionalSSHLocalOptions...) + args = append(args, flattenSSHArgs(options.AdditionalSSHLocalOptions)...) + } + if options.KnownHostsFilePathProvided { + args = append(args, "-o", "UserKnownHostsFile="+options.KnownHostsFilePath) } if options.IdentityFilePathProvided { args = append(args, "-i", options.IdentityFilePath) diff --git a/src/cli/internal/templates/templates.go b/src/cli/internal/templates/templates.go index a3f170b164..1408f56a1b 100644 --- a/src/cli/internal/templates/templates.go +++ b/src/cli/internal/templates/templates.go @@ -97,6 +97,27 @@ func ExactArgs(nameOfCommand string, n int) cobra.PositionalArgs { } } +// MinimumArgs validate the minimum number of input parameters. +// Any additional positional arguments after the required prefix are allowed +// (for example, everything passed after `--`). +func MinimumArgs(nameOfCommand string, n int) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if len(args) >= n { + return nil + } + err := errors.New("argument validation failed") + _, e := fmt.Fprintf(os.Stderr, "fatal: Number of input parameters is incorrect, %s accepts at least %d arg(s), received %d\n\n", nameOfCommand, n, len(args)) + if e != nil { + err = errors.Join(err, e) + } + e = cmd.Help() + if e != nil { + err = errors.Join(err, e) + } + return err + } +} + // PrintWarningForPausedVM prints warning message if VM is paused func PrintWarningForPausedVM(ctx context.Context, virtCli kubeclient.Client, vmName, namespace string) { vm, err := virtCli.VirtualMachines(namespace).Get(ctx, vmName, metav1.GetOptions{}) From 14ed0ab50c4959d2ea013071701889c596b5de71 Mon Sep 17 00:00:00 2001 From: Dmitry Lopatin Date: Mon, 15 Jun 2026 11:02:40 +0300 Subject: [PATCH 2/2] ++ fix ResolveDefaultNamespace Signed-off-by: Dmitry Lopatin --- src/cli/internal/clientconfig/clientconfig.go | 11 +++ src/cli/internal/cmd/scp/scp.go | 9 +- src/cli/internal/cmd/ssh/ssh.go | 27 +----- src/cli/internal/cmd/ssh/ssh_test.go | 94 ++++++++++++------- 4 files changed, 80 insertions(+), 61 deletions(-) diff --git a/src/cli/internal/clientconfig/clientconfig.go b/src/cli/internal/clientconfig/clientconfig.go index 2b502366ca..5a74ae848d 100644 --- a/src/cli/internal/clientconfig/clientconfig.go +++ b/src/cli/internal/clientconfig/clientconfig.go @@ -51,6 +51,17 @@ func ClientAndNamespaceFromContext(ctx context.Context) (client kubeclient.Clien return client, namespace, overridden, nil } +// NamespaceFromContext returns the namespace from the active kubeconfig +// context. +func NamespaceFromContext(ctx context.Context) (string, error) { + clientConfig, ok := ctx.Value(clientConfigKey).(clientcmd.ClientConfig) + if !ok { + return "", fmt.Errorf("unable to get client config from context") + } + namespace, _, err := clientConfig.Namespace() + return namespace, err +} + func GetRESTConfig(ctx context.Context) (*rest.Config, error) { clientConfig, ok := ctx.Value(clientConfigKey).(clientcmd.ClientConfig) if !ok { diff --git a/src/cli/internal/cmd/scp/scp.go b/src/cli/internal/cmd/scp/scp.go index 72c19b7963..0f6062d936 100644 --- a/src/cli/internal/cmd/scp/scp.go +++ b/src/cli/internal/cmd/scp/scp.go @@ -22,6 +22,7 @@ package scp import ( "github.com/spf13/cobra" + "github.com/deckhouse/virtualization/src/cli/internal/clientconfig" "github.com/deckhouse/virtualization/src/cli/internal/cmd/ssh" "github.com/deckhouse/virtualization/src/cli/internal/templates" ) @@ -67,10 +68,10 @@ func (o *SCP) Run(cmd *cobra.Command, args []string) error { return err } - // The Kubernetes client is not required: see SSH.Run for rationale. - // If the API server is reachable we honour the configured namespace, - // otherwise we fall back to "default". - defaultNamespace := ssh.ResolveDefaultNamespace(cmd) + defaultNamespace, err := clientconfig.NamespaceFromContext(cmd.Context()) + if err != nil { + return err + } local, remote, toRemote, err := PrepareCommand(cmd, defaultNamespace, &o.options, args) if err != nil { return err diff --git a/src/cli/internal/cmd/ssh/ssh.go b/src/cli/internal/cmd/ssh/ssh.go index 6c04699293..6be22a06a7 100644 --- a/src/cli/internal/cmd/ssh/ssh.go +++ b/src/cli/internal/cmd/ssh/ssh.go @@ -164,14 +164,10 @@ func (o *SSH) Run(cmd *cobra.Command, args []string) error { return err } - // The Kubernetes client is not required for the local SSH path: - // the local `ssh` binary reaches the VM through `d8 v port-forward` - // running as a ProxyCommand, which establishes its own client - // connection from inside the command. If the API server is reachable, - // use the configured namespace; otherwise fall back to "default" so - // the user can still point at a VM that has the namespace embedded - // in its target (e.g. `user@myvm.test`). - defaultNamespace := ResolveDefaultNamespace(cmd) + defaultNamespace, err := clientconfig.NamespaceFromContext(cmd.Context()) + if err != nil { + return err + } // Anything passed after `--` is the command to execute on the VM. // ArgsLenAtDash returns the number of positional args that appeared @@ -192,21 +188,6 @@ func (o *SSH) Run(cmd *cobra.Command, args []string) error { return RunLocalClient(cmd, namespace, name, &o.options, clientArgs) } -// clientAndNamespaceFromContext is overridden in tests. -var clientAndNamespaceFromContext = clientconfig.ClientAndNamespaceFromContext - -// ResolveDefaultNamespace returns the namespace from the active -// kubeconfig context if the API server is reachable, and "default" -// otherwise. A warning is emitted to stderr on fallback. -func ResolveDefaultNamespace(cmd *cobra.Command) string { - if _, ns, _, err := clientAndNamespaceFromContext(cmd.Context()); err == nil { - return ns - } - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), - "Warning: could not determine namespace from kubeconfig, using \"default\".\n") - return "default" -} - 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) diff --git a/src/cli/internal/cmd/ssh/ssh_test.go b/src/cli/internal/cmd/ssh/ssh_test.go index b92b046a2f..d58b9fe3ef 100644 --- a/src/cli/internal/cmd/ssh/ssh_test.go +++ b/src/cli/internal/cmd/ssh/ssh_test.go @@ -19,14 +19,14 @@ package ssh import ( "bytes" "context" - "errors" "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/spf13/cobra" + "k8s.io/client-go/tools/clientcmd" - "github.com/deckhouse/virtualization/api/client/kubeclient" + "github.com/deckhouse/virtualization/src/cli/internal/clientconfig" ) func TestSSH(t *testing.T) { @@ -145,40 +145,66 @@ var _ = Describe("SSH", func() { }) Describe("ResolveDefaultNamespace", func() { - var ( - original func(context.Context) (kubeclient.Client, string, bool, error) - cmdStderr *bytes.Buffer - ) - - BeforeEach(func() { - original = clientAndNamespaceFromContext - cmdStderr = &bytes.Buffer{} - }) - - AfterEach(func() { - clientAndNamespaceFromContext = original - }) - - newCmd := func() *cobra.Command { - cmd := NewCommand() - cmd.SetErr(cmdStderr) - return cmd + newCtx := func(ns string) context.Context { + yaml := []byte(` +apiVersion: v1 +kind: Config +current-context: test +clusters: +- name: test-cluster + cluster: + server: https://localhost:443 +contexts: +- name: test + context: + cluster: test-cluster + user: test-user + namespace: ` + ns + ` +users: +- name: test-user +`) + clientConfig, err := clientcmd.NewClientConfigFromBytes(yaml) + Expect(err).NotTo(HaveOccurred()) + return clientconfig.NewContext(context.Background(), clientConfig) } - It("uses the namespace from the kubeconfig context when the API is reachable", func() { - clientAndNamespaceFromContext = func(_ context.Context) (kubeclient.Client, string, bool, error) { - return nil, "vm-team", false, nil - } - Expect(ResolveDefaultNamespace(newCmd())).To(Equal("vm-team")) - Expect(cmdStderr.String()).To(BeEmpty()) - }) - - It("falls back to \"default\" and warns when the client cannot be obtained", func() { - clientAndNamespaceFromContext = func(_ context.Context) (kubeclient.Client, string, bool, error) { - return nil, "", false, errors.New("no kubeconfig") - } - Expect(ResolveDefaultNamespace(newCmd())).To(Equal("default")) - Expect(cmdStderr.String()).To(ContainSubstring(`using "default"`)) + It("uses the namespace from the kubeconfig context", func() { + ctx := newCtx("vm-team") + ns, err := clientconfig.NamespaceFromContext(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(ns).To(Equal("vm-team")) + }) + + It("returns \"default\" when no namespace is configured in the kubeconfig", func() { + yaml := []byte(` +apiVersion: v1 +kind: Config +current-context: test +clusters: +- name: test-cluster + cluster: + server: https://localhost:443 +contexts: +- name: test + context: + cluster: test-cluster + user: test-user +users: +- name: test-user +`) + clientConfig, err := clientcmd.NewClientConfigFromBytes(yaml) + Expect(err).NotTo(HaveOccurred()) + ctx := clientconfig.NewContext(context.Background(), clientConfig) + + ns, err := clientconfig.NamespaceFromContext(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(ns).To(Equal("default")) + }) + + It("returns an error when no client config is in the context", func() { + ns, err := clientconfig.NamespaceFromContext(context.Background()) + Expect(err).To(HaveOccurred()) + Expect(ns).To(BeEmpty()) }) }) })