DEV Community

Cover image for Dynamic DNS sync with Cloudflare
Muzafar Umarov
Muzafar Umarov

Posted on

Dynamic DNS sync with Cloudflare

Dynamic DNS sync with Cloudflare

If you ever get yourself a Raspberry Pi, NAS, or turn an old computer into a server, you start to think about accessing it from outside of your network. This usually requires you to open a port on your router and forward to the IP of your device. Some of the services you might want to access could be a Wireguard/OpenVPN VPN or some other service.

One note about VPN, there is a way to get into your network without opening a port. You can use https://tailscale.com/ to open a tunnel to your network and access it with better security. However, using Tailscale doesn’t work with all use cases. In this blog post, I go over how to automatically sync your public IP with Cloudflare and always have your Dynamic DNS domain point to the correct IP address.

Also those of you who have experience with Cloudflare and using their proxy, you will know that Cloudflare only will proxy certain ports and the port you have for your VPN or other service might not work. If you need to have a dedicate subdomain for VPN, you can modify the code below and have it update two records. That's how I have setup.

Another note on syncing IPs with cloudflare: there are already some tools that can do this for you. I went this path, because I wanted to understand how it works under the hood and it was fun! If you are interested in writing some code to automate some tasks, this post is for you!

Assumptions

  • Your domain is on Cloudflare (or you are willing to change your domain provider to Cloudflare)
  • You can run a docker container in your server
  • You have deno installed on your computer
  • You are comfortable writing some TypeScript code to check IP and update it using Cloudflare APIs

Get started

Cloudflare Domain Settings

Create an A record for your Dynamic DNS subdomain or full domain for your public IP.

You can skip this step, if you already have an A record for your public IP.

DNS record for your Public IP

Cloudflare API Token

Go to Account API Tokens > Create Token and select the Edit Zone DNS template.

API key permissions

You want to only allow Zone DNS Edit/Read permissions.

Scope API key to your zone

You also want to limit access to the specific zones you want to work with. In this case, you want to only work with the zone that will have your Dynamic DNS IP A Record. This will limit the blast radius of the API token.

Then you need to Continue to Summary and Create Token.

Store the API key securely while you build the rest.

Cloudflare Worker

You can either use the Cloudflare CLI or the Dashboard to create your worker. I started out by doing this on the Dashboard. Later, I generated a Cloudflare Worker project with CLI and pushed it to a Gitlab repo. Then I connected the existing Worker to the repo. This added support for automatic deployments on push to the main branch.

Create a hello world worker and get it deployed to Cloudflare workers. This will set up all the basics.

You might be asking, why are we using Cloudflare Workers? Because I preferred to keep the Cloudflare API related work within Cloudflare’s walled garden. API key, ideally, never leaves the platform. You will be directly making requests against the Cloudflare Worker and the worker will take care of interacting with Cloudflare API.

Architecture

Architecture for this setup

Code

We will be using TypeScript and Deno.

Cloudflare Worker

You need to make sure you have some secrets set:

  • WORKER_API_KEY: API key to authenticate with the Cloudflare worker
  • API_KEY: Cloudflare API key you created earlier
  • DNS_ZONE_ID: Cloudflare Zone ID. You can get it by making a GET request to https://api.cloudflare.com/client/v4/zones with "Authorization: Bearer API_KEY" in the headers. Be sure to replace API_KEY with your actual API key. Grab the id of the zone you want to update.
  • DNS_RECORD_ID: This is the ID of the Dynamic DNS record. You can find it by sending a GET request to https://api.cloudflare.com/client/v4/zones/DNS_ZONE_ID/dns_records. It will return an array of DNS records. Grab the id of the record you want to update.

wrangler.toml: These are the environment variables for the worker

[vars]
DDNS_NAME="ddns.YOUR_DOMAIN.com"
DDNS_COMMENT="DDNS for my server"
DDNS_PROXIED=true
Enter fullscreen mode Exit fullscreen mode

index.ts

import { getIp } from "./get-ip";
import { updateDnsRecord } from "./update-dns-record";

export default {
  async fetch(request, env): Promise<Response> {
    const WORKER_API_KEY = env.WORKER_API_KEY
    const API_KEY = request.headers.get('Authorization')

    if (API_KEY !== WORKER_API_KEY) {
      return new Response('Not Authorized', { status: 403 })
    }

    if (request.method === 'PUT') {
      const { ip } = await request.json() as { ip: string }
      return updateDnsRecord(ip, env)
    } else {
      return getIp(env)
    }
  },
} satisfies ExportedHandler<Env>;
Enter fullscreen mode Exit fullscreen mode

get-ip.ts

