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 most tedious task that I do a lot on daily basis: logging in to 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 email and hunting down OTP code for each VPN session really slow me down, especially in tight situation like when firefighting in an incident.

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

Analyze before automate

Most of the times, 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 connection initiated
  • The generated OTP are only sent via email, there is no other way

Let’s see how openfortivpn command get 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 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 own logic in Go for learning purpose.

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 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 terminal while monitoring it for the input prompt (“Two-factor authentication token:” string)
  3. When the prompt detected, run a function to fetch email and extract the OTP
  4. Write the OTP to the process stdin, then send newline (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 own use.