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:
- Devices that don't have Astrill clients (like a PlayStation 4) can use the VPN
- We can use multiple devices without managing the connection for each device
- Fine grain filtering on an individual site and device basis
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:
- Open a web browser
- Navigate to the router IP
- Authenticate with username and password to the router
- Navigate to the Astrill VPN applet
- 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:
- Our router is an Asus RT-AX68U
- The router runs Asuswrt-Merlin (version 386.2) firmware installed following this guide
- The Astrill VPN applet (version 2.9.36) is installed following this guide and accessible via the Asuswrt-Merlin web interface
Understanding Astrill's CGI
While inspecting the network traffic of the Astrill VPN applet via the web interface, I found three distinct requests:
- A connect request made on connect button click
- A disconnect request made on disconnect button click
- A status request polling every second
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 ssh
ed into the router:
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:
- If the inconsistency issue was in the UI somewhere, I could write a browser extension to fix it and still use the same web interface. Aside from the assumption that the bug was indeed a UI bug, another problem with this approach is all our devices would need that extension enabled (also assuming we all use the same browser).
- Write a new web interface and try to circumvent CORS and
http-only
cookies to access the endpoints that way. I would have to do more digging to see if this is possible. - Avoid the web server entirely and find a convenient way to
ssh
into the router and execute the scripts directly.
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:
- Connect
- Disconnect
- Status
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:
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:
The first command runs a script on the router that sets the nvram
vars for the selected server:
# japan-vip.sh
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 [ && [
do
runtime=
response=
if [; then
result="Connected"
fi
done
server=
if [
then
else
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:
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=
server=
if [
then
else
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.