Automating interactive shell input in Go

3 minute read

Once every several weeks, I take a moment to review and optimize my daily workflow, identifying bottlenecks or repetitive tasks that can be automated. This time I’m trying to tackle one of the most tedious tasks that I do a lot daily: logging in to a VPN using OTP sent via email.

Why?

Context-switching

I have several VPN profiles and need to switch back and forth between them at work. Opening emails and hunting down the OTP code for each VPN session slows me down, especially in tight situations like when firefighting in an incident.

I know automating 2FA sounds like defeating its very purpose, but I still do it anyway for convenience and educational purposes. Let’s see how I’m doing it.

Analyze before automate

Most of the time, automating shell input can be as simple as:

echo 'y' | ./interactive-setup.sh

Or even better, keep sending y to the process’ stdin:

yes | ./interactive-setup.sh

Yes, “yes” is a standard unix command.

So, why can’t we just do something like this? (note that oathtool is a CLI tool for generating time-based OTP)

oathtool --totp --base64 OTPKEY | openfortivpn -c prod.cfg

It can’t be done because:

  • I don’t have the key used to generate the OTP
  • The new OTP generated EVERYTIME AFTER the connection initiated
  • The generated OTPs are only sent via email, there is no other way

Let’s see how openfortivpn command gets invoked:

❯ sudo openfortivpn -c prod.cfg
INFO:   Connected to gateway.
Two-factor authentication token:🔑

The server generates and sends the OTP via email right before prompting for it. Also because it gets regenerated for each connection attempt, wrong input will somehow “invalidate” the already sent OTP (i.e. after a typo and fail, you cannot simply reconnect and input the correct OTP from the previous attempt).

My solution

I have looked at Unix’s Expect also its Go implementation by Google, but not interested in both. I decided to implement my logic in Go for learning purposes.

The program will execute openfortivpn and automatically input the OTP from my email (via IMAP). I already set the filter in my Gmail account so all OTP emails will be automatically moved to a dedicated label/folder named “OTP”.

Here’s the logic outline:

  1. Spawn openfortivpn client in the background (let’s call it “the process”)
  2. Forward process stdout to the terminal while monitoring it for the input prompt (“Two-factor authentication token:” string)
  3. When the prompt is detected, run a function to fetch email and extract the OTP
  4. Write the OTP to the process stdin, then send a new line (like pressing Return)

It seems simple, but I learned quite a lot along the way. It taught me about how to interact with subprocess’s IO stream in Go, also how to properly use goroutines and channels (the hardest part of Go for me to understand).

Here’s the simplified code.

import (
	"bufio"
	"fmt"
	"io"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
)

func connect() {
	configFile := "prod.cfg"
	promptString := "Two-factor authentication token:"

	// Prepare command
	cmd := exec.Command("openfortivpn", "-c", configFile)
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		log.Fatal(err)
	}
	stdin, err := cmd.StdinPipe()
	if err != nil {
		log.Fatal(err)
	}
	cmd.Stderr = os.Stderr

	// Start command
	if err := cmd.Start(); err != nil {
		log.Fatal(err)
	}
	defer cmd.Wait()

	// Wait for OTP prompt
	promptDetected := func(bytes []byte) bool {
		frags := strings.Split(string(bytes), "\n")
		if len(frags) == 0 {
			return false
		}

		last := frags[len(frags)-1]

		return strings.HasPrefix(last, promptString)
	}
	prompt := make(chan bool, 1)
	go func(ch chan<- bool) {
		scanner := bufio.NewScanner(stdout)
		scanner.Split(bufio.ScanBytes)

		buff := []byte{}
		for scanner.Scan() {
			bytes := scanner.Bytes()
			fmt.Print(string(bytes))
			buff = append(buff, bytes...)
			if promptDetected(buff) {
				ch <- true
			}
		}
	}(prompt)
	<-prompt

	fmt.Println("Getting OTP")
	otp, err := fetchOtpFromEmail() // delegate it to another function
	if err != nil {
		log.Fatal(err)
	}

	// Send input to the prompt
	io.WriteString(stdin, otp)
	io.WriteString(stdin, "\n")
}

The complete code is on my GitHub project. It’s usable and configurable for your use.

Mentions

Comments