├── README.md ├── LICENSE ├── agent.py └── worker.js /README.md: -------------------------------------------------------------------------------- 1 | # Worker DDNS 2 | 3 | Workers as a proxy in a DDNS setup that uses the cloudflare API. 4 | 5 | Both scripts, `worker.js` and `agent.py` don't require any extra dependencies, 6 | so it is very simple to deploy/setup. Just copy the files and you're done. 7 | 8 | More detailed documentation will be added soon. 9 | -------------------------------------------------------------------------------- /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, parse, 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) as err: 76 | logger.exception("Failed to update DNS record") 77 | else: 78 | logger.error("Cannot find configs. Aborting DNS update") 79 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------