github.com/iaas-resource-provision/iaas-rpc@v1.0.7-0.20211021023331-ed21f798c408/internal/communicator/ssh/communicator.go (about) 1 package ssh 2 3 import ( 4 "bufio" 5 "bytes" 6 "context" 7 "errors" 8 "fmt" 9 "io" 10 "io/ioutil" 11 "log" 12 "math/rand" 13 "net" 14 "os" 15 "path/filepath" 16 "strconv" 17 "strings" 18 "sync" 19 "time" 20 21 "github.com/hashicorp/errwrap" 22 "github.com/iaas-resource-provision/iaas-rpc/internal/communicator/remote" 23 "github.com/iaas-resource-provision/iaas-rpc/internal/provisioners" 24 "github.com/zclconf/go-cty/cty" 25 "golang.org/x/crypto/ssh" 26 "golang.org/x/crypto/ssh/agent" 27 28 _ "github.com/iaas-resource-provision/iaas-rpc/internal/logging" 29 ) 30 31 const ( 32 // DefaultShebang is added at the top of a SSH script file 33 DefaultShebang = "#!/bin/sh\n" 34 ) 35 36 var ( 37 // randShared is a global random generator object that is shared. This must be 38 // shared since it is seeded by the current time and creating multiple can 39 // result in the same values. By using a shared RNG we assure different numbers 40 // per call. 41 randLock sync.Mutex 42 randShared *rand.Rand 43 44 // enable ssh keeplive probes by default 45 keepAliveInterval = 2 * time.Second 46 47 // max time to wait for for a KeepAlive response before considering the 48 // connection to be dead. 49 maxKeepAliveDelay = 120 * time.Second 50 ) 51 52 // Communicator represents the SSH communicator 53 type Communicator struct { 54 connInfo *connectionInfo 55 client *ssh.Client 56 config *sshConfig 57 conn net.Conn 58 cancelKeepAlive context.CancelFunc 59 60 lock sync.Mutex 61 } 62 63 type sshConfig struct { 64 // The configuration of the Go SSH connection 65 config *ssh.ClientConfig 66 67 // connection returns a new connection. The current connection 68 // in use will be closed as part of the Close method, or in the 69 // case an error occurs. 70 connection func() (net.Conn, error) 71 72 // noPty, if true, will not request a pty from the remote end. 73 noPty bool 74 75 // sshAgent is a struct surrounding the agent.Agent client and the net.Conn 76 // to the SSH Agent. It is nil if no SSH agent is configured 77 sshAgent *sshAgent 78 } 79 80 type fatalError struct { 81 error 82 } 83 84 func (e fatalError) FatalError() error { 85 return e.error 86 } 87 88 // New creates a new communicator implementation over SSH. 89 func New(v cty.Value) (*Communicator, error) { 90 connInfo, err := parseConnectionInfo(v) 91 if err != nil { 92 return nil, err 93 } 94 95 config, err := prepareSSHConfig(connInfo) 96 if err != nil { 97 return nil, err 98 } 99 100 // Set up the random number generator once. The seed value is the 101 // time multiplied by the PID. This can overflow the int64 but that 102 // is okay. We multiply by the PID in case we have multiple processes 103 // grabbing this at the same time. This is possible with Terraform and 104 // if we communicate to the same host at the same instance, we could 105 // overwrite the same files. Multiplying by the PID prevents this. 106 randLock.Lock() 107 defer randLock.Unlock() 108 if randShared == nil { 109 randShared = rand.New(rand.NewSource( 110 time.Now().UnixNano() * int64(os.Getpid()))) 111 } 112 113 comm := &Communicator{ 114 connInfo: connInfo, 115 config: config, 116 } 117 118 return comm, nil 119 } 120 121 // Connect implementation of communicator.Communicator interface 122 func (c *Communicator) Connect(o provisioners.UIOutput) (err error) { 123 // Grab a lock so we can modify our internal attributes 124 c.lock.Lock() 125 defer c.lock.Unlock() 126 127 if c.conn != nil { 128 c.conn.Close() 129 } 130 131 // Set the conn and client to nil since we'll recreate it 132 c.conn = nil 133 c.client = nil 134 135 if o != nil { 136 o.Output(fmt.Sprintf( 137 "Connecting to remote host via SSH...\n"+ 138 " Host: %s\n"+ 139 " User: %s\n"+ 140 " Password: %t\n"+ 141 " Private key: %t\n"+ 142 " Certificate: %t\n"+ 143 " SSH Agent: %t\n"+ 144 " Checking Host Key: %t\n"+ 145 " Target Platform: %s\n", 146 c.connInfo.Host, c.connInfo.User, 147 c.connInfo.Password != "", 148 c.connInfo.PrivateKey != "", 149 c.connInfo.Certificate != "", 150 c.connInfo.Agent, 151 c.connInfo.HostKey != "", 152 c.connInfo.TargetPlatform, 153 )) 154 155 if c.connInfo.BastionHost != "" { 156 o.Output(fmt.Sprintf( 157 "Using configured bastion host...\n"+ 158 " Host: %s\n"+ 159 " User: %s\n"+ 160 " Password: %t\n"+ 161 " Private key: %t\n"+ 162 " Certificate: %t\n"+ 163 " SSH Agent: %t\n"+ 164 " Checking Host Key: %t", 165 c.connInfo.BastionHost, c.connInfo.BastionUser, 166 c.connInfo.BastionPassword != "", 167 c.connInfo.BastionPrivateKey != "", 168 c.connInfo.BastionCertificate != "", 169 c.connInfo.Agent, 170 c.connInfo.BastionHostKey != "", 171 )) 172 } 173 } 174 175 hostAndPort := fmt.Sprintf("%s:%d", c.connInfo.Host, c.connInfo.Port) 176 log.Printf("[DEBUG] Connecting to %s for SSH", hostAndPort) 177 c.conn, err = c.config.connection() 178 if err != nil { 179 // Explicitly set this to the REAL nil. Connection() can return 180 // a nil implementation of net.Conn which will make the 181 // "if c.conn == nil" check fail above. Read here for more information 182 // on this psychotic language feature: 183 // 184 // http://golang.org/doc/faq#nil_error 185 c.conn = nil 186 187 log.Printf("[ERROR] connection error: %s", err) 188 return err 189 } 190 191 log.Printf("[DEBUG] Connection established. Handshaking for user %v", c.connInfo.User) 192 sshConn, sshChan, req, err := ssh.NewClientConn(c.conn, hostAndPort, c.config.config) 193 if err != nil { 194 err = errwrap.Wrapf(fmt.Sprintf("SSH authentication failed (%s@%s): {{err}}", c.connInfo.User, hostAndPort), err) 195 196 // While in theory this should be a fatal error, some hosts may start 197 // the ssh service before it is properly configured, or before user 198 // authentication data is available. 199 // Log the error, and allow the provisioner to retry. 200 log.Printf("[WARN] %s", err) 201 return err 202 } 203 204 c.client = ssh.NewClient(sshConn, sshChan, req) 205 206 if c.config.sshAgent != nil { 207 log.Printf("[DEBUG] Telling SSH config to forward to agent") 208 if err := c.config.sshAgent.ForwardToAgent(c.client); err != nil { 209 return fatalError{err} 210 } 211 212 log.Printf("[DEBUG] Setting up a session to request agent forwarding") 213 session, err := c.client.NewSession() 214 if err != nil { 215 return err 216 } 217 defer session.Close() 218 219 err = agent.RequestAgentForwarding(session) 220 221 if err == nil { 222 log.Printf("[INFO] agent forwarding enabled") 223 } else { 224 log.Printf("[WARN] error forwarding agent: %s", err) 225 } 226 } 227 228 if err != nil { 229 return err 230 } 231 232 if o != nil { 233 o.Output("Connected!") 234 } 235 236 ctx, cancelKeepAlive := context.WithCancel(context.TODO()) 237 c.cancelKeepAlive = cancelKeepAlive 238 239 // Start a keepalive goroutine to help maintain the connection for 240 // long-running commands. 241 log.Printf("[DEBUG] starting ssh KeepAlives") 242 243 // We want a local copy of the ssh client pointer, so that a reconnect 244 // doesn't race with the running keep-alive loop. 245 sshClient := c.client 246 go func() { 247 defer cancelKeepAlive() 248 // Along with the KeepAlives generating packets to keep the tcp 249 // connection open, we will use the replies to verify liveness of the 250 // connection. This will prevent dead connections from blocking the 251 // provisioner indefinitely. 252 respCh := make(chan error, 1) 253 254 go func() { 255 t := time.NewTicker(keepAliveInterval) 256 defer t.Stop() 257 for { 258 select { 259 case <-t.C: 260 _, _, err := sshClient.SendRequest("keepalive@terraform.io", true, nil) 261 respCh <- err 262 case <-ctx.Done(): 263 return 264 } 265 } 266 }() 267 268 after := time.NewTimer(maxKeepAliveDelay) 269 defer after.Stop() 270 271 for { 272 select { 273 case err := <-respCh: 274 if err != nil { 275 log.Printf("[ERROR] ssh keepalive: %s", err) 276 sshConn.Close() 277 return 278 } 279 case <-after.C: 280 // abort after too many missed keepalives 281 log.Println("[ERROR] no reply from ssh server") 282 sshConn.Close() 283 return 284 case <-ctx.Done(): 285 return 286 } 287 if !after.Stop() { 288 <-after.C 289 } 290 after.Reset(maxKeepAliveDelay) 291 } 292 }() 293 294 return nil 295 } 296 297 // Disconnect implementation of communicator.Communicator interface 298 func (c *Communicator) Disconnect() error { 299 c.lock.Lock() 300 defer c.lock.Unlock() 301 302 if c.cancelKeepAlive != nil { 303 c.cancelKeepAlive() 304 } 305 306 if c.config.sshAgent != nil { 307 if err := c.config.sshAgent.Close(); err != nil { 308 return err 309 } 310 } 311 312 if c.conn != nil { 313 conn := c.conn 314 c.conn = nil 315 return conn.Close() 316 } 317 318 return nil 319 } 320 321 // Timeout implementation of communicator.Communicator interface 322 func (c *Communicator) Timeout() time.Duration { 323 return c.connInfo.TimeoutVal 324 } 325 326 // ScriptPath implementation of communicator.Communicator interface 327 func (c *Communicator) ScriptPath() string { 328 randLock.Lock() 329 defer randLock.Unlock() 330 331 return strings.Replace( 332 c.connInfo.ScriptPath, "%RAND%", 333 strconv.FormatInt(int64(randShared.Int31()), 10), -1) 334 } 335 336 // Start implementation of communicator.Communicator interface 337 func (c *Communicator) Start(cmd *remote.Cmd) error { 338 cmd.Init() 339 340 session, err := c.newSession() 341 if err != nil { 342 return err 343 } 344 345 // Set up our session 346 session.Stdin = cmd.Stdin 347 session.Stdout = cmd.Stdout 348 session.Stderr = cmd.Stderr 349 350 if !c.config.noPty && c.connInfo.TargetPlatform != TargetPlatformWindows { 351 // Request a PTY 352 termModes := ssh.TerminalModes{ 353 ssh.ECHO: 0, // do not echo 354 ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud 355 ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud 356 } 357 358 if err := session.RequestPty("xterm", 80, 40, termModes); err != nil { 359 return err 360 } 361 } 362 363 log.Printf("[DEBUG] starting remote command: %s", cmd.Command) 364 err = session.Start(strings.TrimSpace(cmd.Command) + "\n") 365 if err != nil { 366 return err 367 } 368 369 // Start a goroutine to wait for the session to end and set the 370 // exit boolean and status. 371 go func() { 372 defer session.Close() 373 374 err := session.Wait() 375 exitStatus := 0 376 if err != nil { 377 exitErr, ok := err.(*ssh.ExitError) 378 if ok { 379 exitStatus = exitErr.ExitStatus() 380 } 381 } 382 383 cmd.SetExitStatus(exitStatus, err) 384 log.Printf("[DEBUG] remote command exited with '%d': %s", exitStatus, cmd.Command) 385 }() 386 387 return nil 388 } 389 390 // Upload implementation of communicator.Communicator interface 391 func (c *Communicator) Upload(path string, input io.Reader) error { 392 // The target directory and file for talking the SCP protocol 393 targetDir := filepath.Dir(path) 394 targetFile := filepath.Base(path) 395 396 // On windows, filepath.Dir uses backslash separators (ie. "\tmp"). 397 // This does not work when the target host is unix. Switch to forward slash 398 // which works for unix and windows 399 targetDir = filepath.ToSlash(targetDir) 400 401 // Skip copying if we can get the file size directly from common io.Readers 402 size := int64(0) 403 404 switch src := input.(type) { 405 case *os.File: 406 fi, err := src.Stat() 407 if err != nil { 408 size = fi.Size() 409 } 410 case *bytes.Buffer: 411 size = int64(src.Len()) 412 case *bytes.Reader: 413 size = int64(src.Len()) 414 case *strings.Reader: 415 size = int64(src.Len()) 416 } 417 418 scpFunc := func(w io.Writer, stdoutR *bufio.Reader) error { 419 return scpUploadFile(targetFile, input, w, stdoutR, size) 420 } 421 422 return c.scpSession("scp -vt "+targetDir, scpFunc) 423 } 424 425 // UploadScript implementation of communicator.Communicator interface 426 func (c *Communicator) UploadScript(path string, input io.Reader) error { 427 reader := bufio.NewReader(input) 428 prefix, err := reader.Peek(2) 429 if err != nil { 430 return fmt.Errorf("Error reading script: %s", err) 431 } 432 var script bytes.Buffer 433 434 if string(prefix) != "#!" && c.connInfo.TargetPlatform != TargetPlatformWindows { 435 script.WriteString(DefaultShebang) 436 } 437 script.ReadFrom(reader) 438 439 if err := c.Upload(path, &script); err != nil { 440 return err 441 } 442 if c.connInfo.TargetPlatform != TargetPlatformWindows { 443 var stdout, stderr bytes.Buffer 444 cmd := &remote.Cmd{ 445 Command: fmt.Sprintf("chmod 0777 %s", path), 446 Stdout: &stdout, 447 Stderr: &stderr, 448 } 449 if err := c.Start(cmd); err != nil { 450 return fmt.Errorf( 451 "Error chmodding script file to 0777 in remote "+ 452 "machine: %s", err) 453 } 454 455 if err := cmd.Wait(); err != nil { 456 return fmt.Errorf( 457 "Error chmodding script file to 0777 in remote "+ 458 "machine %v: %s %s", err, stdout.String(), stderr.String()) 459 } 460 } 461 return nil 462 } 463 464 // UploadDir implementation of communicator.Communicator interface 465 func (c *Communicator) UploadDir(dst string, src string) error { 466 log.Printf("[DEBUG] Uploading dir '%s' to '%s'", src, dst) 467 scpFunc := func(w io.Writer, r *bufio.Reader) error { 468 uploadEntries := func() error { 469 f, err := os.Open(src) 470 if err != nil { 471 return err 472 } 473 defer f.Close() 474 475 entries, err := f.Readdir(-1) 476 if err != nil { 477 return err 478 } 479 480 return scpUploadDir(src, entries, w, r) 481 } 482 483 if src[len(src)-1] != '/' { 484 log.Printf("[DEBUG] No trailing slash, creating the source directory name") 485 return scpUploadDirProtocol(filepath.Base(src), w, r, uploadEntries) 486 } 487 // Trailing slash, so only upload the contents 488 return uploadEntries() 489 } 490 491 return c.scpSession("scp -rvt "+dst, scpFunc) 492 } 493 494 func (c *Communicator) newSession() (session *ssh.Session, err error) { 495 log.Println("[DEBUG] opening new ssh session") 496 if c.client == nil { 497 err = errors.New("ssh client is not connected") 498 } else { 499 session, err = c.client.NewSession() 500 } 501 502 if err != nil { 503 log.Printf("[WARN] ssh session open error: '%s', attempting reconnect", err) 504 if err := c.Connect(nil); err != nil { 505 return nil, err 506 } 507 508 return c.client.NewSession() 509 } 510 511 return session, nil 512 } 513 514 func (c *Communicator) scpSession(scpCommand string, f func(io.Writer, *bufio.Reader) error) error { 515 session, err := c.newSession() 516 if err != nil { 517 return err 518 } 519 defer session.Close() 520 521 // Get a pipe to stdin so that we can send data down 522 stdinW, err := session.StdinPipe() 523 if err != nil { 524 return err 525 } 526 527 // We only want to close once, so we nil w after we close it, 528 // and only close in the defer if it hasn't been closed already. 529 defer func() { 530 if stdinW != nil { 531 stdinW.Close() 532 } 533 }() 534 535 // Get a pipe to stdout so that we can get responses back 536 stdoutPipe, err := session.StdoutPipe() 537 if err != nil { 538 return err 539 } 540 stdoutR := bufio.NewReader(stdoutPipe) 541 542 // Set stderr to a bytes buffer 543 stderr := new(bytes.Buffer) 544 session.Stderr = stderr 545 546 // Start the sink mode on the other side 547 // TODO(mitchellh): There are probably issues with shell escaping the path 548 log.Println("[DEBUG] Starting remote scp process: ", scpCommand) 549 if err := session.Start(scpCommand); err != nil { 550 return err 551 } 552 553 // Call our callback that executes in the context of SCP. We ignore 554 // EOF errors if they occur because it usually means that SCP prematurely 555 // ended on the other side. 556 log.Println("[DEBUG] Started SCP session, beginning transfers...") 557 if err := f(stdinW, stdoutR); err != nil && err != io.EOF { 558 return err 559 } 560 561 // Close the stdin, which sends an EOF, and then set w to nil so that 562 // our defer func doesn't close it again since that is unsafe with 563 // the Go SSH package. 564 log.Println("[DEBUG] SCP session complete, closing stdin pipe.") 565 stdinW.Close() 566 stdinW = nil 567 568 // Wait for the SCP connection to close, meaning it has consumed all 569 // our data and has completed. Or has errored. 570 log.Println("[DEBUG] Waiting for SSH session to complete.") 571 err = session.Wait() 572 573 // log any stderr before exiting on an error 574 scpErr := stderr.String() 575 if len(scpErr) > 0 { 576 log.Printf("[ERROR] scp stderr: %q", stderr) 577 } 578 579 if err != nil { 580 if exitErr, ok := err.(*ssh.ExitError); ok { 581 // Otherwise, we have an ExitErorr, meaning we can just read 582 // the exit status 583 log.Printf("[ERROR] %s", exitErr) 584 585 // If we exited with status 127, it means SCP isn't available. 586 // Return a more descriptive error for that. 587 if exitErr.ExitStatus() == 127 { 588 return errors.New( 589 "SCP failed to start. This usually means that SCP is not\n" + 590 "properly installed on the remote system.") 591 } 592 } 593 594 return err 595 } 596 597 return nil 598 } 599 600 // checkSCPStatus checks that a prior command sent to SCP completed 601 // successfully. If it did not complete successfully, an error will 602 // be returned. 603 func checkSCPStatus(r *bufio.Reader) error { 604 code, err := r.ReadByte() 605 if err != nil { 606 return err 607 } 608 609 if code != 0 { 610 // Treat any non-zero (really 1 and 2) as fatal errors 611 message, _, err := r.ReadLine() 612 if err != nil { 613 return fmt.Errorf("Error reading error message: %s", err) 614 } 615 616 return errors.New(string(message)) 617 } 618 619 return nil 620 } 621 622 func scpUploadFile(dst string, src io.Reader, w io.Writer, r *bufio.Reader, size int64) error { 623 if size == 0 { 624 // Create a temporary file where we can copy the contents of the src 625 // so that we can determine the length, since SCP is length-prefixed. 626 tf, err := ioutil.TempFile("", "terraform-upload") 627 if err != nil { 628 return fmt.Errorf("Error creating temporary file for upload: %s", err) 629 } 630 defer os.Remove(tf.Name()) 631 defer tf.Close() 632 633 log.Println("[DEBUG] Copying input data into temporary file so we can read the length") 634 if _, err := io.Copy(tf, src); err != nil { 635 return err 636 } 637 638 // Sync the file so that the contents are definitely on disk, then 639 // read the length of it. 640 if err := tf.Sync(); err != nil { 641 return fmt.Errorf("Error creating temporary file for upload: %s", err) 642 } 643 644 // Seek the file to the beginning so we can re-read all of it 645 if _, err := tf.Seek(0, 0); err != nil { 646 return fmt.Errorf("Error creating temporary file for upload: %s", err) 647 } 648 649 fi, err := tf.Stat() 650 if err != nil { 651 return fmt.Errorf("Error creating temporary file for upload: %s", err) 652 } 653 654 src = tf 655 size = fi.Size() 656 } 657 658 // Start the protocol 659 log.Println("[DEBUG] Beginning file upload...") 660 fmt.Fprintln(w, "C0644", size, dst) 661 if err := checkSCPStatus(r); err != nil { 662 return err 663 } 664 665 if _, err := io.Copy(w, src); err != nil { 666 return err 667 } 668 669 fmt.Fprint(w, "\x00") 670 if err := checkSCPStatus(r); err != nil { 671 return err 672 } 673 674 return nil 675 } 676 677 func scpUploadDirProtocol(name string, w io.Writer, r *bufio.Reader, f func() error) error { 678 log.Printf("[DEBUG] SCP: starting directory upload: %s", name) 679 fmt.Fprintln(w, "D0755 0", name) 680 err := checkSCPStatus(r) 681 if err != nil { 682 return err 683 } 684 685 if err := f(); err != nil { 686 return err 687 } 688 689 fmt.Fprintln(w, "E") 690 if err != nil { 691 return err 692 } 693 694 return nil 695 } 696 697 func scpUploadDir(root string, fs []os.FileInfo, w io.Writer, r *bufio.Reader) error { 698 for _, fi := range fs { 699 realPath := filepath.Join(root, fi.Name()) 700 701 // Track if this is actually a symlink to a directory. If it is 702 // a symlink to a file we don't do any special behavior because uploading 703 // a file just works. If it is a directory, we need to know so we 704 // treat it as such. 705 isSymlinkToDir := false 706 if fi.Mode()&os.ModeSymlink == os.ModeSymlink { 707 symPath, err := filepath.EvalSymlinks(realPath) 708 if err != nil { 709 return err 710 } 711 712 symFi, err := os.Lstat(symPath) 713 if err != nil { 714 return err 715 } 716 717 isSymlinkToDir = symFi.IsDir() 718 } 719 720 if !fi.IsDir() && !isSymlinkToDir { 721 // It is a regular file (or symlink to a file), just upload it 722 f, err := os.Open(realPath) 723 if err != nil { 724 return err 725 } 726 727 err = func() error { 728 defer f.Close() 729 return scpUploadFile(fi.Name(), f, w, r, fi.Size()) 730 }() 731 732 if err != nil { 733 return err 734 } 735 736 continue 737 } 738 739 // It is a directory, recursively upload 740 err := scpUploadDirProtocol(fi.Name(), w, r, func() error { 741 f, err := os.Open(realPath) 742 if err != nil { 743 return err 744 } 745 defer f.Close() 746 747 entries, err := f.Readdir(-1) 748 if err != nil { 749 return err 750 } 751 752 return scpUploadDir(realPath, entries, w, r) 753 }) 754 if err != nil { 755 return err 756 } 757 } 758 759 return nil 760 } 761 762 // ConnectFunc is a convenience method for returning a function 763 // that just uses net.Dial to communicate with the remote end that 764 // is suitable for use with the SSH communicator configuration. 765 func ConnectFunc(network, addr string) func() (net.Conn, error) { 766 return func() (net.Conn, error) { 767 c, err := net.DialTimeout(network, addr, 15*time.Second) 768 if err != nil { 769 return nil, err 770 } 771 772 if tcpConn, ok := c.(*net.TCPConn); ok { 773 tcpConn.SetKeepAlive(true) 774 } 775 776 return c, nil 777 } 778 } 779 780 // BastionConnectFunc is a convenience method for returning a function 781 // that connects to a host over a bastion connection. 782 func BastionConnectFunc( 783 bProto string, 784 bAddr string, 785 bConf *ssh.ClientConfig, 786 proto string, 787 addr string) func() (net.Conn, error) { 788 return func() (net.Conn, error) { 789 log.Printf("[DEBUG] Connecting to bastion: %s", bAddr) 790 bastion, err := ssh.Dial(bProto, bAddr, bConf) 791 if err != nil { 792 return nil, fmt.Errorf("Error connecting to bastion: %s", err) 793 } 794 795 log.Printf("[DEBUG] Connecting via bastion (%s) to host: %s", bAddr, addr) 796 conn, err := bastion.Dial(proto, addr) 797 if err != nil { 798 bastion.Close() 799 return nil, err 800 } 801 802 // Wrap it up so we close both things properly 803 return &bastionConn{ 804 Conn: conn, 805 Bastion: bastion, 806 }, nil 807 } 808 } 809 810 type bastionConn struct { 811 net.Conn 812 Bastion *ssh.Client 813 } 814 815 func (c *bastionConn) Close() error { 816 c.Conn.Close() 817 return c.Bastion.Close() 818 }