github.phpd.cn/cilium/cilium@v1.6.12/test/helpers/ssh_command.go (about)

     1  // Copyright 2017-2019 Authors of Cilium
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package helpers
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"fmt"
    21  	"io"
    22  	"io/ioutil"
    23  	"net"
    24  	"os"
    25  	"strconv"
    26  	"time"
    27  
    28  	"github.com/kevinburke/ssh_config"
    29  	"golang.org/x/crypto/ssh"
    30  	"golang.org/x/crypto/ssh/agent"
    31  )
    32  
    33  // SSHCommand stores the data associated with executing a command.
    34  // TODO: this is poorly named in that it's not related to a command only
    35  // ran over SSH - rename this.
    36  type SSHCommand struct {
    37  	// TODO: path is not a clear name - rename to something more clear.
    38  	Path   string
    39  	Env    []string
    40  	Stdin  io.Reader
    41  	Stdout io.Writer
    42  	Stderr io.Writer
    43  }
    44  
    45  // SSHClient stores the information needed to SSH into a remote location for
    46  // running tests.
    47  type SSHClient struct {
    48  	Config *ssh.ClientConfig // ssh client configuration information.
    49  	Host   string            // Ip/Host from the target virtualserver
    50  	Port   int               // Port to connect to the target server
    51  	client *ssh.Client       // Client implements a traditional SSH client that supports shells,
    52  	// subprocesses, TCP port/streamlocal forwarding and tunneled dialing.
    53  }
    54  
    55  // GetHostPort returns the host port representation of the ssh client
    56  func (cli *SSHClient) GetHostPort() string {
    57  	return net.JoinHostPort(cli.Host, strconv.Itoa(cli.Port))
    58  }
    59  
    60  // SSHConfig contains metadata for an SSH session.
    61  type SSHConfig struct {
    62  	target       string
    63  	host         string
    64  	user         string
    65  	port         int
    66  	identityFile string
    67  }
    68  
    69  // SSHConfigs maps the name of a host (VM) to its corresponding SSHConfiguration
    70  type SSHConfigs map[string]*SSHConfig
    71  
    72  // GetSSHClient initializes an SSHClient based on the provided SSHConfig
    73  func (cfg *SSHConfig) GetSSHClient() *SSHClient {
    74  	sshConfig := &ssh.ClientConfig{
    75  		User: cfg.user,
    76  		Auth: []ssh.AuthMethod{
    77  			cfg.GetSSHAgent(),
    78  		},
    79  		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
    80  		Timeout:         15 * time.Second,
    81  	}
    82  
    83  	return &SSHClient{
    84  		Config: sshConfig,
    85  		Host:   cfg.host,
    86  		Port:   cfg.port,
    87  	}
    88  }
    89  
    90  func (client *SSHClient) String() string {
    91  	return fmt.Sprintf("host: %s, port: %d, user: %s", client.Host, client.Port, client.Config.User)
    92  }
    93  
    94  func (cfg *SSHConfig) String() string {
    95  	return fmt.Sprintf("target: %s, host: %s, port %d, user, %s, identityFile: %s", cfg.target, cfg.host, cfg.port, cfg.user, cfg.identityFile)
    96  }
    97  
    98  // GetSSHAgent returns the ssh.AuthMethod corresponding to SSHConfig cfg.
    99  func (cfg *SSHConfig) GetSSHAgent() ssh.AuthMethod {
   100  	key, err := ioutil.ReadFile(cfg.identityFile)
   101  	if err != nil {
   102  		log.Fatalf("unable to retrieve ssh-key on target '%s': %s", cfg.target, err)
   103  	}
   104  
   105  	signer, err := ssh.ParsePrivateKey(key)
   106  	if err != nil {
   107  		log.Fatalf("unable to parse private key on target '%s': %s", cfg.target, err)
   108  	}
   109  	return ssh.PublicKeys(signer)
   110  }
   111  
   112  // ImportSSHconfig imports the SSH configuration stored at the provided path.
   113  // Returns an error if the SSH configuration could not be instantiated.
   114  func ImportSSHconfig(config []byte) (SSHConfigs, error) {
   115  	result := make(SSHConfigs)
   116  	cfg, err := ssh_config.Decode(bytes.NewBuffer(config))
   117  	if err != nil {
   118  		return nil, err
   119  	}
   120  
   121  	for _, host := range cfg.Hosts {
   122  		key := host.Patterns[0].String()
   123  		if key == "*" {
   124  			continue
   125  		}
   126  		port, _ := cfg.Get(key, "Port")
   127  		hostConfig := SSHConfig{target: key}
   128  		hostConfig.host, _ = cfg.Get(key, "Hostname")
   129  		hostConfig.identityFile, _ = cfg.Get(key, "identityFile")
   130  		hostConfig.user, _ = cfg.Get(key, "User")
   131  		hostConfig.port, _ = strconv.Atoi(port)
   132  		result[key] = &hostConfig
   133  	}
   134  	return result, nil
   135  }
   136  
   137  // copyWait runs an instance of io.Copy() in a goroutine, and returns a channel
   138  // to receive the error result.
   139  func copyWait(dst io.Writer, src io.Reader) chan error {
   140  	c := make(chan error, 1)
   141  	go func() {
   142  		_, err := io.Copy(dst, src)
   143  		c <- err
   144  	}()
   145  	return c
   146  }
   147  
   148  // runCommand runs the specified command on the provided SSH session, and
   149  // gathers both of the sterr and stdout output into the writers provided by
   150  // cmd. Returns whether the command was run and an optional error.
   151  // Returns nil when the command completes successfully and all stderr,
   152  // stdout output has been written. Returns an error otherwise.
   153  func runCommand(session *ssh.Session, cmd *SSHCommand) (bool, error) {
   154  	stderr, err := session.StderrPipe()
   155  	if err != nil {
   156  		return false, fmt.Errorf("Unable to setup stderr for session: %v", err)
   157  	}
   158  	errChan := copyWait(cmd.Stderr, stderr)
   159  
   160  	stdout, err := session.StdoutPipe()
   161  	if err != nil {
   162  		return false, fmt.Errorf("Unable to setup stdout for session: %v", err)
   163  	}
   164  	outChan := copyWait(cmd.Stdout, stdout)
   165  
   166  	if err = session.Run(cmd.Path); err != nil {
   167  		return false, err
   168  	}
   169  
   170  	if err = <-errChan; err != nil {
   171  		return true, err
   172  	}
   173  	if err = <-outChan; err != nil {
   174  		return true, err
   175  	}
   176  	return true, nil
   177  }
   178  
   179  // RunCommand runs a SSHCommand using SSHClient client. The returned error is
   180  // nil if the command runs, has no problems copying stdin, stdout, and stderr,
   181  // and exits with a zero exit status.
   182  func (client *SSHClient) RunCommand(cmd *SSHCommand) error {
   183  	session, err := client.newSession()
   184  	if err != nil {
   185  		return err
   186  	}
   187  	defer session.Close()
   188  
   189  	_, err = runCommand(session, cmd)
   190  	return err
   191  }
   192  
   193  // RunCommandInBackground runs an SSH command in a similar way to
   194  // RunCommandContext, but with a context which allows the command to be
   195  // cancelled at any time. When cancel is called the error of the command is
   196  // returned instead the context error.
   197  func (client *SSHClient) RunCommandInBackground(ctx context.Context, cmd *SSHCommand) error {
   198  	if ctx == nil {
   199  		panic("nil context provided to RunCommandInBackground()")
   200  	}
   201  
   202  	session, err := client.newSession()
   203  	if err != nil {
   204  		return err
   205  	}
   206  	defer session.Close()
   207  
   208  	modes := ssh.TerminalModes{
   209  		ssh.ECHO:          1,     // enable echoing
   210  		ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud
   211  		ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud
   212  	}
   213  	session.RequestPty("xterm-256color", 80, 80, modes)
   214  
   215  	stdin, err := session.StdinPipe()
   216  	if err != nil {
   217  		log.Errorf("Could not get stdin: %s", err)
   218  	}
   219  
   220  	go func() {
   221  		select {
   222  		case <-ctx.Done():
   223  			_, err := stdin.Write([]byte{3})
   224  			if err != nil {
   225  				log.Errorf("write ^C error: %s", err)
   226  			}
   227  			err = session.Wait()
   228  			if err != nil {
   229  				log.Errorf("wait error: %s", err)
   230  			}
   231  			if err = session.Signal(ssh.SIGHUP); err != nil {
   232  				log.Errorf("failed to kill command: %s", err)
   233  			}
   234  			if err = session.Close(); err != nil {
   235  				log.Errorf("failed to close session: %s", err)
   236  			}
   237  		}
   238  	}()
   239  	_, err = runCommand(session, cmd)
   240  	return err
   241  }
   242  
   243  // RunCommandContext runs an SSH command in a similar way to RunCommand but with
   244  // a context. If context is canceled it will return the error of that given
   245  // context.
   246  func (client *SSHClient) RunCommandContext(ctx context.Context, cmd *SSHCommand) error {
   247  	if ctx == nil {
   248  		panic("nil context provided to RunCommandContext()")
   249  	}
   250  
   251  	var (
   252  		session        *ssh.Session
   253  		sessionErrChan = make(chan error, 1)
   254  	)
   255  
   256  	go func() {
   257  		var sessionErr error
   258  
   259  		// This may block depending on the state of the setup tests are being
   260  		// ran against. As a result, these goroutines may leak, but the logic
   261  		// below will fail and propagate to the rest of the CI framework, which
   262  		// will error out anyway. It's better to leak in really bad cases since
   263  		// the CI will fail anyway. Unfortunately, the golang SSH library does
   264  		// not provide a way to propagate context through to creating sessions.
   265  
   266  		// Note that this is a closure on the session variable!
   267  		session, sessionErr = client.newSession()
   268  		if sessionErr != nil {
   269  			log.Infof("error creating session: %s", sessionErr)
   270  			sessionErrChan <- sessionErr
   271  			return
   272  		}
   273  
   274  		_, runErr := runCommand(session, cmd)
   275  		sessionErrChan <- runErr
   276  	}()
   277  
   278  	select {
   279  	case asyncErr := <-sessionErrChan:
   280  		return asyncErr
   281  	case <-ctx.Done():
   282  		if session != nil {
   283  			log.Warning("sending SIGHUP to session due to canceled context")
   284  			if err := session.Signal(ssh.SIGHUP); err != nil {
   285  				log.Errorf("failed to kill command when context is canceled: %s", err)
   286  			}
   287  			if closeErr := session.Close(); closeErr != nil {
   288  				log.WithError(closeErr).Error("failed to close session")
   289  			}
   290  		} else {
   291  			log.Error("timeout reached; no session was able to be created")
   292  		}
   293  		return ctx.Err()
   294  	}
   295  }
   296  
   297  func (client *SSHClient) newSession() (*ssh.Session, error) {
   298  	var connection *ssh.Client
   299  	var err error
   300  
   301  	if client.client != nil {
   302  		connection = client.client
   303  	} else {
   304  		connection, err = ssh.Dial(
   305  			"tcp",
   306  			fmt.Sprintf("%s:%d", client.Host, client.Port),
   307  			client.Config)
   308  
   309  		if err != nil {
   310  			return nil, fmt.Errorf("failed to dial: %s", err)
   311  		}
   312  		client.client = connection
   313  	}
   314  
   315  	session, err := connection.NewSession()
   316  	if err != nil {
   317  		return nil, fmt.Errorf("failed to create session: %s", err)
   318  	}
   319  
   320  	return session, nil
   321  }
   322  
   323  // SSHAgent returns the ssh.Authmethod using the Public keys. Returns nil if
   324  // a connection to SSH_AUTH_SHOCK does not succeed.
   325  func SSHAgent() ssh.AuthMethod {
   326  	if sshAgent, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")); err == nil {
   327  		return ssh.PublicKeysCallback(agent.NewClient(sshAgent).Signers)
   328  	}
   329  	return nil
   330  }
   331  
   332  // GetSSHClient initializes an SSHClient for the specified host/port/user
   333  // combination.
   334  func GetSSHClient(host string, port int, user string) *SSHClient {
   335  
   336  	sshConfig := &ssh.ClientConfig{
   337  		User: user,
   338  		Auth: []ssh.AuthMethod{
   339  			SSHAgent(),
   340  		},
   341  		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
   342  		Timeout:         15 * time.Second,
   343  	}
   344  
   345  	return &SSHClient{
   346  		Config: sshConfig,
   347  		Host:   host,
   348  		Port:   port,
   349  	}
   350  
   351  }