dialer.go

v0.6.1
Doc Versions Source
1
// Package transport is the SSH side-effect layer (PC1) for legatus. It
2
// implements legatus.Conn/legatus.Dialer over golang.org/x/crypto/ssh and
3
// github.com/pkg/sftp, with three hard guarantees:
4
//
5
//   - IV2: remote command arguments and environment are passed STRUCTURALLY —
6
//     every value is individually shell-quoted (see command.go) so the remote
7
//     login shell, which parses the exec string, can never re-split or
8
//     re-evaluate caller data.
9
//   - IV5: no secret (key material, passphrase) is ever written to logs; this
10
//     package logs nothing and folds key bytes only into the ssh library.
11
//   - IV6: non-root commands are sudo-wrapped and non-root file uploads stage to
12
//     a private /tmp path then `sudo mv` into place.
13
//
14
// Authentication is key/agent only (AS1): no password auth is offered. The core
15
// legatus package stays SSH-free; this package is exactly how legatus.Conn is
16
// satisfied (PC1).
17
package transport
18
19
import (
20
	"context"
21
	"net"
22
	"os"
23
	"path/filepath"
24
	"strconv"
25
	"strings"
26
	"time"
27
28
	"github.com/kevinburke/ssh_config"
29
	xssh "golang.org/x/crypto/ssh"
30
	"golang.org/x/crypto/ssh/agent"
31
	"golang.org/x/crypto/ssh/knownhosts"
32
33
	"go.bigb.es/auxilia/culpa"
34
	"go.bigb.es/auxilia/legatus"
35
)
36
37
// defaultDialTimeout bounds the TCP connect plus SSH handshake when the caller
38
// does not set one via WithTimeout.
39
const defaultDialTimeout = 15 * time.Second
40
41
// Option configures a Dialer. Auth methods accumulate in the order supplied and
42
// are tried in that order by the ssh handshake.
43
type Option func(*Dialer)
44
45
// Dialer opens SSH connections, implementing legatus.Dialer. It is safe to
46
// reuse across hosts; each Dial produces an independent legatus.Conn.
47
type Dialer struct {
48
	auths           []xssh.AuthMethod
49
	hostKeyCallback xssh.HostKeyCallback
50
	timeout         time.Duration
51
	useSSHConfig    bool
52
	proxyJump       string
53
	// cfgGet resolves a ~/.ssh/config keyword for an alias. It defaults to the
54
	// package-level ssh_config.Get and is overridable in tests so config and
55
	// ProxyJump resolution are exercisable without a real ~/.ssh/config.
56
	cfgGet func(alias, key string) string
57
}
58
59
var _ legatus.Dialer = (*Dialer)(nil)
60
61
// New builds a Dialer from the given options. At least one auth option
62
// (WithAgent / WithIdentityFile / WithSSHConfig) must be supplied; Dial fails
63
// loudly otherwise (no silent fallback, AS1).
64
func New(opts ...Option) *Dialer {
65
	d := &Dialer{timeout: defaultDialTimeout, cfgGet: ssh_config.Get}
66
	for _, o := range opts {
67
		o(d)
68
	}
69
	return d
70
}
71
72
// WithProxyJump routes the connection through one or more jump hosts, given as an
73
// ssh ProxyJump value (`[user@]host[:port]`, comma-separated, applied left to
74
// right). It overrides any ProxyJump in ~/.ssh/config for the target.
75
func WithProxyJump(spec string) Option {
76
	return func(d *Dialer) { d.proxyJump = spec }
77
}
78
79
// WithAgent authenticates via the ssh-agent at $SSH_AUTH_SOCK. The agent's
80
// signers are resolved lazily at Dial time so a Dialer can be constructed before
81
// the agent is reachable. Key material never leaves the agent (IV5).
82
func WithAgent() Option {
83
	return func(d *Dialer) {
84
		d.auths = append(d.auths, xssh.PublicKeysCallback(agentSigners))
85
	}
86
}
87
88
// WithIdentityFile authenticates with the private key at path (with a leading ~
89
// expanded). The key is parsed at Dial time; an encrypted or missing key surfaces
90
// as a Dial error (AS1 — no password prompt).
91
func WithIdentityFile(path string) Option {
92
	return func(d *Dialer) {
93
		d.auths = append(d.auths, identityFileAuth(path))
94
	}
95
}
96
97
// WithSSHConfig resolves each host through ~/.ssh/config (HostName, Port, User,
98
// IdentityFile) at Dial time, mirroring how the system ssh client behaves. The
99
// host's own Addr/Port/User remain authoritative when set; config fills the gaps.
100
func WithSSHConfig() Option {
101
	return func(d *Dialer) { d.useSSHConfig = true }
102
}
103
104
// WithKnownHosts verifies host keys against the OpenSSH known_hosts file at path
105
// (with a leading ~ expanded). A key mismatch or unknown host fails the dial.
106
func WithKnownHosts(path string) Option {
107
	return func(d *Dialer) {
108
		// Resolve the callback eagerly so a missing/garbled known_hosts file is a
109
		// construction-time error path rather than a silent ignore. We defer the
110
		// error to Dial by storing a callback that returns it.
111
		cb, err := knownhosts.New(expandTilde(path))
112
		if err != nil {
113
			d.hostKeyCallback = func(string, net.Addr, xssh.PublicKey) error {
114
				return culpa.WithCode(
115
					culpa.Wrapf(err, "load known_hosts %q", path),
116
					"SSH_KNOWN_HOSTS",
117
				)
118
			}
119
			return
120
		}
121
		d.hostKeyCallback = cb
122
	}
123
}
124
125
// WithHostKeyCallback sets an explicit host-key verification callback, overriding
126
// any WithKnownHosts. Callers that intentionally skip verification can pass
127
// xssh.InsecureIgnoreHostKey().
128
func WithHostKeyCallback(cb xssh.HostKeyCallback) Option {
129
	return func(d *Dialer) { d.hostKeyCallback = cb }
130
}
131
132
// WithTimeout sets the TCP connect plus SSH handshake timeout.
133
func WithTimeout(timeout time.Duration) Option {
134
	return func(d *Dialer) { d.timeout = timeout }
135
}
136
137
// Dial opens an SSH connection to h, returning a legatus.Conn. It requires at
138
// least one auth method and a host-key callback; both missing are hard errors
139
// (no insecure default — the caller must opt into InsecureIgnoreHostKey via
140
// WithHostKeyCallback). Only key/agent auth is offered (AS1).
141
func (d *Dialer) Dial(ctx context.Context, h *legatus.Host) (legatus.Conn, error) {
142
	if h == nil {
143
		return nil, culpa.WithCode(culpa.New("ssh: nil host"), "SSH_DIAL")
144
	}
145
146
	addr, port, user := h.Addr, h.Port, h.User
147
	var identity xssh.AuthMethod
148
	if d.useSSHConfig {
149
		alias := h.Name
150
		if alias == "" {
151
			alias = h.Addr
152
		}
153
		addr, port, user, identity = d.resolveConfig(alias, addr, port, user)
154
	}
155
	if addr == "" {
156
		addr = h.Name
157
	}
158
	if addr == "" {
159
		return nil, culpa.WithCode(
160
			culpa.Errorf("ssh: host %q has no address", h.Name), "SSH_DIAL")
161
	}
162
	if port == 0 {
163
		port = 22
164
	}
165
	if user == "" {
166
		return nil, culpa.WithCode(
167
			culpa.WithHint(
168
				culpa.Errorf("ssh: host %q has no user", h.Name),
169
				"set Host.User or provide it via ~/.ssh/config with WithSSHConfig",
170
			), "SSH_DIAL")
171
	}
172
173
	if len(d.auths) == 0 && identity == nil {
174
		return nil, culpa.WithCode(
175
			culpa.WithHint(
176
				culpa.New("ssh: no auth method configured"),
177
				"add WithAgent, WithIdentityFile, or WithSSHConfig",
178
			), "SSH_AUTH")
179
	}
180
	if d.hostKeyCallback == nil {
181
		return nil, culpa.WithCode(
182
			culpa.WithHint(
183
				culpa.New("ssh: no host-key verification configured"),
184
				"add WithKnownHosts or WithHostKeyCallback (use InsecureIgnoreHostKey to skip)",
185
			), "SSH_HOSTKEY")
186
	}
187
188
	chain, err := d.jumpChain(h)
189
	if err != nil {
190
		return nil, err
191
	}
192
193
	// Dial each jump in order, tunnelling each subsequent hop through the
194
	// previous one (IV1: every hop is fully authenticated and host-key-verified).
195
	var jumps []*xssh.Client
196
	var through *xssh.Client
197
	for _, j := range chain {
198
		jAddr, jPort, jUser, jID := d.resolveJump(j)
199
		if jUser == "" {
200
			closeAll(jumps)
201
			return nil, culpa.WithCode(
202
				culpa.WithHint(
203
					culpa.Errorf("ssh: jump host %q has no user", j.host),
204
					"set it in the ProxyJump spec (user@host) or ~/.ssh/config",
205
				), "SSH_DIAL")
206
		}
207
		jClient, err := d.dialHop(ctx, through, jAddr, jPort, d.clientConfig(jUser, jID))
208
		if err != nil {
209
			closeAll(jumps)
210
			return nil, err
211
		}
212
		jumps = append(jumps, jClient)
213
		through = jClient
214
	}
215
216
	client, err := d.dialHop(ctx, through, addr, port, d.clientConfig(user, identity))
217
	if err != nil {
218
		closeAll(jumps)
219
		return nil, err
220
	}
221
222
	return &sshConn{client: client, host: addr, jumps: jumps}, nil
223
}
224
225
// dialHop opens an SSH client to addr:port using cfg. With through==nil it dials
226
// TCP directly; otherwise it tunnels the connection through the jump client
227
// (ProxyJump). The returned client owns the underlying conn and is closed by the
228
// sshConn (jump clients) or directly (the target client).
229
func (d *Dialer) dialHop(ctx context.Context, through *xssh.Client, addr string, port int, cfg *xssh.ClientConfig) (*xssh.Client, error) {
230
	target := net.JoinHostPort(addr, strconv.Itoa(port))
231
232
	// Advertise the host key algorithms known_hosts already holds for this
233
	// target, the way OpenSSH orders them. Set here rather than in clientConfig
234
	// because `target` is the exact string handed to NewClientConn below, and so
235
	// the exact string the host key callback checks — deriving both from one
236
	// value keeps the algorithms and the verification in agreement by
237
	// construction. Without this the server picks from the library default and a
238
	// host recorded under one algorithm but negotiated under another fails as a
239
	// spurious "key mismatch" (see hostkeyalgos.go). Nil for an unknown host
240
	// leaves the library default, so it still fails in the callback rather than
241
	// failing to negotiate.
242
	if cfg.HostKeyAlgorithms == nil {
243
		cfg.HostKeyAlgorithms = hostKeyAlgosFor(cfg.HostKeyCallback, target)
244
	}
245
246
	var netConn net.Conn
247
	var err error
248
	if through == nil {
249
		netConn, err = (&net.Dialer{Timeout: d.timeout}).DialContext(ctx, "tcp", target)
250
	} else {
251
		netConn, err = through.DialContext(ctx, "tcp", target)
252
	}
253
	if err != nil {
254
		return nil, culpa.WithCode(
255
			culpa.Wrapf(err, "ssh: dial %s@%s", cfg.User, target), "SSH_DIAL")
256
	}
257
258
	clientConn, chans, reqs, err := xssh.NewClientConn(netConn, target, cfg)
259
	if err != nil {
260
		_ = netConn.Close()
261
		return nil, culpa.WithCode(
262
			culpa.Wrapf(err, "ssh: handshake %s@%s", cfg.User, target), "SSH_HANDSHAKE")
263
	}
264
	return xssh.NewClient(clientConn, chans, reqs), nil
265
}
266
267
// clientConfig builds a per-dial ClientConfig. The auth list is composed freshly
268
// each call — d.auths plus any per-host identity — so a Dialer reused across
269
// hosts under concurrent fan-out never accumulates another host's identity (IV5).
270
//
271
// HostKeyAlgorithms is left unset here and filled in by dialHop, which owns the
272
// target address the host key callback will be checked against.
273
func (d *Dialer) clientConfig(user string, identity xssh.AuthMethod) *xssh.ClientConfig {
274
	auths := make([]xssh.AuthMethod, 0, len(d.auths)+1)
275
	auths = append(auths, d.auths...)
276
	if identity != nil {
277
		auths = append(auths, identity)
278
	}
279
	return &xssh.ClientConfig{
280
		User:            user,
281
		Auth:            auths,
282
		HostKeyCallback: d.hostKeyCallback,
283
		Timeout:         d.timeout,
284
	}
285
}
286
287
// jumpChain returns the ordered jump hosts for h: an explicit WithProxyJump if
288
// set, else the target's ProxyJump from ~/.ssh/config when WithSSHConfig is on.
289
func (d *Dialer) jumpChain(h *legatus.Host) ([]jumpHost, error) {
290
	if d.proxyJump != "" {
291
		return parseJumpSpec(d.proxyJump)
292
	}
293
	if d.useSSHConfig {
294
		return parseJumpSpec(d.cfgGet(h.Name, "ProxyJump"))
295
	}
296
	return nil, nil
297
}
298
299
// resolveJump resolves a jump hop's address/port/user/identity, layering
300
// ~/.ssh/config under the hop's explicit fields and defaulting the port to 22.
301
func (d *Dialer) resolveJump(j jumpHost) (addr string, port int, user string, identity xssh.AuthMethod) {
302
	addr, port, user = j.host, j.port, j.user
303
	if d.useSSHConfig {
304
		addr, port, user, identity = d.resolveConfig(j.host, addr, port, user)
305
	}
306
	if addr == "" {
307
		addr = j.host
308
	}
309
	if port == 0 {
310
		port = 22
311
	}
312
	return addr, port, user, identity
313
}
314
315
// closeAll closes a set of already-opened jump clients, used to unwind a chain
316
// when a later hop fails so no tunnel is leaked.
317
func closeAll(clients []*xssh.Client) {
318
	for i := len(clients) - 1; i >= 0; i-- {
319
		_ = clients[i].Close()
320
	}
321
}
322
323
// agentSigners returns the signers held by the ssh-agent at $SSH_AUTH_SOCK. It
324
// fails loudly when the agent is unset, unreachable, or empty — never falling
325
// back to another auth method (AS1). Key material stays inside the agent (IV5).
326
func agentSigners() ([]xssh.Signer, error) {
327
	sock := os.Getenv("SSH_AUTH_SOCK")
328
	if sock == "" {
329
		return nil, culpa.WithCode(
330
			culpa.WithHint(
331
				culpa.New("ssh: SSH_AUTH_SOCK unset"),
332
				"start ssh-agent and add your key with `ssh-add`",
333
			), "SSH_AGENT")
334
	}
335
	conn, err := net.Dial("unix", sock)
336
	if err != nil {
337
		return nil, culpa.WithCode(
338
			culpa.Wrapf(err, "ssh: connect ssh-agent at %s", sock), "SSH_AGENT")
339
	}
340
	signers, err := agent.NewClient(conn).Signers()
341
	if err != nil {
342
		return nil, culpa.WithCode(
343
			culpa.Wrap(err, "ssh: list ssh-agent signers"), "SSH_AGENT")
344
	}
345
	if len(signers) == 0 {
346
		return nil, culpa.WithCode(
347
			culpa.WithHint(
348
				culpa.New("ssh: ssh-agent has no keys"),
349
				"run `ssh-add` to load a key",
350
			), "SSH_AGENT")
351
	}
352
	return signers, nil
353
}
354
355
// identityFileAuth builds an AuthMethod that reads and parses the private key at
356
// path on each handshake attempt. A missing key or one needing a passphrase is a
357
// hard error (AS1 — no interactive prompt). The raw key bytes are handed only to
358
// the ssh parser and never logged (IV5).
359
func identityFileAuth(path string) xssh.AuthMethod {
360
	return xssh.PublicKeysCallback(func() ([]xssh.Signer, error) {
361
		p := expandTilde(path)
362
		data, err := os.ReadFile(p)
363
		if err != nil {
364
			return nil, culpa.WithCode(
365
				culpa.Wrapf(err, "ssh: read identity file %q", path), "SSH_IDENTITY")
366
		}
367
		signer, err := xssh.ParsePrivateKey(data)
368
		if err != nil {
369
			return nil, culpa.WithCode(
370
				culpa.WithHint(
371
					culpa.Wrapf(err, "ssh: parse identity file %q", path),
372
					"key may be encrypted; add it to ssh-agent and use WithAgent",
373
				), "SSH_IDENTITY")
374
		}
375
		return []xssh.Signer{signer}, nil
376
	})
377
}
378
379
// resolveConfig resolves an alias through ~/.ssh/config, layering config values
380
// UNDER the caller's explicit fields: a non-empty addr/port/user always wins.
381
// An empty alias leaves everything untouched. The resolved IdentityFile, if any,
382
// is RETURNED as an auth method — never appended onto the shared Dialer — so a
383
// Dialer reused across hosts under concurrent fan-out resolves each host
384
// independently (IV5).
385
func (d *Dialer) resolveConfig(alias, addr string, port int, user string) (string, int, string, xssh.AuthMethod) {
386
	if alias == "" {
387
		return addr, port, user, nil
388
	}
389
	if addr == "" {
390
		addr = strings.TrimSpace(d.cfgGet(alias, "HostName"))
391
	}
392
	if port == 0 {
393
		if ps := strings.TrimSpace(d.cfgGet(alias, "Port")); ps != "" {
394
			if pv, err := strconv.Atoi(ps); err == nil {
395
				port = pv
396
			}
397
		}
398
	}
399
	if user == "" {
400
		user = strings.TrimSpace(d.cfgGet(alias, "User"))
401
	}
402
	var identity xssh.AuthMethod
403
	if idf := strings.TrimSpace(d.cfgGet(alias, "IdentityFile")); idf != "" {
404
		identity = identityFileAuth(idf)
405
	}
406
	return addr, port, user, identity
407
}
408
409
// expandTilde expands a leading ~/ (or bare ~) to the user's home directory. A
410
// failure to resolve home leaves the path unchanged so the subsequent open
411
// raises a concrete file error rather than a swallowed one.
412
func expandTilde(p string) string {
413
	if p == "~" || strings.HasPrefix(p, "~/") {
414
		if home, err := os.UserHomeDir(); err == nil {
415
			return filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(p, "~"), "/"))
416
		}
417
	}
418
	return p
419
}
420

Source Files