| src | ||
| vendor | ||
| .gitignore | ||
| .luarc.json | ||
| LICENSE | ||
| meson.build | ||
| README.md | ||
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
- Meson build system
- C compiler (GCC or Clang)
- libcurl with development headers
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:
-
Run directly from the build directory:
./builddir/luaddnstool ./path/to/script.lua -
Install system-wide:
sudo meson install -C builddirThen 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:
- The response's status code is 4xx/5xx
- 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:
- the response's status code is 4xx/5xx
- 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:
-
Add your function to
src/ddns.lua- Follow the existing patterns
- Make your implementation clean, readable, and well-commented
- Use helper functions from
helpers.lualikefetch(),urlencode(),json.encode() - Error handling: raise errors with descriptive messages for failures
-
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
-
Test your implementation
- Create a test script that exercises your function
- Verify it works correctly with the actual service
- Test error conditions
-
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
- Title your PR:
License
Made with ❤ by Lua (foxgirl.dev). Licensed under MIT.