Initial commit. At least it works.
This commit is contained in:
commit
27ce46fa76
|
@ -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.
|
|
@ -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.
|
|
@ -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")
|
|
@ -0,0 +1,137 @@
|
||||||
|
/**
|
||||||
|
* This handled a request to update a given DNS record.
|
||||||
|
* The request should have the following format:
|
||||||
|
*
|
||||||
|
* { "addr": "<ipv4_addr>", "timestamp": <unix_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;
|
||||||
|
}
|
Loading…
Reference in New Issue