Avalon A Programmer Blog

Update Cloudflare DDNS with Docker

I was using Synology and created a script to update Cloudflare DDNS. As my Synology are becoming full, I decide to move to unRaid because Synology is expensive for its hardware specification. So, I decide to move my DDNS from Synology to unRaid in docker.

Use off the shelf docker

I found nouchka/cloudflare-dyndns/. But it is 54 MB for such a simple function and it does not even keep checking IP and update if necessary. They are also other docker available but none of it match my requirement: small and update automatically. I guess the only solution is to write it myself 🤷🏻‍♂️.

First try: Python

I decide to use Python because I am not good at Bash and Shell script. I use python:3.6.2-alpine3.6 because Alpine is a smaller OS. (ALWAYS use Alpine if you can. Alpine 3.6 is 2MB while Ubuntu 17.04 is 38 MB). Here is a simple Python script:

cloudflare.py

import json
from os import environ, getenv
from time import sleep

from requests import get, put


def get_request_header(email, api_key):
    return {
        "X-Auth-Email": email,
        "X-Auth-Key": api_key,
        "Content-Type": "application/json"
    }


def print_error(result):
    for error in result["errors"]:
        print(Exception(error))


def get_zone_id(headers, host_name):
    params = {"name": host_name}
    response = get("https://api.cloudflare.com/client/v4/zones", params=params, headers=headers)
    result = response.json()
    if result["success"]:
        return result["result"][0]["id"]
    else:
        print_error(result)
        return None


def get_record_id(headers, zone_id, host_name):
    params = {"name": host_name}
    response = get(f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records", params=params,
                   headers=headers)
    result = response.json()
    if result["success"]:
        return result["result"][0]["id"]
    else:
        print_error(result)
        return None


def update_dns(headers, zone_id, record_id, host_name, ip, ttl, proxy):
    data = {"type": "A", "name": host_name, "content": ip, "ttl": ttl, "proxied": proxy}
    response = put(f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}",
                   data=json.dumps(data), headers=headers)
    result = response.json()
    if result["success"]:
        return True
    else:
        print_error(result)
        return False


def get_ip():
    return get(getenv("IP_ECHO", "http://ipecho.net/plain")).text  # http://icanhazip.com Also works


def update(ip):
    email = environ["EMAIL"]
    api_key = environ["API"]
    host_name = environ["HOST"]
    headers = get_request_header(email, api_key)
    zone_id = get_zone_id(headers, host_name)
    if zone_id is None:
        return
    record_id = get_record_id(headers, zone_id, host_name)
    if record_id is None:
        return
    ttl = int(getenv("TTL", "1"))
    proxy = getenv("PROXY", "True").lower() == "true"
    result = update_dns(headers, zone_id, record_id, host_name, ip, ttl, proxy)
    if result:
        print(f"Update Success:{host_name}({ip})")
    else:
        print(Exception(f"Update Fail:{host_name}({ip})"))


def main():
    ip = None
    new_ip = get_ip()
    while True:
        if ip != new_ip:
            update(new_ip)
            ip = new_ip
        sleep(int(getenv("WAIT", 300)))
        new_ip = get_ip()


if __name__ == "__main__":
    main()

Docker

FROM python:3.6.2-alpine3.6

ADD cloudflare.py /app
ADD requirements.txt /app

WORKDIR "/app"

CMD [ "pip", "install", "requirements.txt" ]

CMD [ "python", "cloudflare.py" ]

It works, it should be fine right?

Second try: Bash

Compare to my original Bash script, it get the zone id and record id itself. But the Python is pretty big (32 MB), I guest I have to go back to shell script.

cloudflare.sh

#!/bin/bash

