commit 27ce46fa76f0c0f26bac1fd2c24de4eae9c3abb5 Author: Gonçalo Valério Date: Sun May 17 23:53:43 2020 +0100 Initial commit. At least it works. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9b7d05e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Gonçalo Valério + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f3bcb7 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# Worker DDNS + +Workers as a proxy in a DDNS setup that uses the cloudflare API. + +Both scripts, `worker.js` and `agent.py` don't require any extra dependencies, +so it is very simple to deploy/setup. Just copy the files and you're done. + +More detailed documentation will be added soon. diff --git a/agent.py b/agent.py new file mode 100755 index 0000000..b28d981 --- /dev/null +++ b/agent.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +""" +Simple agent script that collects the public IP address of the machine it is +running on and then updates a Cloudflare Worker. + +All requests are signed using a pre-shared key to ensure the integrity of the +message and authenticate the source. +""" +import os +import sys +import hmac +import json +import logging +import random +from datetime import datetime +from urllib import request, parse, error + + +logger = logging.getLogger(__name__) + +IP_SOURCES = [ + "https://api.ipify.org/", + "https://icanhazip.com/", + "https://ifconfig.me/", +] + +# For some reason the default urllib User-Agent is blocked +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" + + +def setup_logger() -> None: + form = logging.Formatter("%(asctime)s | %(levelname)s | %(message)s") + + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(logging.INFO) + handler.setFormatter(form) + + logger.setLevel(logging.INFO) + logger.addHandler(handler) + + +def get_ip_address() -> str: + url = random.choice(IP_SOURCES) + res = request.urlopen(url) + return res.read().decode("utf8").strip() + + +def sign_message(message: bytes, key: bytes) -> str: + message_hmac = hmac.new(key, message, digestmod="sha256") + return message_hmac.hexdigest() + + +def update_dns_record(url: str, key: str): + ip_addr = get_ip_address() + timestamp = int(datetime.now().timestamp()) + payload = json.dumps({"addr": ip_addr, "timestamp": timestamp}).encode("utf8") + signature = sign_message(payload, key.encode("utf8")) + + req = request.Request(f"https://{url}") + req.add_header("Content-Type", "application/json; charset=utf-8") + req.add_header("User-Agent", FAKE_USER_AGENT) + req.add_header("Authorization", signature) + req.add_header("Content-Length", len(payload)) + request.urlopen(req, payload) + logger.info("DNS Record updated successfully") + + +if __name__ == "__main__": + setup_logger() + key = os.environ.get("SHARED_KEY") + url = os.environ.get("WORKER_URL") + if key and url: + try: + update_dns_record(url, key) + except (error.URLError, error.HTTPError) as err: + logger.exception("Failed to update DNS record") + else: + logger.error("Cannot find configs. Aborting DNS update") diff --git a/worker.js b/worker.js new file mode 100644 index 0000000..c29c4c8 --- /dev/null +++ b/worker.js @@ -0,0 +1,137 @@ +/** + * This handled a request to update a given DNS record. + * The request should have the following format: + * + * { "addr": "", "timestamp": } + * + * The request must be made by the machine that the record will be pointed to + * and contain the HMAC (of the request body) in the "Authorization" header + */ +addEventListener("fetch", (event) => { + event.respondWith(handleRequest(event.request)); +}); + +/** + * Handles the request and validates if changes should be made or not + * @param {Request} request + */ +async function handleRequest(request) { + if (request.method === "POST") { + let valid_request = await is_valid(request); + if (valid_request) { + const addr = request.headers.get("cf-connecting-ip"); + await updateRecord(addr); + return new Response("Não há gente como a gente", { status: 200 }); + } + } + return new Response("Por cima", { status: 401 }); +} + +/** + * Checks if it is a valid and authentic request + * @param {Request} request + */ +async function is_valid(request) { + const window = 300; // 5 minutes in seconds + const rawBody = await request.text(); + let bodyContent = {}; + try { + bodyContent = JSON.parse(rawBody); + } catch (e) { + return false; + } + + const sourceAddr = request.headers.get("cf-connecting-ip"); + const signature = request.headers.get("authorization"); + if (!signature || !bodyContent.addr || sourceAddr != bodyContent.addr) { + return false; + } + + const valid_hmac = await verifyHMAC(signature, rawBody); + if (!valid_hmac) { + return false; + } + + const now = Math.floor(Date.now() / 1000); + if (now - bodyContent.timestamp > window) { + return false; + } + return true; +} + +/** + * Verifies the provided HMAC matches the message + * @param {String} signature + * @param {String} message + */ +async function verifyHMAC(signature, message) { + let encoder = new TextEncoder(); + let key = await crypto.subtle.importKey( + "raw", + encoder.encode(SHARED_KEY), + { name: "HMAC", hash: { name: "SHA-256" } }, + false, + ["verify"] + ); + + result = await crypto.subtle.verify( + "HMAC", + key, + hexToArrayBuffer(signature), + encoder.encode(message) + ); + return result; +} + +/** + * Updates the DNS record with the provided IP + * @param {String} addr + */ +async function updateRecord(addr) { + const base = "https://api.cloudflare.com/client/v4/zones"; + const init = { headers: { Authorization: `Bearer ${CF_API_TOKEN}` } }; + let record; + + let record_res = await fetch( + `${base}/${ZONE}/dns_records?name=${DNS_RECORD}`, + init + ); + if (record_res.ok) { + record = (await record_res.json()).result[0]; + } else { + console.log("Get record failed"); + return; + } + + if (record.content != addr) { + init.method = "PATCH"; + init.body = JSON.stringify({ content: addr }); + await fetch(`${base}/${ZONE}/dns_records/${record.id}`, init); + console.log("Updated record"); + } else { + console.log("Record content is the same, skipping update"); + } +} + +/** + * Transforms an HEX string into an ArrayBuffer + * Original work of: https://github.com/LinusU/hex-to-array-buffer + * @param {String} hex + */ +function hexToArrayBuffer(hex) { + if (typeof hex !== "string") { + throw new TypeError("Expected input to be a string"); + } + + if (hex.length % 2 !== 0) { + throw new RangeError("Expected string to be an even number of characters"); + } + + var view = new Uint8Array(hex.length / 2); + + for (var i = 0; i < hex.length; i += 2) { + view[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + + return view.buffer; +}