Speedify: Remote access to localhost bound server

The pattern: How to remotely access a service that is bound to localhost and refuses to respond to requests not originating from localhost? The instance: If using Speedify CLI on a linux-based router with no GUI stack, how to nevertheless view the Speedify GUI in a web browser, from another device on the same network? This post documents some non-conventional hacks needed to get a working solution.

You can jump straight to the actual solution, or read through the background and tinkering that preceded the actual solution.


In a couple of previous blog posts, I described using a service named Speedify to simultaneously utilize two different Internet connections. Speedify’s command line client executes on a Linux router that runs Ubuntu Server 20.04 without a graphical interface. The lack of a graphical interface means that Speedify’s nice and useful UI is not available.. or is it? I figured out a way to access Speedify’s UI while running the CLI client on a headless server with no GUI desktop installed 🙂

Understanding how Speedify’s GUI works

This endeavor wouldn’t have succeeded without some serendipity and dogged tinkering. It all started when I was running the Speedify client on my MacbookPro. On starting the client, Activity Monitor showed a process listening on localhost:9331. Curious, I typed that into a web browser and hey presto! saw Speedify’s GUI in the browser. So it seems that the Speedify GUI is essentially a locally served web page and the Speedify client (at least for Mac) is just a thin application window around the web page.

So maybe, if Speedify CLI is running on a device, the GUI can be accessed by accessing device_ip_addr:9331 in a browser from another computer? Seemed worth a shot. Unfortunately, it is not that straightforward.

Speedify’s GUI in a browser on Linux

By chance, I was running the full Speedify client on a computer with a Linux Desktop. Unlike on the Macbook, there was no process listening on localhost:9331 when the full Speedify client is started on Linux. However, in the /usr/share/speedifyui/files folder, I noticed files that looked suspiciously like those that would be served by a web server. So I went to that directory and started a local webserver

cd /usr/share/speedifyui/files
python3 -m http.server

the above command starts a webserver listening on all interfaces at port 8000. Typing localhost:8000 in a web browser on the same machine shows the Speedify GUI. Bingo! Unfortunately, typing eth_ip_addr:8000 in a web browser from another computer on the network does not work. An interface does come up, but all it shows is a blue button, clicking which achieves nothing.

The Javascript console shows that the page is unable to access a websocket on port 9330 on the computer running Speedify. On that computer, lsof -i :9330 indeed shows that Speedify is listening on port 9330. However, after some testing, it became clear that while the service binds to port 9330 on all interfaces, it only responds to requests from localhost i.e. Well, we should be able to work around this with some iptables magic, right?

Iptables magic

The LAN facing static ip address of the router is . As mentioned above, firing up a webserver on port 8000 and accessing it via does not work. However, accessing it via does work. The reason is that upon page load, some javascript try to access ws:// in the former case, but the websocket connection gets closed immediately. In the latter case, access to ws:// works well. So I tried using iptables to DNAT all requests on ports 8000 and 9330 on the external ip to the internal ip

sudo iptables -t nat -A PREROUTING -d -p tcp -m tcp --dport 9330 -j DNAT --to-destination
sudo iptables -t nat -A PREROUTING -d -p tcp -m tcp --dport 8000 -j DNAT --to-destination

This should make it appear to Speedify that the connection requests are originating from localhost. But with the above two rules, the connections were dropped at the routing table. After a bit of searching, I realized that the kernel does this on purpose. It refuses to route packets with the loopback as source or destination because that qualifies as a martian packet. The solution was to enable the route_localnet flag. As stated in the kernel documentation:

sudo sysctl -w net.ipv4.conf.enp3s0.route_localnet=1

Now, the packets made their way to the listener on, but still the websocket connection failed. Why? Perhaps, the server could see the originating ip in the packet header as not being localhost and so was terminating the connection? So I tried SNAT’ing the connections

sudo iptables -t nat -A INPUT -d -m conntrack --ctstate DNAT -j SNAT --to-source

Doing both DNAT and SNAT on the packets this way impacts throughput, but this isn’t an issue for just the couple of connections I expected to have. Now, the packet should truly appear to be originating from localhost. Funnily enough, even with this, the websocket connection could not be established. At this point, I gave up for a few days until I accidentally came across a statement in this page on writing websocket servers: Tip: All browsers send an Origin header. You can use this header for security (checking for same origin, automatically allowing or denying, etc.) and send a 403 Forbidden if you don’t like what you see. A quick test with curl showed that the websocket server was indeed closing the connection during the handshake with a 403 message. So the solution would be to spoof the origin header, right? Turns out, this is not possible. As described on MDN; Origin is a ‘forbidden’ header, meaning that its value can not be changed programatically by the browser. Well, dang! But hey, is it possible to mangle the origin header on the server-side, before passing the request to the websocket server? With a reverse proxy maybe?

Nginx reverse proxy

nginx can indeed alter headers using the proxy_set_header directive. So I modified the iptables rule above to DNAT requests on to and setup an nginx reverse proxy listening there. The reverse proxy mangles the Origin and Host headers, then forwards the request to the websocket server on

server {
    server_name nasbox;

    location / {
      proxy_set_header Origin;
      proxy_set_header Host;

      proxy_pass http://ws-backend;

      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "upgrade";

upstream ws-backend {


paste the above in a file, say /etc/nginx/sites-available/router.conf and enable it. With this, we now have a somewhat convoluted pipeline

  1. Packets from another computer on the network reaches router’s ip address on ports 8000 and 9330
  2. Iptables will DNAT the packets: -> and -> Nginx is listening at the latter location
  3. Nginx will mangle the http request Origin and Host headers to localhost and forward it to

Thus, the services listening on ports 8000 and 9330 should have no way to determine that a request has originated from anywhere other than Now, typing on a web browser from any other computer in the network indeed shows up the Speedify GUI. Mission accomplished 😀

All together: The actual solution

This section collects all relevant commands in one place. Note that in case only the Speedify CLI was installed, as explained in the previous blog post, you need to first download the speedifyui package and extract the files needed to serve the GUI client with a web server. Do that with

sudo apt download speedifyui
sudo dpkg-deb -x speedifyui_deb_file_name.deb .
sudo mv ./usr/share/speedifyui /usr/share

Then, enable route localnet and set up the iptable rules

sudo sysctl -w net.ipv4.conf.enp3s0.route_localnet=1
sudo iptables -t nat -A PREROUTING -d -p tcp -m tcp --dport 9330 -j DNAT --to-destination
sudo iptables -t nat -A PREROUTING -d -p tcp -m tcp --dport 8000 -j DNAT --to-destination
sudo iptables -t nat -A INPUT -d -m conntrack --ctstate DNAT -j SNAT --to-source

Then, set up an nginx reverse proxy as described in the section above. Head to /usr/share/speedifyui/files and type

python3 -m http.server

And now, you should be able to access Speedify’s GUI from any computer on the network by typing in the browser.