update_cloudflare() {
    zone_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=$ZONE" -H "X-Auth-Email: $EMAIL" -H "X-Auth-Key: $API" -H "Content-Type: application/json" | perl -nle'print $& if m{(?<="id":")[^"]*}' | head -1 )
    record_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$HOST" -H "X-Auth-Email: $EMAIL" -H "X-Auth-Key: $API" -H "Content-Type: application/json"  | perl -nle'print $& if m{(?<="id":")[^"]*}')
    update=$(curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$record_id" -H "X-Auth-Email: $EMAIL" -H "X-Auth-Key: $API" -H "Content-Type: application/json" --data "{\"id\":\"$zone_id\",\"type\":\"A\",\"name\":\"$HOST\",\"content\":\"$ip\",\"ttl\":$TTL,\"proxied\":$PROXY}")

    if [[ ${update} == *"\"success\":false"* ]]; then
        echo "API UPDATE FAILED. DUMPING RESULTS:\n$update"
        exit 1
    else
        echo "IP changed to: $ip"
    fi
}

# http://icanhazip.com Also works
ip=$(curl -s http://ipecho.net/plain)
update_cloudflare


while sleep $WAIT; do
    new_ip=$(curl -s http://ipecho.net/plain)
    if [[ "${ip}" == "$new_ip" ]]; then
        echo "Same ip: $ip"
    else
        ip="$new_ip"
        update_cloudflare
    fi
done

Dockerfile

FROM alpine:3.6
MAINTAINER JoshuaAvalon

RUN apk add --update curl && \
    apk add --update bash && \
    apk add --update perl && \
    rm -rf /var/cache/apk/*

RUN mkdir /app

ADD cloudflare.sh /app

WORKDIR "/app"

CMD [ "sh", "cloudflare.sh" ]

I am use sleep to wait and the docker size is reduced to 11 MB. Yeah 🙌🏻

Third try: Shell

Do we really need Bash? I guess no. With the help of ShellCheck to convert my Bash script to Shell script. Also, using sleep does not feel right to me. I refactor to let cron handle it. cloudflare.sh

#!/bin/sh
echo `date "+%Y-%m-%d %H:%M:%S"`
ip_file="ip.txt"

new_ip=$(curl -s http://ipecho.net/plain)

if [ -f $ip_file ]; then
    ip=$(cat $ip_file)
    if [ $ip == $new_ip ]; then
		echo "Same ip: $ip"
        exit 0
    fi
fi

ip="$new_ip"
	zone_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=$ZONE" -H "X-Auth-Email: $EMAIL" -H "X-Auth-Key: $API" -H "Content-Type: application/json" | perl -nle'print $& if m{(?<="id":")[^"]*}' | head -1 )
record_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$HOST" -H "X-Auth-Email: $EMAIL" -H "X-Auth-Key: $API" -H "Content-Type: application/json"  | perl -nle'print $& if m{(?<="id":")[^"]*}')
update=$(curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$record_id" -H "X-Auth-Email: $EMAIL" -H "X-Auth-Key: $API" -H "Content-Type: application/json" --data "{\"id\":\"$zone_id\",\"type\":\"A\",\"name\":\"$HOST\",\"content\":\"$ip\",\"ttl\":$TTL,\"proxied\":$PROXY}")

if [[ ${update} == *"\"success\":false"* ]]; then
	echo "API UPDATE FAILED. DUMPING RESULTS:\\n$update"
	exit 1
else
	echo "IP changed to: $ip"	
    echo "$ip" > $ip_file
fi

As I look into the image inspection, I found the perl package is pretty big. I guess I remove it as well. However, Alpine grep does not support -P. I have to rewrite the regular expression.

#!/bin/sh

date "+%Y-%m-%d %H:%M:%S"
ip_file="ip.txt"

new_ip=$(curl -s http://ipecho.net/plain)

if [ -f $ip_file ]; then
    ip=$(cat $ip_file)
    if [ "$ip" = "$new_ip" ]; then
		echo "Same ip: $ip"
        exit 0
    fi
fi

ip="$new_ip"
echo "IP: $ip"
zone_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=$ZONE" -H "X-Auth-Email: $EMAIL" -H "X-Auth-Key: $API" -H "Content-Type: application/json" | grep -Eo '"id":.?"\w*?"' |head -1|grep -o ':.*".*"'|grep -o '\w*')
echo "Zone ID: $zone_id"
record_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$HOST" -H "X-Auth-Email: $EMAIL" -H "X-Auth-Key: $API" -H "Content-Type: application/json" | grep -Eo '"id":.?"\w*?"' |head -1|grep -o ':.*".*"'|grep -o '\w*')
echo "Record ID: $record_id"
update=$(curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$record_id" -H "X-Auth-Email: $EMAIL" -H "X-Auth-Key: $API" -H "Content-Type: application/json" --data "{\"id\":\"$zone_id\",\"type\":\"A\",\"name\":\"$HOST\",\"content\":\"$ip\",\"ttl\":$TTL,\"proxied\":$PROXY}")

if echo "$update" | grep -q "\"success\":true"; then
	echo "IP changed to: $ip"	
  echo "$ip" > $ip_file
else
	printf "Update failed:\\n%s" "$update"
	exit 1
fi

I am using multiple grep because I cannot use look ahead or group to extract the exact part I need. "id":.?"\w*?" extract all id field in JSON and head -1 extract the first output. :.*".*" remove the key and \w* extract the value.

Dockerfile

FROM alpine:3.6
MAINTAINER JoshuaAvalon

RUN apk add --update curl && \
    rm -rf /var/cache/apk/*

ADD cloudflare.sh /cloudflare.sh
RUN chmod +x /cloudflare.sh
ADD cron /var/spool/cron/crontabs/root

CMD /cloudflare.sh && crond -f

After going through all of this, the images is reduced to 3 MB. The docker is host on DockerHub and the source is on Github. Take a look if you would like to.