Skip to content

Commit

Permalink
Add CLI KexAlgorithms and fix SSH with no authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
mpenning committed Nov 13, 2023
1 parent cafe660 commit fc6ae52
Show file tree
Hide file tree
Showing 4 changed files with 50 additions and 39 deletions.
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,11 @@ all:
go build -ldflags "-s -w" -o ssh_logger main.go
.PHONY: all

test:
make all
@echo "$(COL_GREEN)>> Test w/ no auth to route-views.routeviews.org$(COL_END)"
## ping with an IP address for a deterministic test timeout
ping -W1 -c2 4.2.2.2
## Run an SSH test to route-views.routeviews.org
./ssh_logger --yaml configs/route_views.yaml
.PHONY: test
26 changes: 12 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Read a `yaml` config, and loop through some pre-defined commands from the config

In short, log SSH Client output; batteries included.

- Pre-scripted command configs, per-host
- Pre-scripted YAML command configs, per-host
- Customizable CLI prompt detection
- Optionally-timestampped command logs (both UTC and local timezones)
- ping logs (requires elevated privileges)
Expand All @@ -16,20 +16,18 @@ In short, log SSH Client output; batteries included.
To build the [`ssh_logger`][1] client, you need to have:

- [Go][10]
- [`ssh`][6], [`sshpass`][7]. [OpenSSH][6] is required.
- [`ssh`][6] and [`sshpass`][7]; [OpenSSH][6] is required.
- [`libpcap-dev`][8] (Unix-like OS) / [npcap][9] (Windows) installed in your operating-system

It's all wrapped into one portable binary that you can install on any number of clients; this is a key advantage of developing in [Go][10] (for instance, compared with [Python][11]). Developing this in [Python][11] would result in a much slower runtime, the inability to reliabily sniff (because of slow execution), and you would need to download all build dependencies on every new [`ssh_logger`][1] client.

# Use case

Example of SSH into 127.0.0.1 as `mpenning`, loop through commands in [`configs/localhost.yaml`][2] with timestamps, and no pings:
- Example of SSH into 127.0.0.1 as `mpenning`, loop through commands in [`configs/localhost.yaml`][2] with timestamps, and no pings:
- ` ssh_log --yaml configs/localhost.yaml --verbose`

- ` ssh_log --yaml configs/localhost.yaml --verbose`

Example of using [`configs/localhost.yaml`][2], which will SSH as `mpenning`, loop through commands with timestamps, pings, and sniffer pcaps on `eth0`:

- `sudo ssh_log --yaml configs/localhost.yaml --verbose --pingCount 10 --sniff eth0`
- Example of using [`configs/localhost.yaml`][2], which will SSH as `mpenning`, loop through commands with timestamps, pings, and sniffer pcaps on `eth0`:
- `sudo ssh_log --yaml configs/localhost.yaml --verbose --pingCount 10 --sniff eth0`

# YAML Configuration Help

Expand Down Expand Up @@ -96,7 +94,7 @@ or
# Inspiration from real life
- Question: Why did you build a custom Go binary to log ssh sessions when you can simply log the output of an ssh session with the [`script`][4] command: `script -c 'ssh foo@bar' log.txt`?
- Answer: Key words above are "batteries included". Real ssh session drops often devolve into a basket of unfun and time-consuming tasks.
- Answer: Real SSH session drops often devolve into a list of time-consuming tasks.
Assume ssh sessions are dropping on your production database server; that's an important problem to solve, especially if the network is dropping traffic (which means your database sessions themselves are slowing down from network packet drops).
Expand All @@ -108,12 +106,12 @@ Assume ssh sessions are dropping on your production database server; that's an i
6. I now get to scrounge around for spare PC(s) to use as sniffers because nobody invested ahead of time in dedicated sniffer appliances; install linux on said PCs.
7. Once I have sniffer traces, the problem is not easily visible since SSH is encrypted, SSH / TCP keepalives can be intermixed with keystrokes, and TCP can batch packets together (i.e. if it uses TCP Nagle)
[`ssh_logger`][1] helps provide proactive evidence for the problem:
Nevertheless, [`ssh_logger`][1] helps provide proactive evidence for the problem:
- Build timestampped command logs, in UTC and your local timezone
- Prompt detection
- ping logs from the SSH client
- sniffer logs from the SSH client
- It's easy to script common use-cases
- It builds timestampped command logs, in UTC and your local timezone
- It keeps ping logs from the SSH client
- It keeps sniffer logs from the SSH client
# License and Copyright
Expand Down
3 changes: 2 additions & 1 deletion configs/route_views.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@

