dialer.go

v0.6.0
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
	var netConn net.Conn
233
	var err error
234
	if through == nil {
235
		netConn, err = (&net.Dialer{Timeout: d.timeout}).DialContext(ctx, "tcp", target)
236
	} else {
237
		netConn, err = through.DialContext(ctx, "tcp", target)
238
	}
239
	if err != nil {
240
		return nil, culpa.WithCode(
241
			culpa.Wrapf(err, "ssh: dial %s@%s", cfg.User, target), "SSH_DIAL")
242
	}
243
244
	clientConn, chans, reqs, err := xssh.NewClientConn(netConn, target, cfg)
245
	if err != nil {
246
		_ = netConn.Close()
247
		return nil, culpa.WithCode(
248
			culpa.Wrapf(err, "ssh: handshake %s@%s", cfg.User, target), "SSH_HANDSHAKE")
249
	}
250
	return xssh.NewClient(clientConn, chans, reqs), nil
251
}
252
253
// clientConfig builds a per-dial ClientConfig. The auth list is composed freshly
254
// each call — d.auths plus any per-host identity — so a Dialer reused across
255
// hosts under concurrent fan-out never accumulates another host's identity (IV5).
256
func (d *Dialer) clientConfig(user string, identity xssh.AuthMethod) *xssh.ClientConfig {
257
	auths := make([]xssh.AuthMethod, 0, len(d.auths)+1)
258
	auths = append(auths, d.auths...)
259
	if identity != nil {
260
		auths = append(auths, identity)
261
	}
262
	return &xssh.ClientConfig{
263
		User:            user,
264
		Auth:            auths,
265
		HostKeyCallback: d.hostKeyCallback,
266
		Timeout:         d.timeout,
267
	}
268
}
269
270
// jumpChain returns the ordered jump hosts for h: an explicit WithProxyJump if
271
// set, else the target's ProxyJump from ~/.ssh/config when WithSSHConfig is on.
272
func (d *Dialer) jumpChain(h *legatus.Host) ([]jumpHost, error) {
273
	if d.proxyJump != "" {
274
		return parseJumpSpec(d.proxyJump)
275
	}
276
	if d.useSSHConfig {
277
		return parseJumpSpec(d.cfgGet(h.Name, "ProxyJump"))
278
	}
279
	return nil, nil
280
}
281
282
// resolveJump resolves a jump hop's address/port/user/identity, layering
283
// ~/.ssh/config under the hop's explicit fields and defaulting the port to 22.
284
func (d *Dialer) resolveJump(j jumpHost) (addr string, port int, user string, identity xssh.AuthMethod) {
285
	addr, port, user = j.host, j.port, j.user
286
	if d.useSSHConfig {
287
		addr, port, user, identity = d.resolveConfig(j.host, addr, port, user)
288
	}
289
	if addr == "" {
290
		addr = j.host
291
	}
292
	if port == 0 {
293
		port = 22
294
	}
295
	return addr, port, user, identity
296
}
297
298
// closeAll closes a set of already-opened jump clients, used to unwind a chain
299
// when a later hop fails so no tunnel is leaked.
300
func closeAll(clients []*xssh.Client) {
301
	for i := len(clients) - 1; i >= 0; i-- {
302
		_ = clients[i].Close()
303
	}
304
}
305
306
// agentSigners returns the signers held by the ssh-agent at $SSH_AUTH_SOCK. It
307
// fails loudly when the agent is unset, unreachable, or empty — never falling
308
// back to another auth method (AS1). Key material stays inside the agent (IV5).
309
func agentSigners() ([]xssh.Signer, error) {
310
	sock := os.Getenv("SSH_AUTH_SOCK")
311
	if sock == "" {
312
		return nil, culpa.WithCode(
313
			culpa.WithHint(
314
				culpa.New("ssh: SSH_AUTH_SOCK unset"),
315
				"start ssh-agent and add your key with `ssh-add`",
316
			), "SSH_AGENT")
317
	}
318
	conn, err := net.Dial("unix", sock)
319
	if err != nil {
320
		return nil, culpa.WithCode(
321
			culpa.Wrapf(err, "ssh: connect ssh-agent at %s", sock), "SSH_AGENT")
322
	}
323
	signers, err := agent.NewClient(conn).Signers()
324
	if err != nil {
325
		return nil, culpa.WithCode(
326
			culpa.Wrap(err, "ssh: list ssh-agent signers"), "SSH_AGENT")
327
	}
328
	if len(signers) == 0 {
329
		return nil, culpa.WithCode(
330
			culpa.WithHint(
331
				culpa.New("ssh: ssh-agent has no keys"),
332
				"run `ssh-add` to load a key",
333
			), "SSH_AGENT")
334
	}
335
	return signers, nil
336
}
337
338
// identityFileAuth builds an AuthMethod that reads and parses the private key at
339
// path on each handshake attempt. A missing key or one needing a passphrase is a
340
// hard error (AS1 — no interactive prompt). The raw key bytes are handed only to
341
// the ssh parser and never logged (IV5).
342
func identityFileAuth(path string) xssh.AuthMethod {
343
	return xssh.PublicKeysCallback(func() ([]xssh.Signer, error) {
344
		p := expandTilde(path)
345
		data, err := os.ReadFile(p)
346
		if err != nil {
347
			return nil, culpa.WithCode(
348
				culpa.Wrapf(err, "ssh: read identity file %q", path), "SSH_IDENTITY")
349
		}
350
		signer, err := xssh.ParsePrivateKey(data)
351
		if err != nil {
352
			return nil, culpa.WithCode(
353
				culpa.WithHint(
354
					culpa.Wrapf(err, "ssh: parse identity file %q", path),
355
					"key may be encrypted; add it to ssh-agent and use WithAgent",
356
				), "SSH_IDENTITY")
357
		}
358
		return []xssh.Signer{signer}, nil
359
	})
360
}
361
362
// resolveConfig resolves an alias through ~/.ssh/config, layering config values
363
// UNDER the caller's explicit fields: a non-empty addr/port/user always wins.
364
// An empty alias leaves everything untouched. The resolved IdentityFile, if any,
365
// is RETURNED as an auth method — never appended onto the shared Dialer — so a
366
// Dialer reused across hosts under concurrent fan-out resolves each host
367
// independently (IV5).
368
func (d *Dialer) resolveConfig(alias, addr string, port int, user string) (string, int, string, xssh.AuthMethod) {
369
	if alias == "" {
370
		return addr, port, user, nil
371
	}
372
	if addr == "" {
373
		addr = strings.TrimSpace(d.cfgGet(alias, "HostName"))
374
	}
375
	if port == 0 {
376
		if ps := strings.TrimSpace(d.cfgGet(alias, "Port")); ps != "" {
377
			if pv, err := strconv.Atoi(ps); err == nil {
378
				port = pv
379
			}
380
		}
381
	}
382
	if user == "" {
383
		user = strings.TrimSpace(d.cfgGet(alias, "User"))
384
	}
385
	var identity xssh.AuthMethod
386
	if idf := strings.TrimSpace(d.cfgGet(alias, "IdentityFile")); idf != "" {
387
		identity = identityFileAuth(idf)
388
	}
389
	return addr, port, user, identity
390
}
391
392
// expandTilde expands a leading ~/ (or bare ~) to the user's home directory. A
393
// failure to resolve home leaves the path unchanged so the subsequent open
394
// raises a concrete file error rather than a swallowed one.
395
func expandTilde(p string) string {
396
	if p == "~" || strings.HasPrefix(p, "~/") {
397
		if home, err := os.UserHomeDir(); err == nil {
398
			return filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(p, "~"), "/"))
399
		}
400
	}
401
	return p
402
}
403

Source Files