Writing a custom controller for Astrill VPN on an Asus router

We're users of Astrill's VPN service in an unusual way - we run the application directly on our router instead of on our wirelessly connected devices. It solves a few problems for us:

The problem is, it's not convenient to connect, disconnect and check the status of connection like it is for regular software VPN clients. Here's what we do every time we need to access it:

  1. Open a web browser
  2. Navigate to the router IP
  3. Authenticate with username and password to the router
  4. Navigate to the Astrill VPN applet
  5. Connect, disconnect or check connection status

This made it very cumbersome for other people on the network to change the server as well.

On top of that, the experience of changing servers was inconsistent. Disconnecting from one server and attempting to connect to another would sometimes fail 2-3 times before a new connection was successful.

After several months of dealing with this frustrating piece of software I decided to have a look at the Astrill applet's network traffic and see what could be done.

A note on setup

Before going further, here's some details on our setup:

Understanding Astrill's CGI

While inspecting the network traffic of the Astrill VPN applet via the web interface, I found three distinct requests:

Here's a look at the HTTP request signatures (some things are redacted with X's or removed if irrelevant):

Connect request

Summary
URL: http://192.168.XX.X:XXXX/user/cgi-bin/astrill.cgi?token=XXXXXXXXXXXXXXXXXX
Status: 200 OK
Initiator: astrill.cgi:155

Request
POST /user/cgi-bin/astrill.cgi HTTP/1.1
Cookie: clickedItem_tab=0; asus_token=XXXXXXXXXXXXXXXXXX
Host: 192.168.XX.X:XXXX

Request Data
action: execute
command: nvram set astrill_serverid='XXX';nvram set astrill_sid='XXX';nvram set astrill_ip='XXXXXXXXX';/dev/astrill/astrillvpn start

Disconnect request

Summary
URL: http://192.168.XX.X:XXXX/user/cgi-bin/astrill.cgi?token=XXXXXXXXXXXXXXXXXX
Status: 200 OK
Initiator: astrill.cgi:155

Request
POST /user/cgi-bin/astrill.cgi HTTP/1.1
Cookie: clickedItem_tab=0; asus_token=XXXXXXXXXXXXXXXXXX
Host: 192.168.XX.X:XXXX

Request Data
action: execute
command: /dev/astrill/astrillvpn stop

Status request

Summary
URL: http://192.168.XX.X:XXXX/user/cgi-bin/astrill_status.cgi
Status: 200 OK
Initiator: astrill.cgi:155

Request
GET /user/cgi-bin/astrill_status.cgi HTTP/1.1
Cookie: clickedItem_tab=0; asus_token=XXXXXXXXXXXXXXXXXX
Host: 192.168.XX.X:XXXX

The interesting thing with these requests is that they're essentially wrappers for shell commands. So I did the natural thing, and sshed into the router:

ssh XXXXXXXX@192.168.XX.X -p XX

Running the commands directly in the shell had the same effect, as expected. Happily, the inconsistency issue I mentioned earlier seemed to be gone as well. Every connection succeeded immediately, meaning the cause of the issue was likely in the web UI or service layer.

Building a custom controller

After learning enough about how this thing works, I ran through some options for circumventing the janky applet interface:

As it turns out I found a nice way to do the third option via Apple Shortcuts, Apple's built-in workflow scripting application. Amazingly enough, one of the components available in Shortcuts is "Run Script Over SSH". Give it your host, port, user, password and your shell script, and you can execute a script from any of your iOS devices.

So I built out a Shortcut that offers three options from a menu:

Each invokes shell scripts I wrote. I debugged the scripts by running them in the shortcut on my local machine, later DRY-ed them out into separate scripts, and finally copied those scripts onto the router itself via:

scp -P XX my-shell-script.sh XXXXXXXX@192.168.XX.X:/tmp/home/root

I'll briefly describe each command:

Connect scripts

If a user clicks connect, the user can then select a server. On select of a particular server (e.g. "Japan (VIP)"), the Shortcut will run this in a "Run Script Over SSH" component:

sh japan-vip.sh
/dev/astrill/astrillvpn start
sh listen-for-connection.sh

The first command runs a script on the router that sets the nvram vars for the selected server:

# japan-vip.sh

nvram set astrill_servername='Japan (VIP)'
nvram set astrill_serverid='XXX'
nvram set astrill_sid='XXX'
nvram set astrill_ip='XXXXXXXXX'

The second command is the Astrill applet start command, which calls a service that returns "Connecting..." immediately. Since that service does not return the status, we need to poll for the status to notify the user when we are connected.

The third command listens for a connected status:

# listen-for-connection.sh

result="Failed to connect"
runtime=0

while [ "$result" != "Connected" ] && [ $runtime -lt 30 ]
do
  runtime=$(( $runtime + 1 ))

  sleep 1

  response=$(curl -s http://192.168.XX.X:XXXX/user/cgi-bin/astrill_status.cgi)

  if [ "$response" = "CONNECTED" ]; then
    result="Connected"
  fi
done

server=$(nvram get astrill_servername)

if [ "$result" = "Connected" ]
then
  echo "Connected to ${server} in ${runtime} seconds"
else
  echo $result
fi

And that's it!

The script will time out after 30 seconds, and even tell us how long it took to connect to the server via the "Show Result" Shortcut component (another improvement over the web interface). Most importantly, it's consistent, with no side effects that cause successive failed connection attempts.

I also added a few more servers to the menu by inspecting the astrill.cgi source in the browser to find the astrill_serverid and astrill_ip for servers we frequently connect to.

Disconnect script

The disconnect script is straightforward - if a user clicks disconnect, this script runs directly in a Shortcut SSH component:

/dev/astrill/astrillvpn stop

This calls the Astrill service, and "Disconnected" is returned and shown in a show result component.

Status script

Lastly, the status script is also one I wrote to give us the name of the server we're connected to. The original Astrill service does not offer this information and only returns "CONNECTED" or "DISCONNECTED". To do this, I set the astrill_servername nvram var in the connection script and retrieve it in this status script (saved as status.sh on the router):

# status.sh

status=$(curl -s http://192.168.XX.X:XXXX/user/cgi-bin/astrill_status.cgi)
server=$(nvram get astrill_servername)

if [ "$status" = "CONNECTED" ]
then
  echo "Connected to ${server}"
else
  echo "Disconnected"
fi

Wrapping up

And there we have it! We no longer use the web interface for managing our router VPN connection and use the custom Shortcut I wrote instead. It's faster to access, more consistent, and is easily usable from all of our Apple devices connected to our Wifi network.

Hope you enjoyed this, take care!


Thanks for reading! Go home for more notes.