File: lstreams_resolver.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 (723 lines) | stat: -rw-r--r-- 20,696 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
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
package core

import (
	"fmt"
	"strings"

	"github.com/dimonomid/nerdlog/shellescape"
	"github.com/dimonomid/ssh_config"
	"github.com/gobwas/glob"
	"github.com/juju/errors"
)

type LStreamsResolver struct {
	params LStreamsResolverParams
}

type LStreamsResolverParams struct {
	// CurOSUser is the current OS username, it's used as the last resort when
	// determining the user for a particular host connection.
	CurOSUser string

	// If UseExternalSSH is true, we'll use external ssh binary instead of
	// internal ssh library (i.e. ShellTransportSSHBin instead of
	// ShellTransportSSHLib).
	UseExternalSSH bool

	// ConfigLogStreams is the nerdlog-specific config, typically coming from
	// ~/.config/nerdlog/logstreams.yaml.
	ConfigLogStreams ConfigLogStreams

	// SSHConfig is the general SSH config, typically coming from ~/.ssh/config
	SSHConfig *ssh_config.Config
}

func NewLStreamsResolver(params LStreamsResolverParams) *LStreamsResolver {
	return &LStreamsResolver{
		params: params,
	}
}

type LogStream struct {
	// Name is an arbitrary string which will be included in log messages as the
	// "lstream" context tag; it must uniquely identify the LogStream.
	Name string

	// NOTE: all fields below are shell-specific; so if at some point we want
	// to support other kinds of backends, we'll probably need to factor all
	// these details to a separate struct like LogStreamShell or something.

	// Transport specifies how we can get shell access to the host containing
	// the logstream.
	Transport ConfigLogStreamShellTransport

	// LogFiles contains a list of files which are part of the logstream, like
	// ["/var/log/syslog", "/var/log/syslog.1"]. The [0]th item is the latest log
	// file [1]st is the previous one, etc. One special case here is journalctl:
	// if [0]th item is "journalctl", then we won't use plain log files, and
	// instead will get the data straight from journalctl.
	//
	// It must contain at least a single item, otherwise LogStream is invalid.
	LogFiles []string

	Options LogStreamOptions
}

// ConfigLogStreamShellTransportSSHLib contains params for the ssh transport
// using internal ssh library.
type ConfigLogStreamShellTransportSSHLib struct {
	Host     ConfigHost
	Jumphost *ConfigHost
}

// ConfigLogStreamShellTransportSSHBin contains params for the ssh transport
// using external ssh binary.
type ConfigLogStreamShellTransportSSHBin struct {
	// Host is just the hostname, like "myserver.com" or an IP address.
	Host string

	// Port is optional: if present, it'll be passed to the ssh binary using
	// the -p flag, otherwise the -p flag will not be given at all.
	Port string
	// User is optional: if present, the destination param to the ssh binary
	// will be prefixed with "<user>@", otherwise the destination will be just
	// the Host.
	User string
}

type ConfigLogStreamShellTransportLocalhost struct {
	// No details are needed here
}

type ConfigLogStreamShellTransport struct {
	SSHLib    *ConfigLogStreamShellTransportSSHLib
	SSHBin    *ConfigLogStreamShellTransportSSHBin
	Localhost *ConfigLogStreamShellTransportLocalhost
}

type LogStreamOptions struct {
	SudoMode SudoMode

	// ShellInit can contain arbitrary shell commands which will be executed
	// right after connecting to the host. A common use case is setting
	// custom env vars for tests, like: "export TZ=America/New_York", but
	// might be useful outside of tests as well.
	ShellInit []string
}

// SudoMode can be used to configure nerdlog to read log files with "sudo -n".
// See constants below for more details.
type SudoMode string

const (
	// SudoModeNone is the same as an empty string, and it obviously means that
	// no sudo will be used. It exists as a separate mode to make it possible to
	// override the mode to not use sudo even if some config specifies some
	// non-empty sudo mode.
	SudoModeNone SudoMode = "none"

	// SudoModeFull means that the whole nerdlog_agent.sh script will be executed
	// with "sudo -n". Useful for cases when the log files are owned by root but
	// sudo doesn't require a password.
	SudoModeFull SudoMode = "full"

	// If needed, we might implement something like SudoModeGranular, which would
	// mean that the agent script runs without sudo, but then internally it
	// executes some commands with sudo. It would mean a more complicated setup
	// and more maintenance burden and harder to configure sudoers file, so
	// postponed until we actually need it (if at all).
)

