├── LICENSE ├── agent.py ├── README.md └── worker.js /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Gonçalo Valério 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /agent.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simple agent script that collects the public IP address of the machine it is 4 | running on and then updates a Cloudflare Worker. 5 | 6 | All requests are signed using a pre-shared key to ensure the integrity of the 7 | message and authenticate the source. 8 | """ 9 | import os 10 | import sys 11 | import hmac 12 | import json 13 | import logging 14 | import random 15 | from datetime import datetime 16 | from urllib import request, error 17 | 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | IP_SOURCES = [ 22 | "https://api.ipify.org/", 23 | "https://icanhazip.com/", 24 | "https://ifconfig.me/", 25 | ] 26 | 27 | # For some reason the default urllib User-Agent is blocked 28 | FAKE_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36" 29 | 30 | 31 | def setup_logger() -> None: 32 | form = logging.Formatter("%(asctime)s | %(levelname)s | %(message)s") 33 | 34 | handler = logging.StreamHandler(sys.stdout) 35 | handler.setLevel(logging.INFO) 36 | handler.setFormatter(form) 37 | 38 | logger.setLevel(logging.INFO) 39 | logger.addHandler(handler) 40 | 41 | 42 | def get_ip_address() -> str: 43 | url = random.choice(IP_SOURCES) 44 | res = request.urlopen(url) 45 | return res.read().decode("utf8").strip() 46 | 47 | 48 | def sign_message(message: bytes, key: bytes) -> str: 49 | message_hmac = hmac.new(key, message, digestmod="sha256") 50 | return message_hmac.hexdigest() 51 | 52 | 53 | def update_dns_record(url: str, key: str): 54 | ip_addr = get_ip_address() 55 | timestamp = int(datetime.now().timestamp()) 56 | payload = json.dumps({"addr": ip_addr, "timestamp": timestamp}).encode("utf8") 57 | signature = sign_message(payload, key.encode("utf8")) 58 | 59 | req = request.Request(f"https://{url}") 60 | req.add_header("Content-Type", "application/json; charset=utf-8") 61 | req.add_header("User-Agent", FAKE_USER_AGENT) 62 | req.add_header("Authorization", signature) 63 | req.add_header("Content-Length", len(payload)) 64 | request.urlopen(req, payload) 65 | logger.info("DNS Record updated successfully") 66 | 67 | 68 | if __name__ == "__main__": 69 | setup_logger() 70 | key = os.environ.get("SHARED_KEY") 71 | url = os.environ.get("WORKER_URL") 72 | if key and url: 73 | try: 74 | update_dns_record(url, key) 75 | except (error.URLError, error.HTTPError): 76 | logger.exception("Failed to update DNS record") 77 | else: 78 | logger.error("Cannot find configs. Aborting DNS update") 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Worker DDNS 2 | 3 | This repository provides two simple scripts that, together, will allow you to build a 4 | simple and efficient DDNS system using Cloudflare Workers and DNS. 5 | 6 | **Example use case:** You have a machine where the IP address is dynamically assigned and 7 | changes frequently. 8 | 9 | The `agent.py` will regularly contact the CF worker running the `worker.js` code, 10 | that will in turn use the Cloudflare API to update the DNS record in question 11 | with the new IP address. 12 | 13 | ## Why use Workers 14 | 15 | Because we don't want to sign up for an extra external service, we want to apply 16 | the principle of the least privilege, and the name should belong to a domain we 17 | control. 18 | 19 | Since Cloudflare API Token permissions aren't granular enough to limit the token 20 | access to a single DNS record, we place a worker in front of it (this way the token 21 | with extra privileges, never leaves Cloudflare's servers). 22 | 23 | ## Usage 24 | 25 | Both scripts (`worker.js` and `agent.py`) don't require any extra dependencies 26 | (they rely only on the existing "standard libraries"), so they can be copied, right 27 | out of the repository to the destination without any extra steps. 28 | 29 | Before starting, you need to create a new API Token on your Cloudflare's profile page with 30 | permissions to edit the DNS records of one of your domains (Zone). 31 | 32 | ### Worker 33 | 34 | The next step is to create a new worker and then set `worker.js` as its content. 35 | This can be easily done using the "Quick Edit" button on the worker's detail page. 36 | 37 | Add the following environment variables on the worker settings tab: 38 | 39 | - `CF_API_TOKEN` - The token you just created. You just also click on the 40 | "encrypt" button. 41 | - `SHARED_KEY` - Generate a long and random string and put it here. Click encrypt. 42 | - `DNS_RECORD` - the DNS record that should be updated. Something like 43 | `.`. 44 | - `ZONE` - The zone_id of your domain. You can find it on the sidebar of the domain 45 | overview page. 46 | 47 | Then deploy the worker. 48 | 49 | ### Agent 50 | 51 | Copy the `agent.py` file to the machine where you want your subdomain/domain 52 | "pointed to". 53 | 54 | Set the following environment variables: 55 | 56 | - `SHARED_KEY` - The same long and random string you generated for the worker. 57 | - `WORKER_URL` - The URL of your worker. 58 | 59 | Then execute the script: 60 | 61 | ```bash 62 | $ ./agent.py 63 | ``` 64 | 65 | In the most common scenario, you will want to run it periodically. So you will need to 66 | use a scheduler like `cron` or a `systemd timer unit`. 67 | 68 | Here's a simple example that can be inserted after running `crontab -e`: 69 | 70 | ``` 71 | SHARED_KEY= 72 | WORKER_URL= 73 | */5 * * * * /path/to/agent.py 74 | ``` 75 | 76 | On the other hand, if you prefer to use `systemd`, the configuration would look like this: 77 | 78 | ``` 79 | # ddns.service 80 | 81 | [Unit] 82 | Description=Updates the DNS record with IP address 83 | Wants=ddns.timer 84 | 85 | [Service] 86 | Environment="SHARED_KEY=" 87 | Environment="WORKER_URL=" 88 | Type=oneshot 89 | ExecStart=/path/to/agent.py 90 | 91 | [Install] 92 | WantedBy=multi-user.target 93 | ``` 94 | 95 | ``` 96 | # ddns.timer 97 | 98 | [Unit] 99 | Description=Runs the DDNS agent periodically 100 | Requires=ddns.service 101 | 102 | [Timer] 103 | Unit=ddns.service 104 | OnBootSec=60 105 | OnUnitActiveSec=5m 106 | 107 | [Install] 108 | WantedBy=timers.target 109 | ``` 110 | -------------------------------------------------------------------------------- /worker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This handled a request to update a given DNS record. 3 | * The request should have the following format: 4 | * 5 | * { "addr": "", "timestamp": } 6 | * 7 | * The request must be made by the machine that the record will be pointed to 8 | * and contain the HMAC (of the request body) in the "Authorization" header 9 | */ 10 | addEventListener("fetch", (event) => { 11 | event.respondWith(handleRequest(event.request)); 12 | }); 13 | 14 | /** 15 | * Handles the request and validates if changes should be made or not 16 | * @param {Request} request 17 | */ 18 | async function handleRequest(request) { 19 | if (request.method === "POST") { 20 | let valid_request = await is_valid(request); 21 | if (valid_request) { 22 | const addr = request.headers.get("cf-connecting-ip"); 23 | await updateRecord(addr); 24 | return new Response("Não há gente como a gente", { status: 200 }); 25 | } 26 | } 27 | return new Response("Por cima", { status: 401 }); 28 | } 29 | 30 | /** 31 | * Checks if it is a valid and authentic request 32 | * @param {Request} request 33 | */ 34 | async function is_valid(request) { 35 | const window = 300; // 5 minutes in seconds 36 | const rawBody = await request.text(); 37 | let bodyContent = {}; 38 | try { 39 | bodyContent = JSON.parse(rawBody); 40 | } catch (e) { 41 | return false; 42 | } 43 | 44 | const sourceAddr = request.headers.get("cf-connecting-ip"); 45 | const signature = request.headers.get("authorization"); 46 | if (!signature || !bodyContent.addr || sourceAddr != bodyContent.addr) { 47 | return false; 48 | } 49 | 50 | const valid_hmac = await verifyHMAC(signature, rawBody); 51 | if (!valid_hmac) { 52 | return false; 53 | } 54 | 55 | const now = Math.floor(Date.now() / 1000); 56 | if (now - bodyContent.timestamp > window) { 57 | return false; 58 | } 59 | return true; 60 | } 61 | 62 | /** 63 | * Verifies the provided HMAC matches the message 64 | * @param {String} signature 65 | * @param {String} message 66 | */ 67 | async function verifyHMAC(signature, message) { 68 | let encoder = new TextEncoder(); 69 | let key = await crypto.subtle.importKey( 70 | "raw", 71 | encoder.encode(SHARED_KEY), 72 | { name: "HMAC", hash: { name: "SHA-256" } }, 73 | false, 74 | ["verify"] 75 | ); 76 | 77 | result = await crypto.subtle.verify( 78 | "HMAC", 79 | key, 80 | hexToArrayBuffer(signature), 81 | encoder.encode(message) 82 | ); 83 | return result; 84 | } 85 | 86 | /** 87 | * Updates the DNS record with the provided IP 88 | * @param {String} addr 89 | */ 90 | async function updateRecord(addr) { 91 | const base = "https://api.cloudflare.com/client/v4/zones"; 92 | const init = { headers: { Authorization: `Bearer ${CF_API_TOKEN}` } }; 93 | let record; 94 | 95 | let record_res = await fetch( 96 | `${base}/${ZONE}/dns_records?name=${DNS_RECORD}`, 97 | init 98 | ); 99 | if (record_res.ok) { 100 | record = (await record_res.json()).result[0]; 101 | } else { 102 | console.log("Get record failed"); 103 | return; 104 | } 105 | 106 | if (record.content != addr) { 107 | init.method = "PATCH"; 108 | init.body = JSON.stringify({ content: addr }); 109 | await fetch(`${base}/${ZONE}/dns_records/${record.id}`, init); 110 | console.log("Updated record"); 111 | } else { 112 | console.log("Record content is the same, skipping update"); 113 | } 114 | } 115 | 116 | /** 117 | * Transforms an HEX string into an ArrayBuffer 118 | * Original work of: https://github.com/LinusU/hex-to-array-buffer 119 | * @param {String} hex 120 | */ 121 | function hexToArrayBuffer(hex) { 122 | if (typeof hex !== "string") { 123 | throw new TypeError("Expected input to be a string"); 124 | } 125 | 126 | if (hex.length % 2 !== 0) { 127 | throw new RangeError("Expected string to be an even number of characters"); 128 | } 129 | 130 | var view = new Uint8Array(hex.length / 2); 131 | 132 | for (var i = 0; i < hex.length; i += 2) { 133 | view[i / 2] = parseInt(hex.substring(i, i + 2), 16); 134 | } 135 | 136 | return view.buffer; 137 | } 138 | --------------------------------------------------------------------------------