Linode started to offer US$5 VMs and they are available in Tokyo (9ms ping as opposed to 122ms ping to California), so I could not resist to get another one and use it for some experimenting which I simply don’t dare to do on this very blog page (and my wife is using it for work too).
Goal
- 2 web servers serving web server stuff (a simple static index.html serves here)
- 3 virtual web addresses:
- first web server
- second web server
- round-robin web server which serves data from the first and second web server
- Everything with SSL without any warning from web browsers
Ingredients
- 1 VM with a public IP address
- 3 FQDNs pointing all to above IP address
- www1.qw2.org
- www3.qw2.org
- www4.qw2.org
- 2 web servers (www1, www3)
- 1 load-balancer (www4)
- 3 certificates for above FQDNs
Let’s Encrypt Certificates
I use acme.sh for my few certificate needs. This is how to get a new certificate issued:
./acme.sh --issue --dns dns_linode --dnssleep 1200 -d www4.qw2.org
This is using the DNS API from Linode (who hosts my DNS records). See more details here. It creates the required TXT record and removes it later again. I found that 1200 seconds wait time works. 900 does not always. I end up using 10 seconds, suspend the acme.sh command (shell ^Z), and use “dig -t TXT _acme-challenge.www4.qw2.org” until it returns some TXT record. Then continue the suspended acme.sh command.
You should then have a new directory www4.qw2.org in your acme.sh directory with those files:
harald@blue:~/.acme.sh$ ls -la www4.qw2.org/
total 36
drwxr-xr-x 2 harald users 4096 Feb 19 10:16 .
drwx------ 17 harald users 4096 Feb 19 00:26 ..
-rw-r--r-- 1 harald users 1647 Feb 19 10:16 ca.cer
-rw-r--r-- 1 harald users 3436 Feb 19 10:16 fullchain.cer
-rw-r--r-- 1 harald users 1789 Feb 19 10:16 www4.qw2.org.cer
-rw-r--r-- 1 harald users 517 Feb 19 10:16 www4.qw2.org.conf
-rw-r--r-- 1 harald users 936 Feb 19 00:26 www4.qw2.org.csr
-rw-r--r-- 1 harald users 175 Feb 19 00:26 www4.qw2.org.csr.conf
-rw-r--r-- 1 harald users 1675 Feb 19 00:26 www4.qw2.org.key
You’ll need the fullchain.cer and the private key www4.qw2.org.key later.
Repeat for www1 and www3 too.
Note that the secret key is world readable. the .acme.sh directory is therefore secured with 0700 permissions.
Setting up the Web Servers
Using lighttpd herte. The full directory structure:
harald@lintok1:~$ tree lighttpd
lighttpd
├── 33100
│ ├── etc
│ │ ├── lighttpd.conf
│ │ ├── mime-types.conf
│ │ ├── mod_cgi.conf
│ │ ├── mod_fastcgi.conf
│ │ ├── mod_fastcgi_fpm.conf
│ │ └── www1.qw2.org
│ │ ├── combined.pem
│ │ └── fullchain.cer
│ └── htdocs
│ └── index.html
├── 33102
│ ├── etc
│ │ ├── lighttpd.conf
│ │ ├── mime-types.conf
│ │ ├── mod_cgi.conf
│ │ ├── mod_fastcgi.conf
│ │ ├── mod_fastcgi_fpm.conf
│ │ └── www3.qw2.org
│ │ ├── combined.pem
│ │ └── fullchain.cer
│ └── htdocs
│ └── index.html
└── docker-compose.yml
Using the lighttpd.conf is simple and can be done in 5 or 10 minutes. The part for enabling https is this:
$SERVER["socket"] == ":443" {
ssl.engine = "enable"
ssl.pemfile = "/etc/lighttpd/www1.qw2.org/combined.pem"
ssl.ca-file = "/etc/lighttpd/www1.qw2.org/fullchain.cer"
}
fullchain.cer is the one you get from the Let’s Encrypt run. “combined.pem” is created via
cat fullchain.cer www1.qw2.org.key > combined.pem
Here the content of docker-compose.yml:
lighttpd-33100:
image: sebp/lighttpd
volumes:
- /home/harald/lighttpd/33100/htdocs:/var/www/localhost/htdocs
- /home/harald/lighttpd/33100/etc:/etc/lighttpd
ports:
- 33100:80
- 33101:443
restart: always
lighttpd-33102:
image: sebp/lighttpd
volumes:
- /home/harald/lighttpd/33102/htdocs:/var/www/localhost/htdocs
- /home/harald/lighttpd/33102/etc:/etc/lighttpd
ports:
- 33102:80
- 33103:443
restart: always
To start those 2 web servers, use docker-compose:
docker-compose up
If you want to have a reboot automatically restart the service, then use do “docker-compose start” afterwards which installs a service.
To test, access: http://www1.qw2.org:33100, https://www1.qw2.org:33101, http://www3.qw2.org:33102, https://www3.qw2.org:33103
They all should work, and the https pages should find a proper security status (valid certificate, no name mismatch etc.).
Adding HAProxy
HAProxy (1.7.2 as of the time of writing) can be the SSL termination and forwarded traffic between the web server and HAProxy is unencrypted (resp. can be encrypted via another method), or HAProxy can simply forward traffic. Which one is preferred depends on the application. In my case it makes most sense to let HAProxy handle SSL.
First the full directory structure:
haproxy
├── docker-compose.yml
└── etc
├── errors
│ ├── 400.http
│ ├── 403.http
│ ├── 408.http
│ ├── 500.http
│ ├── 502.http
│ ├── 503.http
│ ├── 504.http
│ └── README
├── haproxy.cfg
└── ssl
└── private
├── www1.qw2.org.pem
├── www3.qw2.org.pem
└── www4.qw2.org.pem
The www{1,3}.qw2.org.pem were copied from the lighttpd files.
haproxy.cfg:
harald@lintok1:~/haproxy/etc$ cat haproxy.cfg
global
user nobody
group users
#daemon
# Admin socket
stats socket /var/run/haproxy.sock mode 600 level admin
stats timeout 2m
# Default SSL material locations
#ca-base /usr/local/etc/haproxy/ssl/certs
#crt-base /usr/local/etc/haproxy/ssl/private
# Default ciphers to use on SSL-enabled listening sockets.
# For more information, see ciphers(1SSL).
tune.ssl.default-dh-param 2048
ssl-default-bind-options no-sslv3 no-tls-tickets
ssl-default-bind-ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA
-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256
:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES1
28-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA
:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256
-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:A
ES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CB
C3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA
ssl-default-server-options no-sslv3 no-tls-tickets
ssl-default-server-ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-R
SA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA2
56:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AE
S128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-S
HA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES2
56-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA
:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-
CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA
defaults
log global
mode http
option dontlognull
timeout connect 5000
timeout client 50000
timeout server 50000
errorfile 400 /usr/local/etc/haproxy/errors/400.http
errorfile 403 /usr/local/etc/haproxy/errors/403.http
errorfile 408 /usr/local/etc/haproxy/errors/408.http
errorfile 500 /usr/local/etc/haproxy/errors/500.http
errorfile 502 /usr/local/etc/haproxy/errors/502.http
errorfile 503 /usr/local/etc/haproxy/errors/503.http
errorfile 504 /usr/local/etc/haproxy/errors/504.http
stats enable
stats uri /stats
stats realm Haproxy\ Statistics
stats auth admin:SOME_PASSWORD
frontend http-in
bind *:80
acl is_www1 hdr_end(host) -i www1.qw2.org
acl is_www3 hdr_end(host) -i www3.qw2.org
acl is_www4 hdr_end(host) -i www4.qw2.org
use_backend www1 if is_www1
use_backend www3 if is_www3
use_backend www4 if is_www4
frontend https-in
bind *:443 ssl crt /usr/local/etc/haproxy/ssl/private/
reqadd X-Forward-Proto:\ https
acl is_www1 hdr_end(host) -i www1.qw2.org
acl is_www3 hdr_end(host) -i www3.qw2.org
acl is_www4 hdr_end(host) -i www4.qw2.org
use_backend www1 if is_www1
use_backend www3 if is_www3
use_backend www4 if is_www4
backend www1
balance roundrobin
option httpclose
option forwardfor
server s1 www1.qw2.org:33100 maxconn 32
backend www3
balance roundrobin
option httpclose
option forwardfor
server s3 www3.qw2.org:33102 maxconn 32
backend www4
balance roundrobin
option httpclose
option forwardfor
server s4-1 www1.qw2.org:33100 maxconn 32
server s4-3 www3.qw2.org:33102 maxconn 32
listen admin
bind *:1936
stats enable
stats admin if TRUE
Replace “SOME_PASSWORD” with an admin password for the admin user who can stop/start backends via the Web UI.
Here the docker-compose.yml file to start HAProxy:
harald@lintok1:~/haproxy$ cat docker-compose.yml
haproxy:
image: haproxy:1.7
volumes:
- /home/harald/haproxy/etc:/usr/local/etc/haproxy
ports:
- 80:80
- 443:443
- 1936:1936
restart: always
To start haproxy, do:
docker-compose up
The Result
Now http://www1.qw2.org as well as https://www1.qw2.org works. No need for specific ports like 33100 or 33101 anymore. Same for www3.qw2.org. www4.qw2.org is a round-robin of www1 and www3, but it’s using the www4 certificate when using https. In all cases HAProxy terminates the SSL connections and it’s presenting the correct certificates.
Related: on http://www4.qw2.org:1936/haproxy?stats you can see the statistics of HAProxy.
Connecting it all all
Running 2 web servers plus the load-balancer with all of them internally connected and only the load-balancer visible on port 80 resp. 443 needs a new docker-compose.yml (changed to version 3 syntax) and a slight matching change haproxy.conf file:
harald@lintok1:~/three$ cat docker-compose.yml
version: '3'
services:
lighttpd-33100:
image: sebp/lighttpd
volumes:
- /home/harald/lighttpd/33100/htdocs:/var/www/localhost/htdocs
- /home/harald/lighttpd/33100/etc:/etc/lighttpd
expose:
- 80
restart: always
lighttpd-33102:
image: sebp/lighttpd
volumes:
- /home/harald/lighttpd/33102/htdocs:/var/www/localhost/htdocs
- /home/harald/lighttpd/33102/etc:/etc/lighttpd
expose:
- 80
restart: always
haproxy:
image: haproxy:1.7
volumes:
- /home/harald/three/haproxy/etc:/usr/local/etc/haproxy
ports:
- 80:80
- 443:443
- 1936:1936
restart: always
No need for lighttpd to handle SSL anymore (no more port 443 needed to be exposed at all). Only the HAProxy is visible from outside. Small changes are needed on haproxy.conf, but only in the backend section:
[...]
backend www1
balance roundrobin
option httpclose
option forwardfor
server s1 lighttpd-33100:80 maxconn 32
backend www3
balance roundrobin
option httpclose
option forwardfor
server s3 lighttpd-33102:80 maxconn 32
backend www4
balance roundrobin
option httpclose
option forwardfor
server s4-1 lighttpd-33100:80 maxconn 32
server s4-3 lighttpd-33102:80 maxconn 32
[...]
And with “docker ps” we can see what’s happening under the hood of docker-compose:
harald@lintok1:~/three$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS
PORTS NAMES
742a2e5388f2 sebp/lighttpd "lighttpd -D -f /e..." 4 minutes ago Up 3 minutes
80/tcp three_lighttpd-33100_1
9d4c61e6c162 sebp/lighttpd "lighttpd -D -f /e..." 4 minutes ago Up 3 minutes
80/tcp three_lighttpd-33102_1
2e41dfa26ac9 haproxy:1.7 "/docker-entrypoin..." 4 minutes ago Up 3 minutes
0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, 0.0.0.0:1936->1936/tcp three_haproxy_1