var ValidSudoModes = map[SudoMode]struct{}{
	SudoModeNone: {},
	SudoModeFull: {},
}

type ConfigHost struct {
	// Addr is the address to connect to, in the same format which is used by
	// net.Dial. To copy-paste some docs from net.Dial: the address has the form
	// "host:port". The host must be a literal IP address, or a host name that
	// can be resolved to IP addresses. The port must be a literal port number or
	// a service name.
	//
	// Examples: "golang.org:http", "192.0.2.1:http", "198.51.100.1:22".
	Addr string
	// User is the username to authenticate as.
	User string
}

func (ch *ConfigHost) Key() string {
	return fmt.Sprintf("%s@%s", ch.Addr, ch.User)
}

func (ls LogStream) LogFileLast() string {
	// LogFiles must contain at least a single item, so we don't check anything
	// here, and let it panic naturally if the invariant breaks due to some bug.
	return ls.LogFiles[0]
}

func (ls LogStream) LogFilePrev() (string, bool) {
	if len(ls.LogFiles) >= 2 {
		return ls.LogFiles[1], true
	}

	return "", false
}

// Resolve parses the given logstream spec, and returns the mapping from
// LogStream.Name to the corresponding LogStream. Examples of logstream spec are:
//
// - "myuser@myserver.com:22:/var/log/syslog"
// - "myuser@myserver.com:22"
// - "myuser@myserver.com"
// - "myserver.com"
func (r *LStreamsResolver) Resolve(lstreamsStr string) (map[string]LogStream, error) {
	lstreamsStr = strings.TrimSpace(lstreamsStr)

	parsedLogStreams := map[string]LogStream{}

	// Special case for an empty input: it's allowed and just results in no
	// logstreams.
	if lstreamsStr == "" {
		return parsedLogStreams, nil
	}

	// TODO: when json is supported, splitting by commas will need to be improved.
	parts := strings.Split(lstreamsStr, ",")
	for i, part := range parts {
		part = strings.TrimSpace(part)

		if part == "" {
			return nil, errors.Errorf("entry #%d is empty", i+1)
		}

		cfs, err := r.parseLogStreamSpecEntry(part)
		if err != nil {
			return nil, errors.Annotatef(err, "parsing entry #%d (%s)", i+1, part)
		}

		for _, ch := range cfs {
			key := ch.Name

			if _, exists := parsedLogStreams[key]; exists {
				return nil, errors.Errorf("the logstream %s is present at least twice", key)
			}

			parsedLogStreams[key] = ch
		}
	}

	return parsedLogStreams, nil
}

// draftLogStream is a draft version of LogStream; it's used as temporary
// storage in the process of resolving logstreams.
type draftLogStream struct {
	name     string
	host     ConfigHost
	jumphost *ConfigHost
	logFiles []string
	options  LogStreamOptions
}

