Files
DIY-DynDNS-Nushell/readme.md
T
2026-04-25 17:57:06 +02:00

7.3 KiB

So to start learning NuShell I tasked myself with rewriting my simple Powershell script. Turns out I have quite a lot to learn, but it's a lot easier than Bash. This doc is basically my learning notes meant mostly for my own use.

Getting started

#!/usr/bin/env nu

Shebang tells the system to use the NuShell interpreter.

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.

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. 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

$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

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:

$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

        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:

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 1to 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, 2for 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

#!/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