Tiny C program for managing Dynamic DNS records with Lua scripts
Find a file
2025-12-10 14:33:16 -08:00
src implement porkbun_update_record 2025-12-10 13:24:22 -08:00
vendor disable lua API checks 2025-12-10 13:37:09 -08:00
.gitignore initial commit 2025-12-09 23:02:25 -08:00
.luarc.json implement c stubs with libcurl, implement he_update_record 2025-12-10 12:48:00 -08:00
LICENSE initial commit 2025-12-09 23:02:25 -08:00
meson.build implement c stubs with libcurl, implement he_update_record 2025-12-10 12:48:00 -08:00
README.md make checkip documentation consistent with behaviour 2025-12-10 14:33:16 -08:00

luaddnstool

luaddnstool is a tiny C program for managing Dynamic DNS records on various DNS hosting services using simple Lua scripts.

You use it like this:

$ luaddnstool ./myscript.lua

The provided Lua script runs in a Lua 5.3 environment with access to the standard library, including io and os, plus specialized DDNS API functions and various utility functions.

Here's a simple example:

local old_ip = nil
while true do
  local new_ip = checkip()
  if old_ip ~= new_ip then
    print("ip changed from "..tostring(old_ip).." to "..new_ip)
    old_ip = new_ip
    local ok, error = pcall(function()
      local changed = he_update_record("A", "paws.lua.pet", "myawesomepassword", new_ip)
      print(changed and "he record updated" or "he record not updated")
    end)
    if not ok then
      print(error)
      email({
        url = "smtp://mail.example.com:587",
        username = "myawesomeusername",
        password = "myawesomepassword",
        from = "ddns@lua.pet",
        to = "lua@foxgirl.dev",
        subject = "DDNS update failed",
        body = "Whoopsie!\n" .. tostring(error)
      })
    end
  end
  sleep(300)
end

Installation

Prerequisites

On Debian/Ubuntu:

sudo apt install build-essential meson libcurl4-openssl-dev

On Arch Linux:

sudo pacman -S meson gcc curl

Download

Clone the repository:

git clone https://git.vixen.computer/lua/luaddnstool
cd luaddnstool

Build

Set up the build directory:

meson setup builddir --buildtype=release

Compile the project:

meson compile -C builddir

Install

After building, you can either:

  1. Run directly from the build directory:

    ./builddir/luaddnstool ./path/to/script.lua
    
  2. Install system-wide:

    sudo meson install -C builddir
    

    Then run from anywhere:

    luaddnstool ./path/to/script.lua
    

DDNS API functions

he_update_record(record_type, name, password, content)

Updates a Dynamic DNS record on Hurricane Electric's Hosted DNS service.

Read the docs at https://dns.he.net/.

Parameters:

  • record_type - DNS record type (e.g., A, AAAA, or TXT)
  • name - Fully qualified domain name to update
  • password - Dynamic DNS password for the record
  • content - Answer content for the DNS record (e.g., IP address)

Returns true if the update was successful or false if the record is unchanged. Raises an error if the update fails.

Example:

local current_ip = checkip()
he_update_record("A", "home.example.com", "the-ddns-password", current_ip)

porkbun_update_record(options)

Updates a DNS record on Porkbun DNS via the REST API.

Read the docs at https://porkbun.com/api/json/v3/documentation.

Supports the following parameters in the options table:

Field Type Required Description
secretapikey string Yes Porkbun secret API key
apikey string Yes Porkbun API key
type string Yes DNS record type (e.g., A, AAAA, or TXT)
domain string Yes Domain name to edit (eg. example.com)
subdomain string Yes Subdomain entry to edit (eg. www)
content string Yes Answer content for the DNS record (e.g., IP address)
ttl string No Time to live in seconds
prio string No Priority of the record for those that support it
notes string No Notes to set for the record, pass empty string to clear

Returns nothing. Raises an error if the update fails.

Example:

porkbun_update_record({
  secretapikey = os.getenv("PORKBUN_SECRET_APIKEY"),
  apikey = os.getenv("PORKBUN_APIKEY"),
  type = "A",
  domain = "example.com",
  subdomain = "www",
  content = checkip(),
})

Utility functions

json.encode(value) / json.decode(value)

Encodes/decodes JSON using rxi's json.lua library.

Example:

local decoded_table = json.decode('{ "key": "value" }')
print(decoded_table.key)  -- Prints "value"

local encoded_string = json.encode({ foo = "bar", x = 10 })
print(encoded_string)  -- Prints '{"foo":"bar","x":10}'

sleep(seconds)

Pauses execution for the specified number of seconds.

Example:

sleep(30)  -- Waits for 30 seconds

dig(record_type, name)

Performs a DNS lookup for the specified record type and name.