// parseLogStreamSpecEntry parses a single logstream spec entry like
// "myuser@myserver.com:22:/var/log/syslog", or "myserver.com", or
// "myserver-*", and returns the corresponding LogStream-s. Note that the spec
// might contain a glob, in which case we might return more than 1 LogStream.
// If the glob didn't match anything, an error is returned.
//
// TODO: it should take a predefined config, to support globs
func (r *LStreamsResolver) parseLogStreamSpecEntry(s string) ([]LogStream, error) {
	parts, err := shellescape.Parse(s)
	if err != nil {
		return nil, errors.Trace(err)
	}

	var plstream *parsedLStream
	var jhconf *ConfigHost
	var logFiles []string

	curFlag := ""
	for _, part := range parts {
		if curFlag == "" && len(part) > 0 && part[0] == '-' {
			curFlag = part
			continue
		}

		switch curFlag {
		case "-J", "--jumphost":
			jhparsed, err := r.parseLStreamStr(part)
			if err != nil {
				return nil, errors.Annotatef(err, "parsing %q as a jumphost", part)
			}

			//if jhparsed.logFileLast != "" || jhparsed.logFilePrev != "" {
			//return nil, errors.Annotatef(err, "jumphost config shouldn't contain files")
			//}

			jhPort := jhparsed.port

			if len(jhparsed.colonParts) > 1 {
				return nil, errors.Errorf("parsing %q as a jumphost: too many colons", part)
			}

			jhconf = &ConfigHost{
				Addr: fmt.Sprintf("%s:%s", jhparsed.hostname, jhPort),
				User: jhparsed.user,
			}

		case "":
			var err error
			plstream, err = r.parseLStreamStr(part)
			if err != nil {
				return nil, errors.Annotatef(err, "parsing %q as a logstream", part)
			}

			if len(plstream.colonParts) > 0 {
				logFiles = append(logFiles, plstream.colonParts[0])
			}

			if len(plstream.colonParts) > 1 {
				logFiles = append(logFiles, plstream.colonParts[1])
			}

			if len(plstream.colonParts) > 2 {
				return nil, errors.Errorf("%q: too many colons", part)
			}
		default:
			return nil, errors.Errorf("invalid flag %s", curFlag)
		}

		curFlag = ""
	}

	if plstream == nil {
		return nil, errors.Errorf("no logstream specified in %q", s)
	}

	lstreams := []draftLogStream{
		{
			name: s,

			host: ConfigHost{
				Addr: fmt.Sprintf("%s:%s", plstream.hostname, plstream.port),
				User: plstream.user,
			},
			jumphost: jhconf,

			logFiles: logFiles,
		},
	}

	// Expand from nerdlog config.
	lstreams, err = expandFromLogStreamsConfig(
		lstreams, r.params.ConfigLogStreams, expandOpts{},
	)
	if err != nil {
		return nil, errors.Annotatef(err, "expanding from nerdlog config")
	}

	// Expand from ssh config.
	lsConfigFromSSHConfig, err := sshConfigToLSConfig(r.params.SSHConfig)
	if err != nil {
		return nil, errors.Annotatef(err, "parsing ssh config")
	}

	lstreams, err = expandFromLogStreamsConfig(
		lstreams, lsConfigFromSSHConfig, expandOpts{
			// If using internal ssh library, then expand everything as usual: expand
			// globs and fill in the missing details. But if using external ssh, then
			// only expand globs, and leave filling all the missing connection
			// details up to ssh.
			skipFillingConnDetails: r.params.UseExternalSSH,
		},
	)
	if err != nil {
		return nil, errors.Annotatef(err, "expanding from ssh config")
	}

	if !r.params.UseExternalSSH {
		// We're not using external ssh binary, so also try to fill in the
		// details from the parsed ssh config, and then from the defaults too.

		lstreams, err = setLogStreamsConnDefaults(lstreams, r.params.CurOSUser)
		if err != nil {
			return nil, errors.Annotatef(err, "setting defaults")
		}
	} else {
		// We're using external ssh binary: in this case, we don't fill in the
		// details from ssh config or from the defaults manually, and instead leave
		// all this up to the external ssh binary.
	}

	// Regardless of the external or internal ssh, we still need to fill in
	// the default logfiles.
	lstreams, err = setLogStreamsFileDefaults(lstreams)
	if err != nil {
		return nil, errors.Annotatef(err, "setting defaults")
	}

	// Check if some of the items were clearly indended to be globs matching
	// something (those with asterisks in them), and didn't match anything.
	for _, ls := range lstreams {
		// TODO: would perhaps be useful to implement a function like IsValidDialAddress,
		// which checks a bunch of other things, but for now, a single asterisk check
		// will do.
		if strings.Contains(ls.host.Addr, "*") {
			return nil, errors.Errorf("glob %q didn't match anything (having address %q)", s, ls.host.Addr)
		}
	}

	// Convert draft logstreams to the actual ones.
	ret := make([]LogStream, 0, len(lstreams))
	for _, ls := range lstreams {

		var transport ConfigLogStreamShellTransport
		// Using kinda hackish logic: if the hostname part is "localhost", then
		// ignore the port and user completely, and just use local shell.
		//
		// Maybe we need to treat some other strings similarly, like
		// "localhost.localdomain", or "127.0.0.1", or "::1"; but not sure if it
		// would actually bring any value. So for now, only "localhost" has this
		// special treatment (which is also the default when one opens Nerdlog for
		// the first time).
		if strings.HasPrefix(ls.host.Addr, "localhost:") {
			// Use local shell
			transport = ConfigLogStreamShellTransport{
				Localhost: &ConfigLogStreamShellTransportLocalhost{},
			}
		} else {
			// Use ssh
			if !r.params.UseExternalSSH {
				// Use internal ssh library
				transport = ConfigLogStreamShellTransport{
					SSHLib: &ConfigLogStreamShellTransportSSHLib{
						Host:     ls.host,
						Jumphost: ls.jumphost,
					},
				}
			} else {
				// Use external ssh binary
				parsedAddr, err := parseAddr(ls.host.Addr)
				if err != nil {
					return nil, errors.Annotatef(err, "parsing addr %s for external ssh binary", ls.host.Addr)
				}

				transport = ConfigLogStreamShellTransport{
					SSHBin: &ConfigLogStreamShellTransportSSHBin{
						Host: parsedAddr.host,
						Port: parsedAddr.port,
						User: ls.host.User,
					},
				}
			}
		}

		ret = append(ret, LogStream{
			Name:      ls.name,
			Transport: transport,
			LogFiles:  ls.logFiles,
			Options:   ls.options,
		})
	}

	return ret, nil
}