export async function getIp(env: Env) {
  const options = {
    method: "GET",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${env.API_KEY}`,
    }
  };

  try {
    const response = await fetch(
      `https://api.cloudflare.com/client/v4/zones/${env.DNS_ZONE_ID}/dns_records/${env.DNS_RECORD_ID}`,
      options
    )

    const { result: { content: ip } } = await response.json() as { result: { content: string } }

    return new Response(JSON.stringify({ ip }), { headers: { 'Content-Type': 'application/json' } });
  } catch (err) {
    console.error(err)
    return new Response(JSON.stringify(err))
  }
}
Enter fullscreen mode Exit fullscreen mode

update-dns-record.ts

export async function updateDnsRecord(ip: string, env: Env) {
  if (ip) {
    // snake_case because Cloudflare API uses snake_case :)
    const zone_id = env.DNS_ZONE_ID

    const id = env.DNS_RECORD_ID
    const name = env.DDNS_NAME
    const comment = env.DDNS_COMMENT
    const proxied = env.DDNS_PROXIED

    try {
      const update = {
        id,
        zone_id,
        name,
        comment,
        type: "A",
        content: ip,
        proxied
      }
      const body = JSON.stringify(update);

      const options = {
        method: "PUT",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${env.API_KEY}`,
        },
        body,
      };

      const response = await fetch(
        `https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${update.id}`,
        options
      )

      const responseBody = await response.json()

      return new Response(JSON.stringify(responseBody), { headers: { 'Content-Type': 'application/json' } });
    } catch (err) {
      console.error(err)
      return new Response(JSON.stringify(err))
    }
  }

  return new Response(JSON.stringify({ message: 'bad request' }));
}
Enter fullscreen mode Exit fullscreen mode

Docker Container

You can get started by installing deno on your machine and generate a new deno app with deno init ddns_sync.

I used Deno in this example, because it is so easy to get started and have a running code without installing bunch of stuff. Deno is so awesome for that. I installed zero packages to achieve cron and HTTP requests.

Dockerfile

FROM denoland/deno:alpine-2.1.4

WORKDIR /app

ADD *.ts .
ADD *.json .

RUN deno cache main.ts

ENTRYPOINT ["deno", "task", "start"]
Enter fullscreen mode Exit fullscreen mode

main.ts

Deno.cron("Sample cron job", "*/1 * * * *", async () => {
  const currentDateTime = new Date().toISOString()
  try {
    const [publicIp, ddnsIp] = await Promise.all([
      getPublicIp(),
      getExistingDdnsIp(),
    ]);

    if (publicIp === ddnsIp) {
      console.log(currentDateTime, "IP address did not change");
    } else {
      const updatedIp = await updateDdnsIp(publicIp);
      console.log(
        currentDateTime,
        updatedIp === publicIp
          ? "DDNS IP has been updated"
          : "DDNS failed to update"
      );
    }
  } catch (error) {
    console.error(currentDateTime, 'Error: ', error)
  }
});

async function getPublicIp(): Promise<string> {
  const response = await fetch("https://api.ipify.org/?format=json");
  const body = await response.json();

  return body.ip;
}

async function getExistingDdnsIp(): Promise<string> {
  const response = await fetch(Deno.env.get("WORKER_URL"), {
    headers: { Authorization: Deno.env.get("API_KEY") },
  });
  const body = await response.json();

  return body.ip;
}

async function updateDdnsIp(ip: string) {
  const options = {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
      Authorization: Deno.env.get("API_KEY"),
    },
    body: JSON.stringify({ ip }),
  };

  const response = await fetch(Deno.env.get("WORKER_URL"), options);
  const body = await response.json();

  return body.result.content;
}
Enter fullscreen mode Exit fullscreen mode

deno.json

{
  "tasks": {
    "dev": "deno run --unstable-cron --allow-net --allow-env --watch main.ts",
    "start": "deno run --unstable-cron --allow-env --allow-net main.ts"
  },
  "imports": {
    "@std/assert": "jsr:@std/assert@1"
  }
}
Enter fullscreen mode Exit fullscreen mode

You can build your docker image and run it on your server like this:

docker build -t ddns-sync .
docker run -d --name ddns-sync \
  -e API_KEY=WORKER_API_KEY \
  -e WORKER_URL="http://ddns.YOUR_USERNAME.workers.dev" \
  --restart unless-stopped ddns-sync
Enter fullscreen mode Exit fullscreen mode

That’s it. That should have you check your public IP every minute and compare it against the DNS record. If your public IP changes, this will automatically update it. You will get about a minute of outage if this happens.

I recently had to do this and decided to document it as a blog post. I hope someone finds this helpful!

The code quality is at the level of a homelab project, so there is definitely room for improvement. Let me know if you have suggestions on how to improve this.

If you have any questions or corrections, definitely leave a comment. I am more than happy to answer them.

Top comments (0)