Parameters:

  • record_type - DNS record type (e.g., A, AAAA, CNAME, etc.)
  • name - Domain name to query

Returns the DNS record value as a string, or nil if not found.

Example:

local a_record = dig("A", "example.com")
local txt_record = dig("TXT", "_dmarc.example.com")

fetch(url, options)

Performs a HTTP(S) request to the specified URL.

Supports the following optional parameters provided in the options table:

Field Type Description
method string HTTP method (GET, POST, etc.), default GET
headers table HTTP headers as key-value pairs
body string Request body for POST/PUT
username string Username for basic auth
password string Password for basic auth
raise boolean Error on 4xx/5xx status codes, default true
timeout number Timeout in seconds, default 120s
verifyssl boolean Verify SSL certificates, default true
ipversion string IP version to use: ipv4, ipv6, or nil for default

Returns a table with the following fields:

Field Type Description
status number HTTP status code
headers table Response headers as key-value pairs, lowercase names
body string Response body

Raises an error if the request fails, or if both:

  1. The response's status code is 4xx/5xx
  2. The raise option is not false

Example:

local response = fetch("https://api.example.com", {
  method = "POST",
  headers = { ["Content-Type"] = "application/json" },
  body = json.encode({ data = "value" }),
  timeout = 10
})

The headers table can include multiple headers with the same name by providing a table of values:

fetch("https://api.example.com", {
  headers = {
    ["X-Custom-Header"] = {"value1", "value2"}
  }
})

If a response contains multiple headers with the same name (e.g., Set-Cookie), they are stored as a table:

local response = fetch("https://example.com")
-- If multiple Set-Cookie headers exist:
-- response.headers["set-cookie"] = {"id=123", "theme=light"}
-- Otherwise, a single value:
-- response.headers["content-type"] = "text/html"

email(options)

Sends an email via SMTP.

Supports the following parameters in the options table:

Field Type Required Description
url string Yes SMTP server (e.g., smtp://mail.example.com:587)
username string Yes SMTP username
password string Yes SMTP password
from string Yes Sender email address
to string/array Yes Recipient(s)
cc string/array No CC recipient(s)
subject string Yes Email subject
body string Yes Email body
raise boolean No Error on 4xx/5xx status codes, default true
timeout number No Timeout in seconds, default 300s
verifyssl boolean No Verify SSL certificates, default true
ipversion string No IP version to use: ipv4, ipv6, or nil for default

Returns a table with the following fields:

Field Type Description
status number SMTP status code

Raises an error if sending fails, or if both:

  1. the response's status code is 4xx/5xx
  2. the raise option is not false

Example:

email({
  url = "smtp://mail.example.com:587",
  username = "sender@example.com",
  password = "app-password",
  from = "sender@example.com",
  to = {"recipient@example.com"},
  subject = "Alert",
  body = "Something bad happened!"
})

checkip()

Gets your current public IPv4 address using Cloudflare's canihazip.com, falling back to whatismyip.akamai.com if Cloudflare is broken.

Returns the IPv4 address as a string. Raises an error if no IPv4 address is available or if something goes wrong.

Example:

print(checkip())  -- Prints "23.215.0.138"

checkip6()

Gets your current public IPv6 address using Cloudflare's canihazip.com, falling back to whatismyip.akamai.com if Cloudflare is broken.

Returns the IPv6 address as a string. Raises an error if no IPv6 address is available or if something goes wrong.

Example:

print(checkip6())  -- Prints "2600:1408:ec00:36::1736:7f24"

urlencode(str, formdata)

URL-encodes the provided string.

Parameters:

  • str - String to encode
  • formdata - If true, spaces are encoded as '+' and newlines are normalized, default false

Example:

local encoded = urlencode("hello world!")
print(encoded)  -- Prints "hello%20world%21"

print(urlencode('hello world!\ngoodbye, world.', true))  -- Prints "hello+world%21%0D%0Agoodbye%2C+world."

Contributing New DDNS Functions

To add support for a new Dynamic DNS provider:

  1. Add your function to src/ddns.lua

    • Follow the existing patterns
    • Make your implementation clean, readable, and well-commented
    • Use helper functions from helpers.lua like fetch(), urlencode(), json.encode()
    • Error handling: raise errors with descriptive messages for failures
  2. Update the README.md

    • Add a new section under "DDNS API functions" documenting your function
    • Include all parameters, their types, and whether they're required
    • Provide a complete usage example
  3. Test your implementation

    • Create a test script that exercises your function
    • Verify it works correctly with the actual service
    • Test error conditions
  4. Submit a pull request

    • Title your PR: new ddns function: [Service Name]
    • In the description, include:
      • Link to the service's website and documentation
      • Brief explanation of how the function works

License

Made with ❤ by Lua (foxgirl.dev). Licensed under MIT.