type parsedLStream struct {
	hostname string
	user     string
	port     string

	colonParts []string
}

func (r *LStreamsResolver) parseLStreamStr(s string) (*parsedLStream, error) {
	// Parsing the logstream descriptor like
	// "user@hostname:/path/to/logfile:/path/to/logfile.1"

	// Parse user, if present
	username := ""
	atIdx := strings.IndexRune(s, '@')
	if atIdx == 0 {
		return nil, errors.Errorf("username is empty")
	} else if atIdx > 0 {
		username = s[:atIdx]
		s = s[atIdx+1:]
	}

	port := ""
	colonParts := []string{}
	parts := strings.Split(s, ":")
	if len(parts) == 0 || parts[0] == "" {
		return nil, errors.Errorf("no hostname")
	}

	if len(parts) > 1 {
		port = parts[1]
	}

	if len(parts) > 2 {
		colonParts = parts[2:]
	}

	return &parsedLStream{
		hostname:   parts[0],
		user:       username,
		port:       port,
		colonParts: colonParts,
	}, nil
}

type ConfigLogStreamWKey struct {
	// Key is the key at which the corresponding ConfigLogStream was
	// stored in the ConfigLogStreams map.
	Key string

	ConfigLogStream
}

type expandOpts struct {
	// If skipFillingConnDetails is true, then the connection details (host,
	// port, username) will not be filled from the config. This must be set to
	// true when the config was parsed from the ssh config and we're using
	// external ssh binary: in this case, filling all the missing details should
	// be left up to the external ssh binary.
	skipFillingConnDetails bool
}

// expandFromLogStreamsConfig goes through each of the logstreams, and
// potentially expands every item as per the provided config.
func expandFromLogStreamsConfig(
	logStreams []draftLogStream,
	lsConfig ConfigLogStreams,
	opts expandOpts,
) ([]draftLogStream, error) {
	// If there's no config, cut it short.
	if lsConfig == nil {
		return logStreams, nil
	}

	var ret []draftLogStream

	for i, ls := range logStreams {
		var matchedConfigItems []*ConfigLogStreamWKey

		addr, err := parseAddr(ls.host.Addr)
		if err != nil {
			return nil, errors.Annotatef(err, "logstream #%d, parsing address", i+1)
		}

		globPattern := addr.host
		matcher, err := glob.Compile(globPattern)
		if err != nil {
			return nil, errors.Annotatef(err, "logstream #%d, parsing hostname %q as a glob pattern", i+1, addr.host)
		}

		for _, key := range lsConfig.Keys() {
			if matcher.Match(key) {
				matchedConfigItems = append(matchedConfigItems, &ConfigLogStreamWKey{
					Key:             key,
					ConfigLogStream: lsConfig[key],
				})
			}
		}

		// If there's no match, just copy that logstream unchanged.
		if len(matchedConfigItems) == 0 {
			ret = append(ret, ls)
			continue
		}

		// There are some matches, so we need to expand things.
		for _, matchedItem := range matchedConfigItems {
			lsCopy := ls
			addrCopy := addr

			// Always override the name with the key from the config.
			lsCopy.name = strings.Replace(lsCopy.name, globPattern, matchedItem.Key, -1)

			if !opts.skipFillingConnDetails {
				// Overwrite the host address (since what we've had might be a glob):
				// either with the Hostname if it's specified explicitly, or if not, then
				// with the item key.
				if matchedItem.Hostname != "" {
					addrCopy.host = matchedItem.Hostname
				} else {
					addrCopy.host = matchedItem.Key
				}

				// Everything else we'll only override if it's not specified already.

				if addrCopy.port == "" {
					addrCopy.port = matchedItem.Port
				}

				if lsCopy.host.User == "" {
					lsCopy.host.User = matchedItem.User
				}
			} else {
				// We're asked to skip filling conn details, so just replace the host
				// with the matched item's key (because or original host might have
				// been a glob), and leave the port and user as is.
				addrCopy.host = matchedItem.Key
			}

			// For non-connection details, override them if not specified already.

			if lsCopy.options.SudoMode == "" {
				lsCopy.options.SudoMode = matchedItem.Options.EffectiveSudoMode()
			}

			if lsCopy.options.ShellInit == nil {
				lsCopy.options.ShellInit = matchedItem.Options.ShellInit
			}

			if len(lsCopy.logFiles) == 0 {
				lsCopy.logFiles = matchedItem.LogFiles
			}

			lsCopy.host.Addr = fmt.Sprintf("%s:%s", addrCopy.host, addrCopy.port)

			ret = append(ret, lsCopy)
		}
	}

	return ret, nil
}

