From ce0aa8bb7f9a8f72cb180d30c8676fa7f0045202 Mon Sep 17 00:00:00 2001 From: ssn Date: Sat, 25 Apr 2026 17:57:06 +0200 Subject: [PATCH] Initial commit --- README.md | 3 -- fixDNS.nu | 30 ++++++++++++++ readme.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 3 deletions(-) delete mode 100644 README.md create mode 100644 fixDNS.nu create mode 100644 readme.md diff --git a/README.md b/README.md deleted file mode 100644 index 02817a8..0000000 --- a/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# DIY-DynDNS-Nushell - -Automatically updating the Cloudflare DNS records for your self-hosted domain - with Nushell! \ No newline at end of file diff --git a/fixDNS.nu b/fixDNS.nu new file mode 100644 index 0000000..46b5afe --- /dev/null +++ b/fixDNS.nu @@ -0,0 +1,30 @@ +#!/usr/bin/env nu +#First stab at a nushell script, let's fix Cloudflare DNS +let timeStamp = date now | format date "%Y-%m-%dT%H:%M:%S" +let cfToken = "gbkanjmrKgCEqusC28anY3PNTrOdovurXTmbackA" +let myIp = http get https://api.ipify.org +let cfAPIBaseURI = "https://api.cloudflare.com/client/v4/" +let authHeader = { + Authorization: $"Bearer ($cfToken)" +} +let dnsZones = (http get $"($cfAPIBaseURI)zones" --headers $authHeader).result +#Iterate through the array of dnsZones +$dnsZones | each { + |zone| + #Get all the non-Tailscale A-records (those where the IP does not start with 100) that don't match $myIp + let badRecords = ((http get $"($cfAPIBaseURI)zones/($zone.id)/dns_records" --headers $authHeader).result | where type == "A" and content =~ "^(?!^100)" and content != $myIp) + $badRecords | each { + |badRecord| + #Update the DNS record to the correct IP + let recordBody = { + comment: $"($timeStamp) | Updated automatically from ($badRecord.content) to ($myIp)" + content: $"($myIp)" + } + let updateResult = (http patch $"($cfAPIBaseURI)zones/($zone.id)/dns_records/($badRecord.id)" $recordBody --headers $authHeader --content-type application/json) + if $updateResult.success { + print $"Successfully changed the DNS record for ($badRecord.name) from ($badRecord.content) to ($myIp)" + } else { + print $"Failed to change the DNS record for ($badRecord.name) from ($badRecord.content) to ($myIp)" + } + } +} | compact --empty #Don't output empty lists diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..7345e11 --- /dev/null +++ b/readme.md @@ -0,0 +1,116 @@ +*So to start learning NuShell I tasked myself with rewriting [my simple Powershell script](obsidian://open?vault=Personal&file=Glamdring%202%2FUpdate%20Cloudflare%20DNS%20entries%20automatically%20-%20With%20Powershell). Turns out I have quite a lot to learn, but it's a lot easier than [Bash](obsidian://open?vault=Personal&file=Glamdring%202%2FUpdate%20Cloudflare%20DNS%20entries%20automatically%20-%20With%20Bash). This doc is basically my learning notes meant mostly for my own use.* + +### Getting started +```bash +#!/usr/bin/env nu +``` +Shebang tells the system to use the NuShell interpreter. + +```nushell +let timeStamp = date now | format date "%Y-%m-%dT%H:%M:%S" +let cfToken = "hurdyburdytokengoeshere" +let myIp = http get https://api.ipify.org +let cfAPIBaseURI = "https://api.cloudflare.com/client/v4/" +let authHeader = { + Authorization: $"Bearer ($cfToken)" +} +``` +The usual suspects here, except we use the `let` command to set immutable variables. Note how the headers array is treated like an actual array and instead of relying on `curl`, NuShell has native `http get` capability. + +```nushell +let dnsZones = (http get $"($cfAPIBaseURI)zones" --headers $authHeader).result +``` +Using the URI & header variables we just established, the script pulls a JSON array of my DNS zones from Cloudflare, [as seen here](https://pastebin.com/fLGsDTVK). NuShell is made for structured data and thus natively understands JSON arrays. We're after the `result` part of the JSON response, hence the `.result` tacked on at the end, just like in PowerShell. +### Traversing the JSON array of DNS zones +```nushell +$dnsZones | each { + |zone| +``` +NuShell has no problems working with JSON arrays, so we do a simple `each` loop, declaring that the zone we're currently working on should be referred to as `$zone`. +### Traversing the arrays of DNS records in the array of DNS zones + +```nushell +let badRecords = ((http get $"($cfAPIBaseURI)zones/($zone.id)/dns_records" --headers $authHeader).result | where type == "A" and content =~ "^(?!^100)" and content != $myIp) +``` +Leveraging the built-in `http get` functionality we query the Cloudflare API for the DNS records of the DNS zone we're working with. As the JSON data returned from this query is natively supported in NuShell, we can filter it directly in order to only deal with A-records (`where type == "A"`) that are not TailScale entries, ie the IP does not begin with "100" (`and content =~ "^(?!^100)"`) and that aren't already set to the current WAN IP (`and content != $myIp`). To deal with any such records, it's time for another `each` loop: + +```nushell +$badRecords | each { + |badRecord| + let recordBody = { + comment: $"($timeStamp) | Updated automatically from ($badRecord.content) to ($myIp)" + content: $"($myIp)" + } +``` +*Oh look, native array support in a human-readable format!* +Determining that the current DNS record we're working with shall be called $badRecord, we create the (JSON) array body to submit to Cloudflare. `comment` is simply the comment we'd like to add, in this case that the record was automatically updated. `content` holds the IP we'd like the DNS record to have. +### Updating the DNS records +```nushell + let updateResult = (http patch $"($cfAPIBaseURI)zones/($zone.id)/dns_records/($badRecord.id)" $recordBody --headers $authHeader --content-type application/json) + if $updateResult.success { + print $"Successfully changed the DNS record for ($badRecord.name) from ($badRecord.content) to ($myIp)" + } else { + print $"Failed to change the DNS record for ($badRecord.name) from ($badRecord.content) to ($myIp)" + } +``` +The array we just created is submitted to Cloudflare via `http patch`, this time adding `--content-type application/json` to inform the API that we're sending a JSON array. Storing the output of the `http patch` commmand as the `$updateResult` variable means that we can determine the outcome by simply reading the boolean `.success` property and printing the appropriate information. + +Our nested `each` loop can now continue with the next bad DNS record for the current DNS zone and when it's gone through them all, the parent `each` loop can move on to the next DNS zone. + +That's it. + +### Doing it all automatically +The point of using a script to check that your DNS records are actually pointing to your home IP is to have it run automatically. On whichever linux instance you deem suited (it should be located where you want your DNS records to point, such as your homelab), simply create a cron job: +```bash +crontab -e +``` +This will open an editor showing the current (if any) scheduled jobs. I want my script, `/etc/fixDNS.sh`, to run every hour, on the hour, 24/7. This means my cron entry looks like this: +``` +0 * * * * /etc/fixDNS.nu +``` +Absolutely unreadable if you've never dealt with cron jobs before, but there **is** logic to it: +``` +[Minute] [hour] [Day_of_the_Month] [Month_of_the_Year] [Day_of_the_Week] [command] +``` +First column is the minutes. We want it to run at `0`, meaning 12:00, 13:00 etc. +Second column is the hour. To run it at noon every day, set this to `12`. We want it every hour of the day, so we're going with `*`. +Third: Day of the month, type in `1`to run it on the first, we want it running every day, so it's another `*` for us. +Fourth column: Month of the year. Only want to do this in June and July? Go with `6,7`. We want 'em all, `*` yet again. +Fifth column: Day of the week. For Mondays, `1` is what you need, `2`for Tuesdays etc. Take a wild guess at what `*` means here +Sixth and final columt is what to actually *do*. Simple enough in this case, the script is saved in /etc and called "fixDNS.sh", so `/etc/fixDNS.sh` gets the job done. + +Now save the file (`ctrl+x` in Nano) and that's all there is to it! + +### Full source code +```nushell +#!/usr/bin/env nu +#First stab at a nushell script, let's fix Cloudflare DNS +let timeStamp = date now | format date "%Y-%m-%dT%H:%M:%S" +let cfToken = "gbkanjmrKgCEqusC28anY3PNTrOdovurXTmbackA" +let myIp = http get https://api.ipify.org +let cfAPIBaseURI = "https://api.cloudflare.com/client/v4/" +let authHeader = { + Authorization: $"Bearer ($cfToken)" +} +let dnsZones = (http get $"($cfAPIBaseURI)zones" --headers $authHeader).result +#Iterate through the array of dnsZones +$dnsZones | each { + |zone| + #Get all the non-Tailscale A-records (those where the IP does not start with 100) that don't match $myIp + let badRecords = ((http get $"($cfAPIBaseURI)zones/($zone.id)/dns_records" --headers $authHeader).result | where type == "A" and content =~ "^(?!^100)" and content != $myIp) + $badRecords | each { + |badRecord| + #Update the DNS record to the correct IP + let recordBody = { + comment: $"($timeStamp) | Updated automatically from ($badRecord.content) to ($myIp)" + content: $"($myIp)" + } + let updateResult = (http patch $"($cfAPIBaseURI)zones/($zone.id)/dns_records/($badRecord.id)" $recordBody --headers $authHeader --content-type application/json) + if $updateResult.success { + print $"Successfully changed the DNS record for ($badRecord.name) from ($badRecord.content) to ($myIp)" + } else { + print $"Failed to change the DNS record for ($badRecord.name) from ($badRecord.content) to ($myIp)" + } + } +} | compact --empty #Don't output empty lists +``` \ No newline at end of file