ssh_logger:
timezone_location: "America/Chicago"
ssh_loop_sleep_seconds: 5
## Set ssh_loop_sleep_seconds to 0 for unit tests
ssh_loop_sleep_seconds: 0
ssh_user: "rviews"
ssh_host: "route-views.routeviews.org"
ssh_authentication: "none"
Expand Down
52 changes: 28 additions & 24 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type cliOpts struct {
yaml string
sniff string
sshKeepalive int
sshKexAlgorithms string
pingCount int
pingInterval int
pingSizeBytes int
Expand All @@ -59,13 +60,17 @@ type yamlConfig struct {

func main() {

keyExchangeAlgorithms := "ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group-exchange-sha1,diffie-hellman-group14-sha1,diffie-hellman-group1-sha1"

////////////////////////////////////////////////////////////////////////////
// parse CLI flags here
////////////////////////////////////////////////////////////////////////////
commandLogFilenamePtr := pflag.String("logFilename", "commands.log", "Name of the Netflix go-expect command logfile. Default is `commands.log`")
sniffPtr := pflag.String("sniff", "__UNDEFINED__", "Name of interface to sniff")
yamlPtr := pflag.String("yaml", "__UNDEFINED__", "Path to the YAML configuration file")
sshKeepalivePtr := pflag.Int("sshKeepalive", 60, "Specify ssh keepalive timeout, in seconds")
sshKexAlgorithms := pflag.String("sshKexAlgorithms", keyExchangeAlgorithms, "List of accepted KexAlgorithms")

pingCountPtr := pflag.Int("pingCount", 0, "Specify the number of pings")
pingIntervalPtr := pflag.Int("pingInterval", 200, "Specify the ping interval, in milliseconds")
pingSizeBytesPtr := pflag.Int("pingSize", 64, "Specify the ping size, in bytes")
Expand All @@ -83,6 +88,7 @@ func main() {
commandLogFilename: *commandLogFilenamePtr,
yaml: *yamlPtr,
sshKeepalive: *sshKeepalivePtr,
sshKexAlgorithms: *sshKexAlgorithms,
sniff: *sniffPtr,
pingCount: *pingCountPtr,
pingInterval: *pingIntervalPtr,
Expand Down Expand Up @@ -182,7 +188,7 @@ func main() {
ii := 0
for {
logoru.Debug(fmt.Sprintf("Starting SSH loop idx: %v", ii))
sshLoopSleepSeconds = sshLoginSession(opts, config)
sshLoopSleepSeconds = SshLoginSession(opts, config)
if sshLoopSleepSeconds == 0 {
break
} else {
Expand All @@ -193,7 +199,7 @@ func main() {
}
}

func sshLoginSession(opts cliOpts, config yamlConfig) int {
func SshLoginSession(opts cliOpts, config yamlConfig) int {

sshAuthentication := config.sshAuthentication
sshPromptRegex := config.sshPromptRegex
Expand Down Expand Up @@ -227,7 +233,7 @@ func sshLoginSession(opts cliOpts, config yamlConfig) int {
waitGroup := &sync.WaitGroup{}
if opts.sniff != "__UNDEFINED__" {
pcapFilterStr := fmt.Sprintf("host %v", config.sshHost)
go capturePackets(ctx, waitGroup, opts.sniff, pcapFilterStr)
go CapturePackets(ctx, waitGroup, opts.sniff, pcapFilterStr)
}

// define a UTC time location
Expand Down Expand Up @@ -287,12 +293,12 @@ func sshLoginSession(opts cliOpts, config yamlConfig) int {
return sshLoopSleepSeconds
}
}
// Call printPingStats()
printPingStats(stats, opts.pingSizeBytes)
// Call PrintPingStats()
PrintPingStats(stats, opts.pingSizeBytes)
}

// get an expect console, and debug if called as such...
console := newExpectConsole(opts.debug, logFile)
console := NewExpectConsole(opts.debug, logFile)
defer console.Close()
defer logFile.Close()

Expand All @@ -303,8 +309,8 @@ func sshLoginSession(opts cliOpts, config yamlConfig) int {
if opts.debug {
logoru.Debug(fmt.Sprintf("Calling `ssh -o %v -o %v %v`", keepAliveArg, keyExchangeArg, sshHostStr))
}
sshSession := exec.Command("ssh", "-o", keepAliveArg, "-o", keyExchangeArg, sshHostStr)
sshSession = spawnSshCmd(sshAuthentication, config.sshPassword, fmt.Sprint(opts.sshKeepalive), keyExchangAlgorithms, sshHostStr)
logoru.Debug(opts.sshKexAlgorithms)
sshSession := SpawnSshCmd(sshAuthentication, config.sshPassword, fmt.Sprint(opts.sshKeepalive), opts.sshKexAlgorithms, sshHostStr)

loginTimeStamp := fmt.Sprintf("\n~~~ LOGIN attempt to %v at %v / %v ~~~\n", sshHostStr, login.In(utcTimeZone), login.In(locationTimeZone))
_, err = logFile.WriteString(loginTimeStamp)
Expand All @@ -322,7 +328,7 @@ func sshLoginSession(opts cliOpts, config yamlConfig) int {
if err != nil {
logoru.Error(err)
}
logoru.Info(fmt.Sprintf("Spawned ssh to %v", config.sshHost))
logoru.Info(fmt.Sprintf("Spawned SSH session to %v", config.sshHost))

if sshAuthentication == "none" {
logoru.Debug("Logging in with no SSH authentication")
Expand Down Expand Up @@ -372,19 +378,17 @@ func sshLoginSession(opts cliOpts, config yamlConfig) int {
if sshPrivilegeCmd != "" {
logoru.Info(sshPrivilegeCmd)
// Send sshPrivilegeCmd once
logPrefixConsoleCmd(*console, *logFile, sshSession, opts.verboseTime, config.tzLocation, sshPromptRegex, prefixCmd, sshPrivilegeCmd)
LogPrefixConsoleCmd(*console, *logFile, sshSession, opts.verboseTime, config.tzLocation, sshPromptRegex, prefixCmd, sshPrivilegeCmd)
}
for idx, _ := range myCommands {
logoru.Info(myCommands[idx])
logPrefixConsoleCmd(*console, *logFile, sshSession, opts.verboseTime, config.tzLocation, sshPromptRegex, prefixCmd, myCommands[idx])
LogPrefixConsoleCmd(*console, *logFile, sshSession, opts.verboseTime, config.tzLocation, sshPromptRegex, prefixCmd, myCommands[idx])
}

logoru.Success("SSH Session finished")
logoru.Success(fmt.Sprintf("SSH session to %v finished", config.sshHost))
defer sshSession.Wait()
console.Tty().Close()

logoru.Info("SSH Output done")

// WriteString() a couple of blank lines
_, err = logFile.WriteString("\n\n")
if err != nil {
Expand Down Expand Up @@ -412,7 +416,7 @@ func sshLoginSession(opts cliOpts, config yamlConfig) int {

}

func newExpectConsole(debug bool, logFile *os.File) *expect.Console {
func NewExpectConsole(debug bool, logFile *os.File) *expect.Console {
/////////////////////////////////////////////////////////////////////////////
// Create a new Netflix go-expect console and return it...
/////////////////////////////////////////////////////////////////////////////
Expand All @@ -434,10 +438,10 @@ func newExpectConsole(debug bool, logFile *os.File) *expect.Console {
}
}

func logPrefixConsoleCmd(console expect.Console, logFile os.File, sshSession *exec.Cmd, verboseTime bool, locationStr string, sshPromptRegex string, prefixCmd string, cmd string) {
func LogPrefixConsoleCmd(console expect.Console, logFile os.File, sshSession *exec.Cmd, verboseTime bool, locationStr string, sshPromptRegex string, prefixCmd string, cmd string) {
////////////////////////////////////////////////////////////////////////////
//
// logPrefixConsoleCommand() can be used when you want to run two commands
// LogPrefixConsoleCommand() can be used when you want to run two commands
// together. This is useful as a hack around the lack of Cisco's
// `term exec prompt timestamp` VTY command.
//
Expand Down Expand Up @@ -532,13 +536,13 @@ func logPrefixConsoleCmd(console expect.Console, logFile os.File, sshSession *ex

}

func spawnSshCmd(authentication string, password string, keepalive string, keyalgorithms string, sshHostStr string) *exec.Cmd {
func SpawnSshCmd(authentication string, password string, keepalive string, keyalgorithms string, sshHostStr string) *exec.Cmd {
////////////////////////////////////////////////////////////////////////////
// Spawn an SSH session based on the type of authentication. no
// auhtentication or key authentication requires no password.
////////////////////////////////////////////////////////////////////////////
if authentication == "none" {
sshSession := exec.Command("ssh", sshHostStr)
sshSession := exec.Command("ssh", "-o", fmt.Sprintf("ServerAliveInterval=%v", keepalive), "-o", fmt.Sprintf("KexAlgorithms=%v", keyalgorithms), sshHostStr)
return sshSession
} else if authentication == "password" {
////////////////////////////////////////////////////////////////////////////
Expand All @@ -549,24 +553,24 @@ func spawnSshCmd(authentication string, password string, keepalive string, keyal
// different than how Don Libes' Expect handles ssh password prompts (it
// sees the ssh password without any special sshpass-kludge)
////////////////////////////////////////////////////////////////////////////
sshSession := exec.Command("sshpass", "-p", password, "ssh", sshHostStr)
sshSession := exec.Command("sshpass", "-p", password, "ssh", "-o", fmt.Sprintf("ServerAliveInterval=%v", keepalive), "-o", fmt.Sprintf("KexAlgorithms=%v", keyalgorithms), sshHostStr)
return sshSession
} else {
logoru.Critical(fmt.Sprintf("Authentication %v is not supported", authentication))
}
return nil
}

func capturePackets(ctx context.Context, waitGroup *sync.WaitGroup, iface, bpfFilter string) {
func CapturePackets(ctx context.Context, waitGroup *sync.WaitGroup, iface, bpfFilter string) {
waitGroup.Add(1)
defer waitGroup.Done()

for packet := range packets(ctx, waitGroup, iface, bpfFilter) {
for packet := range Packets(ctx, waitGroup, iface, bpfFilter) {
logoru.Debug(packet)
}
}

func packets(ctx context.Context, waitGroup *sync.WaitGroup, iface, bpfFilter string) chan gopacket.Packet {
func Packets(ctx context.Context, waitGroup *sync.WaitGroup, iface, bpfFilter string) chan gopacket.Packet {
maxMtu := 9000

fh, err := os.Create("session.pcap")
Expand Down Expand Up @@ -606,7 +610,7 @@ func packets(ctx context.Context, waitGroup *sync.WaitGroup, iface, bpfFilter st
return nil
}

func printPingStats(stats *probing.Statistics, pingSize int) {
func PrintPingStats(stats *probing.Statistics, pingSize int) {
fmt.Printf("\n--- %s ping statistics ---\n", stats.Addr)
fmt.Printf("%d %v byte packets transmitted, %d packets received, %v%% packet loss\n",
stats.PacketsSent, pingSize, stats.PacketsRecv, stats.PacketLoss)
Expand Down

0 comments on commit fc6ae52

Please sign in to comment.