People are lazy — I am lazy. Yet laziness doesn’t justify not safe-guarding my personal web services with authentication layer, even though my experimental abominations have the shittiest quality possible. Lazy as I am, I wouldn’t lift a finger to implement event the simplest humblest web server-based basic auth, not to mention the all troublesome OpenID Connect.
For people like me, we have VPN that comes to rescue. VPN stands for virtual private network. Once your device is connected to a VPN, it is as if you magically have an additional LAN port on your devices (even if it doesn’t have one) plugged in and connected to a virtual (get it?) network over any network. Also, the network is private in a sense that no external hosts can access hosts within the network without first connecting to the network. As such, one can host totally insecure and authentication-less web services entirely inside an VPN, and have devices connect to the network when needed. All the web services will be hidden behind the wall of the VPN itself, only allow the selected few with the relevant credentials to access.
When I researched for the topic, I was surprised not many have covered the topic, except like one question on StackExchange that pretty much explained it all. The answer covers pretty much all I have to show you in this post, and it’s fairly obvious. Probably that’s why nobody goes one step further to actually show how it’s done.
I am going to show you how to use Docker to set up such an environment.
Server setup
- A Linode instance with 1GB RAM
- Ubuntu 18.04
- Docker 18.06.1-ce
- Docker image
- VPN: hwdsl2/ipsec-vpn-server
- Web service: nginx:1.13.8-alpine
How it works?
In Docker, you can create internal subnets (bridge network) where your
containers sit on. By default, no port is open to public unless you
specify which port to make open with --publish
or it’s shortened form
-p
when spinning up a container.
In addition, you can create a bridge network that’s only open to the outside world with ports your VPN service uses (e.g. 450 and 5000 UDP), while keeping everything else in isolation from the external network. As such, only hosts connected to the VPN network is able to access services within it’s own isolated bridge network. Once connected to your VPN, you can access all containers within the same network as your VPN with their internal IP addresses.
This setup isn’t without disadvantages. One of which being services within the network are shielded such that services that requires incoming transmissions, for example webhooks, are unusable. For most of my use cases, this doesn’t pose an issue to me, but it may affect some people who listens to transmissions from external services and require a public facing interface. Also, forget about HTTPS and navigating to services using domain names. While you can specify an IP for a service, it is a bit tricky to get Let’s encrypt to issue you a free certificate. Not impossible, but tricky, troublesome, and fall out of scope of this post. I may write another post to follow up with these issues.
Creating a user-defined bridge network
We are going to create a user-defined bridge network with a specific network segment such that we can assign a static IP to each service.
docker network create vpn --subnet 172.20.0.0/16
# 848f85b7a08f31ae26b57d9a1efa70099e6048461ca0d50a5b7c941647e34184
docker inspect vpn
By inspecting the network we see that we have created a network with
name vpn
for the subnet 172.20.0.0/16
. The subnet mask has 16 bits,
meaning the first 16 bits represents the network address, and we have 16
bits of addressable space for network host, which translates to 2^16 -
2 = 65534
available host addresses. With 172.20.0.0
reserved for
network address and 172.20.255.255
for broadcast address. Discounting
the one address we reserve for our VPN server, that’s a lot of hosts
you can stuck behind an VPN.
[
{
"Name": "vpn",
"Id": "848f85b7a08f31ae26b57d9a1efa70099e6048461ca0d50a5b7c941647e34184",
"Created": "2018-12-28T21:15:59.205797293+08:00",
"Scope": "local",
"Driver": "bridge",
"EnableIPv6": false,
"IPAM": {
"Driver": "default",
"Options": {},
"Config": [
{
"Subnet": "172.20.0.0/16"
}
]
},
"Internal": false,
"Attachable": false,
"Ingress": false,
"Containers": {},
"Options": {},
"Labels": {}
}
]
Setting up VPN
For a detailed walkthrough and explanation, you should check out the excellently written documentation of hwdsl2/ipsec-vpn-server, I will not go into details for each step.
On a Linode 1GB instance running Ubuntu 18.04, first I load the IPsec
af_key
kernel module. Documentation states that this is optional for
Ubuntu but I found my Android client unable to connect unless I do this.
sudo modprobe af_key
sudo reboot
After reboot, download the sample vpn.env
file and edit accordingly.
wget https://raw.githubusercontent.com/hwdsl2/docker-ipsec-vpn-server/master/vpn.env.example -O vpn.env
vim vpn.env
You should change the three lines regarding login credentials.
VPN_IPSEC_PSK=your_ipsec_pre_shared_key
VPN_USER=your_vpn_username
VPN_PASSWORD=your_vpn_password
On vim
, press i
to enter Insert mode, edit your config. Esc
to return
to Normal mode, then :wq
to save and exit.
Now spin up the VPN server.
docker run \
--name ipsec-vpn-server \
--env-file ./vpn.env \
--restart=always \
--network vpn \
--ip 172.20.0.2 \
-p 500:500/udp \
-p 4500:4500/udp \
-v /lib/modules:/lib/modules:ro \
-d --privileged \
hwdsl2/ipsec-vpn-server
To connect to the VPN, follow the guide
here.
Note that even though in the docker run
command the container’s IP is
set to 172.20.0.2
, that’s is just it’s IP inside the subnet vpn
. You
need to use the host machine’s (your VPS’s) IP to connect to VPN from
outside.
Setting up an insecure web service
For the sake of demonstration, let’s create a web service that contains super secret content yet doesn’t come with any authentication.
mkdir test-web && cd test-web
echo 'be aware of the lizard people!' > index.html
The content is served by a nginx server. Although it’s possible to configurate basic auth on nginx, we are not going to do that so keep this service insecure.
docker run \
--name test-web \
-v `pwd`:/usr/share/nginx/html:ro \
--network vpn \
--ip 172.20.0.3 \
--restart=always \
-d \
nginx:1.13.8-alpine
Notice that there is no -p
or --publish
argument because we don’t
want to publish the container’s port to the external-facing interface of
the host machine. Instead, we keep it inside the VPN’s network by
specifying --network vpn
and giving it a static IP --ip 172.20.0.3
.
At this stage, we haven’t had a way to resolve that IP by name. While it
is possible to point a (sub)domain to 172.20.0.3
on a public-facing
DNS, but then this IP actually points to two different hosts when you
disconnect from the VPN. This introduces an obvious security loophole as
you may send traffic to an totally unrelated service if you are unware
that you’re not on the VPN at that moment.
For now, let’s connect to the services using strictly IP address.
Accessing the web service within VPN
Assuming you have connected to the VPN, it is trivial to simply open a
web browser and navigate to the IP 172.20.0.3
. After disconnecting
from the network, you’ll find that the same IP is no longer accessible.
Closing notes
With that, we have created an VPN and hide an insecure web service within the same isolated subnet of that VPN. Only if you connect to the VPN can you access the insecure web service. VPN serves as an authentication layer without any sort of integration on our insecure web services.
However, one issue remains. On this set up we have to navigate to our web services using IP address. In a next post we’ll explore how to conveniently use customized hostname to access web services within the VPN network, and potentially even enable HTTPS.