conn.go

v0.6.0
Doc Versions Source
1
package transport
2
3
import (
4
	"bytes"
5
	"context"
6
	"io"
7
	"os"
8
	"path"
9
	"strconv"
10
	"time"
11
12
	"github.com/pkg/sftp"
13
	xssh "golang.org/x/crypto/ssh"
14
15
	"go.bigb.es/auxilia/culpa"
16
	"go.bigb.es/auxilia/legatus"
17
)
18
19
// sshConn is a live SSH connection to one host, implementing legatus.Conn over
20
// golang.org/x/crypto/ssh and github.com/pkg/sftp. It carries no user identity:
21
// sudo-wrapping is driven entirely by the per-call Cmd.Sudo / PutOptions.Sudo
22
// flags (the privileged action, not the login identity, decides). No key
23
// material or secret is logged here (IV5).
24
type sshConn struct {
25
	client *xssh.Client
26
	host   string
27
	// jumps are the ProxyJump clients this connection tunnels through, in dial
28
	// order. They must outlive the target client and are closed in reverse on
29
	// Close — closing a jump tears down the tunnel beneath it.
30
	jumps []*xssh.Client
31
}
32
33
var _ legatus.Conn = (*sshConn)(nil)
34
35
// Exec runs cmd on the remote host. The command is rendered by buildRemote,
36
// which individually quotes every argument and env value (IV2) and applies sudo
37
// + env(1) wrapping when cmd.Sudo is set (IV6). stdout/stderr are captured in
38
// full and the exit code is reported in ExecResult.
39
//
40
// A command that runs to completion is NOT an error, even on a non-zero exit:
41
// the exit status is reported via ExecResult.ExitCode and err is nil. Callers
42
// decide what a non-zero exit means — a step's Apply treats it as failure, while
43
// a probe (`test -e`, `systemctl is-active`, `dpkg-query`) treats it as a signal.
44
// err is reserved for failures to run the command at all: no session, a lost
45
// signal, an IO error, or context cancellation (which sends SIGTERM, tears down
46
// the remote process, and surfaces ctx.Err()).
47
func (c *sshConn) Exec(ctx context.Context, cmd legatus.Cmd) (legatus.ExecResult, error) {
48
	return c.ExecStream(ctx, cmd, nil, nil)
49
}
50
51
// ExecStream runs cmd, streaming each non-nil writer live and buffering each nil
52
// writer into the returned ExecResult (the buffered contract Exec relies on). The
53
// exit-code and cancellation behavior is identical to Exec.
54
func (c *sshConn) ExecStream(ctx context.Context, cmd legatus.Cmd, stdout, stderr io.Writer) (legatus.ExecResult, error) {
55
	if cmd.Path == "" {
56
		return legatus.ExecResult{}, culpa.WithCode(
57
			culpa.New("ssh: empty command path"), "SSH_EXEC")
58
	}
59
60
	sess, err := c.client.NewSession()
61
	if err != nil {
62
		return legatus.ExecResult{}, culpa.WithCode(
63
			culpa.Wrapf(err, "ssh: new session on %s", c.host), "SSH_EXEC")
64
	}
65
	defer func() { _ = sess.Close() }()
66
67
	// A nil writer falls back to a buffer copied into ExecResult; a non-nil writer
68
	// receives the stream live and leaves the ExecResult field nil.
69
	var outBuf, errBuf bytes.Buffer
70
	if stdout != nil {
71
		sess.Stdout = stdout
72
	} else {
73
		sess.Stdout = &outBuf
74
	}
75
	if stderr != nil {
76
		sess.Stderr = stderr
77
	} else {
78
		sess.Stderr = &errBuf
79
	}
80
	if cmd.Stdin != nil {
81
		sess.Stdin = cmd.Stdin
82
	}
83
84
	remote, err := buildRemote(cmd)
85
	if err != nil {
86
		return legatus.ExecResult{}, err
87
	}
88
89
	if err := sess.Start(remote); err != nil {
90
		return legatus.ExecResult{}, culpa.WithCode(
91
			culpa.Wrapf(err, "ssh: start command on %s", c.host), "SSH_EXEC")
92
	}
93
94
	result := func() legatus.ExecResult {
95
		r := legatus.ExecResult{}
96
		if stdout == nil {
97
			r.Stdout = outBuf.Bytes()
98
		}
99
		if stderr == nil {
100
			r.Stderr = errBuf.Bytes()
101
		}
102
		return r
103
	}
104
105
	done := make(chan error, 1)
106
	go func() { done <- sess.Wait() }()
107
108
	select {
109
	case <-ctx.Done():
110
		_ = sess.Signal(xssh.SIGTERM)
111
		_ = sess.Close()
112
		<-done
113
		return result(), culpa.WithCode(
114
			culpa.Wrapf(ctx.Err(), "ssh: command on %s cancelled", c.host), "SSH_EXEC")
115
	case werr := <-done:
116
		res := result()
117
		if werr == nil {
118
			return res, nil
119
		}
120
		var ee *xssh.ExitError
121
		if culpa.As(werr, &ee) {
122
			// Ran to completion, non-zero exit: report via ExitCode, not error.
123
			res.ExitCode = ee.ExitStatus()
124
			return res, nil
125
		}
126
		// Non-exit failure (lost signal, IO error, …): the command did not run to
127
		// a clean exit, so this is a hard error.
128
		return res, culpa.WithCode(
129
			culpa.Wrapf(werr, "ssh: command on %s", c.host), "SSH_EXEC")
130
	}
131
}
132
133
// Put uploads content to remote with mode. When opts request sudo, the file is
134
// staged to a private per-invocation /tmp path (the SFTP subsystem runs as the
135
// login user and can't write a root-owned destination), then the destination
136
// directory is created and the file moved into place with sudo (IV6); an owner,
137
// if given, is applied with `sudo chown`. Without sudo the file is written
138
// straight to the destination via SFTP.
139
func (c *sshConn) Put(ctx context.Context, content io.Reader, remote string, mode legatus.FileMode, opts ...legatus.PutOption) error {
140
	if remote == "" {
141
		return culpa.WithCode(culpa.New("ssh: empty remote path"), "SSH_PUT")
142
	}
143
	var o legatus.PutOptions
144
	for _, opt := range opts {
145
		opt(&o)
146
	}
147
148
	sc, err := sftp.NewClient(c.client)
149
	if err != nil {
150
		return culpa.WithCode(
151
			culpa.Wrapf(err, "ssh: open sftp to %s", c.host), "SSH_PUT")
152
	}
153
	defer func() { _ = sc.Close() }()
154
155
	if !o.Sudo {
156
		if err := sftpWrite(sc, content, remote, mode); err != nil {
157
			return err
158
		}
159
		if o.Owner != "" {
160
			if err := c.chown(ctx, o.Owner, remote, false); err != nil {
161
				return err
162
			}
163
		}
164
		return nil
165
	}
166
167
	// Sudo path: stage to a private temp file, then privileged-move into place.
168
	tmp := remoteTempPath(remote)
169
	if err := sftpWrite(sc, content, tmp, mode); err != nil {
170
		return err
171
	}
172
	if err := c.checkedExec(ctx,
173
		legatus.Cmd{Path: "mkdir", Args: []string{"-p", path.Dir(remote)}, Sudo: true},
174
		"sudo mkdir remote dir"); err != nil {
175
		_ = sc.Remove(tmp)
176
		return err
177
	}
178
	if err := c.checkedExec(ctx,
179
		legatus.Cmd{Path: "mv", Args: []string{tmp, remote}, Sudo: true},
180
		"sudo mv into place"); err != nil {
181
		_ = sc.Remove(tmp)
182
		return err
183
	}
184
	if o.Owner != "" {
185
		if err := c.chown(ctx, o.Owner, remote, true); err != nil {
186
			return err
187
		}
188
	}
189
	return nil
190
}
191
192
// chown sets the owner of remote, sudo-wrapped when sudo is set.
193
func (c *sshConn) chown(ctx context.Context, owner, remote string, sudo bool) error {
194
	return c.checkedExec(ctx,
195
		legatus.Cmd{Path: "chown", Args: []string{owner, remote}, Sudo: sudo},
196
		"chown "+remote)
197
}
198
199
// checkedExec runs a Put-path command that must succeed. The Conn.Exec contract
200
// reports a clean non-zero exit via ExitCode with a nil error, so requiring
201
// success means checking ExitCode too — otherwise a failed sudo mv/chown would
202
// be silently swallowed, leaving the remote partially written or mis-owned. The
203
// failure (transport error or non-zero exit with its output) is wrapped SSH_PUT.
204
func (c *sshConn) checkedExec(ctx context.Context, cmd legatus.Cmd, what string) error {
205
	res, err := c.Exec(ctx, cmd)
206
	if err == nil && res.ExitCode != 0 {
207
		err = culpa.Errorf("exit %d: %s", res.ExitCode, bytes.TrimSpace(res.Combined()))
208
	}
209
	if err != nil {
210
		return culpa.WithCode(culpa.Wrapf(err, "ssh: %s on %s", what, c.host), "SSH_PUT")
211
	}
212
	return nil
213
}
214
215
// Get streams the remote file to w via SFTP.
216
func (c *sshConn) Get(ctx context.Context, remote string, w io.Writer) error {
217
	if remote == "" {
218
		return culpa.WithCode(culpa.New("ssh: empty remote path"), "SSH_GET")
219
	}
220
	sc, err := sftp.NewClient(c.client)
221
	if err != nil {
222
		return culpa.WithCode(
223
			culpa.Wrapf(err, "ssh: open sftp to %s", c.host), "SSH_GET")
224
	}
225
	defer func() { _ = sc.Close() }()
226
227
	f, err := sc.Open(remote)
228
	if err != nil {
229
		return culpa.WithCode(
230
			culpa.Wrapf(err, "ssh: open remote %s", remote), "SSH_GET")
231
	}
232
	defer func() { _ = f.Close() }()
233
234
	if _, err := io.Copy(w, f); err != nil {
235
		return culpa.WithCode(
236
			culpa.Wrapf(err, "ssh: read remote %s", remote), "SSH_GET")
237
	}
238
	return nil
239
}
240
241
// Close releases the underlying SSH connection, then any ProxyJump clients in
242
// reverse dial order (a jump must outlive the hop it carries). The first error
243
// is returned; later closes still run.
244
func (c *sshConn) Close() error {
245
	var firstErr error
246
	if c.client != nil {
247
		if err := c.client.Close(); err != nil {
248
			firstErr = culpa.WithCode(
249
				culpa.Wrapf(err, "ssh: close connection to %s", c.host), "SSH_CLOSE")
250
		}
251
	}
252
	for i := len(c.jumps) - 1; i >= 0; i-- {
253
		if err := c.jumps[i].Close(); err != nil && firstErr == nil {
254
			firstErr = culpa.WithCode(
255
				culpa.Wrapf(err, "ssh: close jump connection"), "SSH_CLOSE")
256
		}
257
	}
258
	return firstErr
259
}
260
261
// sftpWrite writes src to remote via SFTP, creating parent dirs and applying
262
// mode. It is the unprivileged write path shared by Put's direct and staging
263
// branches.
264
func sftpWrite(sc *sftp.Client, src io.Reader, remote string, mode legatus.FileMode) error {
265
	if dir := path.Dir(remote); dir != "." && dir != "/" {
266
		if err := sc.MkdirAll(dir); err != nil {
267
			return culpa.WithCode(
268
				culpa.Wrapf(err, "ssh: mkdir remote dir %s", dir), "SSH_PUT")
269
		}
270
	}
271
	dst, err := sc.OpenFile(remote, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
272
	if err != nil {
273
		return culpa.WithCode(
274
			culpa.Wrapf(err, "ssh: open remote %s", remote), "SSH_PUT")
275
	}
276
	if _, err := io.Copy(dst, src); err != nil {
277
		_ = dst.Close()
278
		return culpa.WithCode(
279
			culpa.Wrapf(err, "ssh: write remote %s", remote), "SSH_PUT")
280
	}
281
	if err := dst.Close(); err != nil {
282
		return culpa.WithCode(
283
			culpa.Wrapf(err, "ssh: close remote %s", remote), "SSH_PUT")
284
	}
285
	if err := sc.Chmod(remote, mode); err != nil {
286
		return culpa.WithCode(
287
			culpa.Wrapf(err, "ssh: chmod remote %s", remote), "SSH_PUT")
288
	}
289
	return nil
290
}
291
292
// remoteTempPath derives a private staging path under /tmp from the destination
293
// base name plus a pid-and-time suffix, unique enough for concurrent uploads;
294
// the file is moved or removed immediately after.
295
func remoteTempPath(remote string) string {
296
	base := path.Base(remote)
297
	return "/tmp/legatus." + strconv.Itoa(os.Getpid()) + "." +
298
		strconv.FormatInt(time.Now().UnixNano(), 10) + "." + base
299
}
300

Source Files