CDN Benefits

People expect websites to load quickly and typically don’t stick around too long if a site takes more than a couple of seconds to load. CDNs can help with this by significantly speeding up your page load time. They are able to cache your content and have a global presence.

This also can take a significant load off of web servers since many requests will not even hit the actual site if they are served from cache. Using a CDN also has an added benefit of improved security because many provide native DDOS mitigation features and also can act as web application firewalls preventing things like SQL injection attacks on sites.

So there are obviously many great benefits of using a CDN but there are some things to consider. For example, if you make updates to your site you may not want your CDN to serve a cached version. You will need to understand how to use the appropriate cache http headers within your application and also know how to flush a CDNs cache.


CDN Firewall Considerations

Another important consideration is how you allow access to your website at your hosted environment. If your going to use the security features from your CDN, then you probably don’t want to allow the entire internet to still connect to your site which is surprisingly a very common practice.

I have seen a couple of denial-of-service attacks where the client was using a CDN but allowing the entire internet to reach their site and the attack bypassed the CDN by connecting directly to the backend ip address of the website instead of connecting to the CDN edge servers using the DNS hostname. It’s always a best practice to only allow the ip addresses of your CDN provider to access your site and block all other sources. Most CDN providers can provide you with their address ranges so you can only allow their networks to your website.

Now this presents another problem. What if the CDN adds new ip blocks for their edge servers? It could result in your site being unreachable for certain regions of the world. I’m going to show you how I handle this issue on my site and this can be applied to other environments.

I use Cloudflare as my CDN. They have one of the largest CDN around the world and most importantly, they offer a free plan that provides basic CDN and DDOS mitigation services. Cloudflare makes it easy for their customers to determine their subnets by providing it publicly in a text based format at https://www.cloudflare.com/ips-v4.

I then have a python script I wrote that will poll that url every hour and if it detects a change it will adjust ip tables accordingly. It does this by writing the output from Cloudflare to a text file and then comparing the current subnets used in the existing firewall rule to the subnets in the text file. This script executes as root and is scheduled to run as a cron job every hour.

Below is the script, the cronjob used for scheduling, and the public repo. The script is executed on a standalone docker host. There are a couple of things to note. I’ve hard-coded the ip address for the nginx container and added a deny rule for any source in the DOCKER-USER iptables chain. The script will add allow rules at the top of that chain for Cloudflare subnets.


Cloudflare.py

import os
import subprocess
import logging
import datetime
import requests

logFormatter = logging.Formatter("%(asctime)s [%(threadName)-12.12s] [%(levelname)-5.5s]  %(message)s")


def setup_logger(name, log_file, level=logging.INFO, stdout=False):
    """Function setup as many loggers as you want"""

    handler = logging.FileHandler(log_file)
    handler.setFormatter(logFormatter)

    logger = logging.getLogger(name)
    logger.setLevel(level)
    logger.addHandler(handler)

    if stdout:
        consoleHandler = logging.StreamHandler()
        consoleHandler.setFormatter(logFormatter)
        logger.addHandler(consoleHandler)

    return logger


class CloudFlare(object):
    """
    CloudFlare:
    cf_file = File containing cloudflare networks from last poll
    nginx_ip = ip of front-end nginx container
    f_nets = Networks read from cf_file
    n_nets = Networks read from cloudflare API call
    """

    def __init__(self, cf_file, nginx_ip):
        self.cf_file = cf_file
        self.nginx_ip = nginx_ip
        self.f_nets = []
        self.n_nets = []
        self.ifname = None
        self.emailLogger = setup_logger('emailLogger', log_file='/var/log/cloudflare.log', stdout=True)
        self.infoLogger = setup_logger('infoLogger', log_file='/var/log/cloudflare.log')

    @staticmethod
    def missing_nets(nets1, nets2):
        """
        :param nets1:
        :param nets2:
        :return: Returns nets from nets1 that are not in nets2
        """
        nets = []
        for net in nets1:
            if net not in nets2:
                nets.append(net)
        return nets

    def call_cloudflare(self):
        """
        :return: Returns response from cloudflare containing current CDN networks
        """
        url = "https://www.cloudflare.com/ips-v4"
        response = requests.get(url)
        if response.status_code == 200:
            self.n_nets = response.text.split()
        else:
            raise RuntimeError('Recieved response code {}'.format(response.status_code))

    def open_nets(self):
        """
        Simple method to open file containing CloudFlare networks from previous poll
        """
        with open(self.cf_file, mode='rt', encoding='utf-8') as netsfile:
            self.f_nets = netsfile.read().split()

    def write_nets(self, new_nets):
        """
        Simple method to save file containing CloudFlare networks from recent poll
        :param new_nets: Networks from current cloudflare poll
        """
        with open(self.cf_file, mode='w', encoding='utf-8') as netsfile:
            netsfile.write("\n".join(new_nets))

    def get_if_name(self):
        """
        Simple method that saves the egress bridge if-name of the nginx container using in the Iptables rules
        """
        command = "ip route get {}".format(self.nginx_ip)
        self.ifname = str(subprocess.check_output(command.split())).split()[2]

    def add_rules(self, nets):
        """
        Method uses to iterate through new CloudFlare networks and add required iptables rules
        :param nets: list containing networks using in new iptables update
        """
        if nets:
            for net in nets:
                command = ("iptables -I DOCKER-USER -o {0} -p tcp -m tcp --match multiport --dports 80,443 -s "
                           "{1} -j ACCEPT").format(self.ifname, net)
                code = os.system(("iptables -I DOCKER-USER -o {0} -p tcp -m tcp --match multiport --dports 80,443 -s "
                                  "{1} -j ACCEPT").format(self.ifname, net))

                if code == 0:
                    self.emailLogger.info('iptables update completed successfully, command=\'{}\''.format(command))
                else:
                    self.emailLogger.error('iptables update failed, update = \'{}\''.format(command))
            commands = ["cp /etc/sysconfig/iptables /root/iptables_backup/iptables_{}.bak".format(str(
                datetime.datetime.now()).split()[0]), "iptables-save > /etc/sysconfig/iptables"]
            for command in commands:
                code = os.system(command)
                if code == 0:
                    self.emailLogger.info('iptables command executed, command=\'{}\''.format(command))
                else:
                    self.emailLogger.error('iptables command failed, command = \'{}\''.format(command))

    def main(self):
        """
        Method used to start
        """
        self.infoLogger.info('cloudflare.py executed')
        self.open_nets()
        self.call_cloudflare()
        self.get_if_name()
        add_nets = self.missing_nets(self.n_nets, self.f_nets)
        self.add_rules(add_nets)
        rm_nets = self.missing_nets(self.f_nets, self.n_nets)
        if rm_nets:
            self.emailLogger.warning('Cloud Flare reporting networks {} no longer part of CDN network'.format(rm_nets))
        self.write_nets(self.n_nets)
        self.infoLogger.info('cloudflare.py completed')


if __name__ == '__main__':
    CloudFlare(cf_file="cf_nets.txt", nginx_ip="172.28.0.254").main()

Cron Job

MAILTO=<alert email address>
SHELL=/usr/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin:/root/bin

0 * * * * /bin/python3 /root/cloudflare.py

Conclusion

Using a CDN can speed up a site and provide additional security capabilities. They are particularly useful for sites that host a lot of static content like a WordPress blog. Properly restricting access to your site so your CDN cannot be bypassed is very important to protect against application and denial of service attacks.

If your interested in more WordPress articles then click here!