File: shell_transport_ssh_bin.go

package info (click to toggle)
nerdlog 1.10.0-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 4,296 kB
  • sloc: sh: 1,004; makefile: 85
file content (217 lines) | stat: -rw-r--r-- 5,547 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
package core

import (
	"bufio"
	"fmt"
	"io"
	"os/exec"
	"strings"
	"time"

	"github.com/dimonomid/nerdlog/log"
	"github.com/juju/errors"
)

const echoMarkerConnected = "__CONNECTED__"

// ShellTransportSSHBin is an implementation of ShellTransport that opens an
// ssh session using external ssh binary.
type ShellTransportSSHBin struct {
	params ShellTransportSSHBinParams
}

type ShellTransportSSHBinParams struct {
	Host string
	User string
	Port string

	Logger *log.Logger
}

// NewShellTransportSSHBin creates a new ShellTransportSSHBin with the given shell command.
func NewShellTransportSSHBin(params ShellTransportSSHBinParams) *ShellTransportSSHBin {
	params.Logger = params.Logger.WithNamespaceAppended("TransportSSHBin")

	return &ShellTransportSSHBin{
		params: params,
	}
}

// Connect starts the local shell and sends the result to the provided channel.
func (s *ShellTransportSSHBin) Connect(resCh chan<- ShellConnUpdate) {
	go s.doConnect(resCh)
}

func (s *ShellTransportSSHBin) doConnect(
	resCh chan<- ShellConnUpdate,
) (res ShellConnResult) {
	logger := s.params.Logger

	defer func() {
		if res.Err != nil {
			logger.Errorf("Connection failed: %s", res.Err)
		}
		resCh <- ShellConnUpdate{
			Result: &res,
		}
	}()

	var sshArgs []string
	if s.params.Port != "" {
		sshArgs = append(sshArgs, "-p", s.params.Port)
	}

	// We can't easily intercept any prompts for passwords etc, because ssh
	// interacts directly with the terminal, not stdin/stdout, and so we don't
	// even try, and instruct ssh to fail instead of prompting.
	//
	// We might, in theory, use a PTY like https://github.com/creack/pty, but
	// it's not gonna be very robust and smells like it might open a can of
	// worms, so not for now.
	sshArgs = append(sshArgs, "-o", "BatchMode=yes")

	dest := s.params.Host
	if s.params.User != "" {
		dest = fmt.Sprintf("%s@%s", s.params.User, dest)
	}
	sshArgs = append(sshArgs, dest, "/bin/sh")

	var sshCmdDebugBuilder strings.Builder
	sshCmdDebugBuilder.WriteString("ssh")
	for _, v := range sshArgs {
		sshCmdDebugBuilder.WriteString(" ")
		sshCmdDebugBuilder.WriteString(shellQuote(v))
	}
	sshCmdDebug := sshCmdDebugBuilder.String()

	resCh <- ShellConnUpdate{
		DebugInfo: s.makeDebugInfo(fmt.Sprintf(
			"Trying to connect using external command: %q", sshCmdDebug,
		)),
	}
	logger.Infof("Executing external ssh command: %q", sshCmdDebug)

	cmd := exec.Command("ssh", sshArgs...)
	stdin, err := cmd.StdinPipe()
	if err != nil {
		res.Err = errors.Annotatef(err, "getting stdin pipe")
		return res
	}
	rawStdout, err := cmd.StdoutPipe()
	if err != nil {
		res.Err = errors.Annotatef(err, "getting stdout pipe")
		return res
	}
	stderr, err := cmd.StderrPipe()
	if err != nil {
		res.Err = errors.Annotatef(err, "getting stderr pipe")
		return res
	}

	if err := cmd.Start(); err != nil {
		res.Err = errors.Annotatef(err, "starting shell")
		return res
	}

	// To make sure we were able to connect, we just write "echo __CONNECTED__"
	// to stdin, and wait for it to show up in the stdout.

	resCh <- ShellConnUpdate{
		DebugInfo: s.makeDebugInfo(fmt.Sprintf(
			"Command started, writing \"echo %s\", waiting for it in stdout", echoMarkerConnected,
		)),
	}

	_, err = fmt.Fprintf(stdin, "echo %s\n", echoMarkerConnected)
	if err != nil {
		res.Err = errors.Annotatef(err, "writing connection marker")
		return res
	}

	clientStdoutR, clientStdoutW := io.Pipe()
	scanner := bufio.NewScanner(rawStdout)
	connErrCh := make(chan error)
	go func() {
		defer clientStdoutW.Close()
		for scanner.Scan() {
			line := scanner.Text()
			logger.Verbose3f("Got line while looking for connected marker: %s", line)
			if line == echoMarkerConnected {
				logger.Verbose3f("Got the marker, switching to raw passthrough for stdout")
				// Done waiting, switch to raw passthrough
				connErrCh <- nil
				io.Copy(clientStdoutW, rawStdout)
				return
			}
		}
		if err := scanner.Err(); err != nil {
			logger.Errorf("Got scanner error while waiting for connection marker: %s", err.Error())
			connErrCh <- errors.Annotatef(err, "reading from stdout while waiting for connection marker")
		} else {
			// Got EOF while waiting for the marker; apparently ssh failed to connect,
			// so just read up all stderr (which likely contains the actual error message),
			// and return it as an error.
			stderrBytes, _ := io.ReadAll(stderr)
			connErrCh <- errors.Errorf(
				"failed to connect using external command \"%s\": %s",
				sshCmdDebug, string(stderrBytes),
			)
		}
	}()

	// Wait for the marker to show up in output.
	select {
	case err := <-connErrCh:
		if err != nil {
			res.Err = errors.Trace(err)
			return res
		}

		resCh <- ShellConnUpdate{
			DebugInfo: s.makeDebugInfo("Got the marker, connected successfully"),
		}

		// Got the marker, so we're done.
		res.Conn = &ShellConnSSHBin{
			cmd:    cmd,
			stdin:  stdin,
			stdout: clientStdoutR,
			stderr: stderr,
		}
		return res

	case <-time.After(connectionTimeout):
		res.Err = errors.New("timeout waiting for SSH connection marker")
		return res
	}
}

func (s *ShellTransportSSHBin) makeDebugInfo(message string) *ShellConnDebugInfo {
	return &ShellConnDebugInfo{
		Message: message,
	}
}

type ShellConnSSHBin struct {
	cmd *exec.Cmd

	stdin  io.WriteCloser
	stdout io.Reader
	stderr io.Reader
}

func (s *ShellConnSSHBin) Stdin() io.Writer {
	return s.stdin
}

func (s *ShellConnSSHBin) Stdout() io.Reader {
	return s.stdout
}

func (s *ShellConnSSHBin) Stderr() io.Reader {
	return s.stderr
}

func (s *ShellConnSSHBin) Close() {
	s.stdin.Close()
}