Using HA Proxy for SaaS systems - Custom Domains
March 24, 2017
A common problem with setting up a SaaS system is how to manage the customers on a custom domain.
It’s easy when everybody enters your system through example.com
, and even account-name.example.com
wildcards, but what if your system offers the ability to have a custom domain such as zando.io
.
This is a problem we had at Shopblocks, and I tried a few ways of solving it.
If you are experienced with HA Proxy and just want to see the configuration, skip to the bottom.
If you want to experience the journey with me, read on!
The first involved using Ansible to create a new virtual host for Apache and then reloading the apache server.
This worked, however, performance became an issue pretty quickly.
Registrations were taking a long time, sometimes up to a minute.
Using Ansible with Apache also had no guarantee of consistency, if a server was offline during the registration process then that server didn’t have the virtual host, and could not resolve the request.
To fix the consistency issue we sharing the virtual hosts across the platform on a mounted drive using NFS. The performance of the mounted drive was not majorly affected by this move, but the performance was still an issue on the process side. We had solved the consistency issue, but we still had a couple of others.
Because we enforce HTTPS on all requests, our load balancer could not utilise proxying with the clients IP address. This meant that our logs from Apache, and internally in our system, all would have the load balancers private IP and not the clients IP.
We also still had the performance issue. This wasn’t really acceptable, and so we wanted a completely different approach.
We were already using HA proxy for our load balancing, and we knew that HA proxy could terminate SSL connections and modify requests.
If we could solve:
- SSL connection terminating
- Retaining the client IP address
- Unique Virtual Hosts
- Use of Ansible
Then we should be in a much better position.
It would allow us to more easily perform upgrades on our application servers.
The possibilities for upgrades were plentiful; whether that be changing from Apache to Nginx, adding more servers to the clusters easily or even moving to completely different architecture in the backend (for example, NodeJS instead of Apache + PHP).
These sorts of upgrades are now much simpler to perform.
It seemed like we were going down the correct route.
The first problem to solve is forcing HTTP to HTTPS redirects. This is very easy to do in HA Proxy
frontend http
bind :80
redirect scheme https code 301
frontend https
bind :443
# We will fill this in later
We are listening for requests on port 80 and port 443.
Any request that comes in on port 80 will be redirected with a 301 Permanent Redirect
to https
.
We are now done with any HTTP requests. Everything from now on is HTTPS only.
Now that all of our traffic is HTTPS, we need to terminate those requests.
At Shopblocks, all new accounts start with a couple of myshopblocks.com
subdomains.
We have a wildcard SSL certificate available that covers all of these domains.
Customers can also setup a custom domain. This gets provided with a Lets Encrypt certificate.
Allowing Lets Encrypt certificates in this setup is a topic of its own, but the gist is you need to get the certificate in pem
format, which involves concatonating the private key
and ceritifcate
together into a flat directory file.
frontend https
bind :443 ssl crt /path/to/certificates
# We will fill this in later
This directory of certificates must be flat, contain at least 1 certificate that is valid, and be in .pem
format. The filename of the certificate does not matter.
Note, that this method uses SNI headers from the request, and so some requests from old browsers, for example Android 2.3, may not return with the correct certificate, and instead take the first certificate it finds. This is not a major issue, since the systems that do not use SNI are largely outdated and less commonly used.
Now that all of our traffic is HTTPS and is being terminated, we need to load balance the requests and send them through to our web servers.
frontend https
bind :443 ssl crt /path/to/certificates
use_backend core
backend core
balance roundrobin
server WEB_HOSTNAME_01 10.20.30.40:80 check
server WEB_HOSTNAME_02 10.20.30.41:80 check
Here we have 2 servers defined in our backend named core
. The backend name does not matter, and is used as an identifier between the use_backend
and the backend itself.
The two servers are being referenced using a private network IP. You can use either public or private network IP’s, however I would suggest private networking so that you can have finer control and limit direct access to the server.
Because of the SSL has already been terminated in the load balancer, we now have a plain HTTP request which we will send through on port 80 to our web server.
Our traffic is now going through the load balancer and hitting a web server, however you are probably getting the 000-default page in apache.
The reason for this is that your request is still coming through on zando.io
instead of a domain name that the apache server knows how to resolve.
To fix this issue we need to modify the headers sent through.
This has some caveats, such as not being able to use the HTTP_HOST server variables in PHP as expected. There are ways around that, but it is worth keeping in mind.
I am going to map the request through to system.example.com
in this example. In reality you can set this to any domain, whether you control it or not since no DNS lookups take place.
backend core
...
http-request set-header Host system.example.com
...
The domain you choose to replace system.example.com
needs to be recognised by your apache virtual host on port 80.
You are now getting the correct page, but your system does not know which domain was originally requested, and by extension, what the customer’s ID is.
To fix this, we will need a map of all domains to a customer ID.
This is stored as a simple key => value pair in a text file.
domain1.com 1
domain2.com 2
domain3.com 3
domain1.com
belongs to customer with an ID of 1
, and so on.
This file is going to be referred to as our customer.map
file.
Whenever a new customer or domain gets added to your system, you simply need to append to this file, and reload haproxy.
frontend https
...
http-request set-header X-Customer-ID %[req.hdr(host),lower,map_str(/path/to/customer.map)]
...
This will set the X-Customer-ID
header based on the value
matching the host
. You want to ensure that your customer.map
file is all lowercase, since the lower
function in use will convert the request header to lowercase.
We now have the system assigning the X-Customer-ID
for domains that the map file contains, but if it does not contain a file, the X-Customer-ID
is set, but empty.
If this is okay in your system, then leave it as it is, however if you do not want to resolve requests at all for any domains you do not recognise then above the http-request
line, you can add the following.
frontend https
...
acl is_customer hdr(host),lower,map_str(/path/to/customer.map) -m found
http-request silent-drop unless is_customer
http-request set-header ...
...
Now the connection will drop immediately for any domain requests not recognised in the customer.map
file.
We determine whether somebody is a customer using an Access Control List (acl).
This will create a variable called is_customer
which will be a variable based on the found
method specified by the -m
flag used.
We can assume that if the domain is found in the list, they are a customer of ours.
Putting that all together you should have something that looks like the following
frontend http
bind :80
redirect scheme https code 301
frontend https
bind :443 ssl crt /path/to/certificates
acl is_customer hdr(host),lower,map_str(/path/to/customer.map) -m found
http-request silent-drop unless is_customer
http-request set-header X-Customer-ID %[req.hdr(host),lower,map_str(/path/to/customer.map)]
use_backend core
backend core
balance roundrobin
http-request set-header Host system.example.com
server WEB_HOSTNAME_01 10.20.30.40:80 check
server WEB_HOSTNAME_02 10.20.30.41:80 check
And that is the basics of setting up a SaaS resolver in HA proxy and passing it through to Apache.
If you have any questions, feel free to send me a message on twitter or email me
Thank you to Hugojmd for proof reading and spotting mistakes.