// setLogStreamsConnDefaults goes through each of the logstreams, and fills in
// missing pieces for which it knows the defaults for how to connect to the
// hosts: port 22, user as the current OS user.
//
// Note that it shouldn't be used when we're utilizing external ssh binary:
// in this case, we want to leave it all up to the ssh.
func setLogStreamsConnDefaults(
	logStreams []draftLogStream,
	osUser string,
) ([]draftLogStream, error) {
	ret := make([]draftLogStream, 0, len(logStreams))

	for i, ls := range logStreams {
		port, err := portFromAddr(ls.host.Addr)
		if err != nil {
			return nil, errors.Annotatef(err, "logstream #%d, getting port", i+1)
		}

		if port == "" {
			ls.host.Addr += "22"
		}

		if ls.host.User == "" {
			ls.host.User = osUser
		}

		ret = append(ret, ls)
	}

	return ret, nil
}

// setLogStreamsFileDefaults goes through each of the logstreams, and fills in
// missing non-connection pieces, such as default log files.
func setLogStreamsFileDefaults(logStreams []draftLogStream) ([]draftLogStream, error) {
	ret := make([]draftLogStream, 0, len(logStreams))

	for _, ls := range logStreams {
		if len(ls.logFiles) == 0 {
			// Will be autodetected by the agent script.
			ls.logFiles = append(ls.logFiles, "auto")
		}

		if len(ls.logFiles) == 1 {
			// Will be autodetected by the agent script.
			ls.logFiles = append(ls.logFiles, "auto")
		}

		ret = append(ret, ls)
	}

	return ret, nil
}

type parsedAddr struct {
	host string
	port string
}

func parseAddr(addr string) (parsedAddr, error) {
	parts := strings.Split(addr, ":")
	if len(parts) != 2 {
		return parsedAddr{}, errors.Errorf("not a valid addr %q, expected host:port", addr)
	}

	return parsedAddr{
		host: parts[0],
		port: parts[1],
	}, nil
}

// hostnameFromAddr takes an address like net.Dial takes, in the form of
// "host:port", and returns the host part.
func hostnameFromAddr(addr string) (string, error) {
	parts := strings.Split(addr, ":")
	if len(parts) != 2 {
		return "", errors.Errorf("not a valid addr %q, expected host:port", addr)
	}

	return parts[0], nil
}

// portFromAddr takes an address like net.Dial takes, in the form of
// "host:port", and returns the port part.
func portFromAddr(addr string) (string, error) {
	parts := strings.Split(addr, ":")
	if len(parts) != 2 {
		return "", errors.Errorf("not a valid addr %q, expected host:port", addr)
	}

	return parts[1], nil
}

func sshConfigToLSConfig(sshConfig *ssh_config.Config) (ConfigLogStreams, error) {
	if sshConfig == nil {
		return nil, nil
	}

	ret := make(ConfigLogStreams, len(sshConfig.Hosts))

	for _, host := range sshConfig.Hosts {
		if len(host.Patterns) == 0 {
			continue
		}

		name := host.Patterns[0].String()
		if name == "" {
			continue
		}

		// If it's a pattern, ignore it
		// (there might be valid use cases where we'd want to use them, but
		// not bothering for now)
		if strings.ContainsAny(name, "*?[]") {
			continue
		}

		hostname, _ := sshConfig.Get(name, "HostName")
		port, _ := sshConfig.Get(name, "Port")
		user, _ := sshConfig.Get(name, "User")

		if hostname == "" && port == "" && user == "" {
			// We can't get anything useful out of this entry anyway, so don't add it
			continue
		}

		ret[name] = ConfigLogStream{
			Hostname: hostname,
			Port:     port,
			User:     user,
		}
	}

	return ret, nil
}