Yesterday I mentioned that I needed a solution for registering SLAAC addresses against DNS hostnames. Within the network I run MikroTik routers, and their internal DNS, as well as a container with pi-hole. I try to keep the network related stuff in the MikroTiks, as that seems to work for my sense of order. But the MTs don't have a nice way of keeping dynamic addresses updated in static DNS records.
There are some community scripts, typically in the RouterOS scripting language which I've not even barely looked at. And while that works kinda OK for DHCP issued leases, SLAAC turns that on its head, because the client is the one deciding what address to use. This leaves the poor MT trying to figure out the network state based on neighbour discovery state. :bleurgh:
So I sent Claude off on a little mission, and without too much fuss I was the proud father of a sloppy little bit of Bash.
I've passed a cursory glance over it, and while I can't see anything glaringly obviously risky, I'm sure I missed a few issues. Time will tell.
I'll add a setup for the lab git repo, and perhaps add it to the LXC template once it's festered for a while.
Use at your own risk. No warranty given or implied. You break it you get to keep both parts. Have fun.
Editors note: remember to tell your friendly agent to use ASCII only.
#!/usr/bin/env bash
# =============================================================================
# mikrotik-dns-register.sh
#
# Registers this host's IPv4 and IPv6 addresses as static DNS entries on a
# MikroTik router via the RouterOS REST API (requires RouterOS 7+).
#
# Dependencies: curl, ip (iproute2), jq
#
# Usage:
# mikrotik-dns-register.sh [OPTIONS]
#
# Options:
# -h, --host HOST MikroTik hostname or IP (required, or set MIKROTIK_HOST)
# -u, --user USER RouterOS API username (default: admin, or MIKROTIK_USER)
# -p, --pass PASS RouterOS API password (required, or set MIKROTIK_PASS)
# -n, --name HOSTNAME Override the DNS name to register (default: auto-detect)
# -d, --domain DOMAIN Domain suffix to append if hostname is not fully qualified
# -i, --iface IFACE Network interface to read addresses from (default: auto)
# --ipv4-only Only register an A record
# --ipv6-only Only register AAAA records
# --ipv6-mode MODE Which IPv6 addresses to register:
# ula - ULA only (fc00::/7) [default]
# gua - GUA only (2000::/3, excluding privacy addrs)
# both - ULA + GUA (two AAAA records)
# prefer-ula - ULA if present, else fall back to GUA
# --ttl SECONDS DNS TTL (default: 300)
# --https Use HTTPS for the RouterOS API (default: HTTP)
# --dry-run Print what would be done without making changes
# -v, --verbose Enable verbose output
# --help Show this help
#
# Configuration file (optional, sourced if present):
# /etc/mikrotik-dns-register.conf
# ~/.config/mikrotik-dns-register.conf
#
# Example configuration file:
# MIKROTIK_HOST="192.168.88.1"
# MIKROTIK_USER="dns-updater"
# MIKROTIK_PASS="secret"
# DNS_DOMAIN="lab.example.com"
# IPV6_MODE="prefer-ula"
#
# =============================================================================
set -euo pipefail
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
MIKROTIK_HOST="${MIKROTIK_HOST:-}"
MIKROTIK_USER="${MIKROTIK_USER:-admin}"
MIKROTIK_PASS="${MIKROTIK_PASS:-}"
DNS_NAME=""
DNS_DOMAIN=""
IFACE=""
REGISTER_IPV4=true
REGISTER_IPV6=true
IPV6_MODE="ula" # ula | gua | both | prefer-ula
TTL=300
USE_HTTPS=false
DRY_RUN=false
VERBOSE=false
# ---------------------------------------------------------------------------
# Source config files if present
# ---------------------------------------------------------------------------
for conf in /etc/mikrotik-dns-register.conf "$HOME/.config/mikrotik-dns-register.conf"; do
# shellcheck source=/dev/null
[[ -f "$conf" ]] && source "$conf"
done
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2; }
info() { log "INFO $*"; }
warn() { log "WARN $*"; }
dbg() { $VERBOSE && log "DEBUG $*" || true; }
die() { log "ERROR $*"; exit 1; }
usage() {
sed -n '/^# Usage:/,/^# ====/p' "$0" | grep '^#' | sed 's/^# \{0,2\}//'
exit 0
}
# ---------------------------------------------------------------------------
# CLI argument parsing
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--host) MIKROTIK_HOST="$2"; shift 2 ;;
-u|--user) MIKROTIK_USER="$2"; shift 2 ;;
-p|--pass) MIKROTIK_PASS="$2"; shift 2 ;;
-n|--name) DNS_NAME="$2"; shift 2 ;;
-d|--domain) DNS_DOMAIN="$2"; shift 2 ;;
-i|--iface) IFACE="$2"; shift 2 ;;
--ipv4-only) REGISTER_IPV6=false; shift ;;
--ipv6-only) REGISTER_IPV4=false; shift ;;
--ipv6-mode) IPV6_MODE="$2"; shift 2 ;;
--ttl) TTL="$2"; shift 2 ;;
--https) USE_HTTPS=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
-v|--verbose) VERBOSE=true; shift ;;
--help) usage ;;
*) die "Unknown argument: $1. Use --help for usage." ;;
esac
done
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
[[ -z "$MIKROTIK_HOST" ]] && die "MikroTik host not set. Use -h or set MIKROTIK_HOST."
[[ -z "$MIKROTIK_PASS" ]] && die "MikroTik password not set. Use -p or set MIKROTIK_PASS."
command -v curl >/dev/null 2>&1 || die "'curl' is required but not installed."
command -v ip >/dev/null 2>&1 || die "'ip' (iproute2) is required but not installed."
command -v jq >/dev/null 2>&1 || die "'jq' is required but not installed."
case "$IPV6_MODE" in
ula|gua|both|prefer-ula) ;;
*) die "Invalid --ipv6-mode '$IPV6_MODE'. Choose: ula, gua, both, prefer-ula" ;;
esac
# ---------------------------------------------------------------------------
# DNS name resolution
# ---------------------------------------------------------------------------
resolve_dns_name() {
local name="${DNS_NAME:-$(hostname -f 2>/dev/null || hostname)}"
# Append domain if name is unqualified and DNS_DOMAIN is set
[[ "$name" != *.* && -n "$DNS_DOMAIN" ]] && name="${name}.${DNS_DOMAIN}"
echo "${name%.}" # strip any trailing dot
}
# ---------------------------------------------------------------------------
# Address discovery
# ---------------------------------------------------------------------------
get_ipv4() {
local filter="${IFACE:+dev $IFACE}"
local addr
# shellcheck disable=SC2086
addr=$(ip -4 addr show $filter scope global 2>/dev/null \
| awk '/inet /{print $2}' \
| cut -d/ -f1 \
| grep -Ev '^(127\.|169\.254\.)' \
| head -1) || true # # prevent pipefail from propagating
[[ -n "$addr" ]] && echo "$addr" || return 0
}
# Classify an IPv6 address: ll | ula | gua | other
classify_ipv6() {
case "${1,,}" in
fe80*) echo "ll" ;;
f[cd]*) echo "ula" ;;
[23]*) echo "gua" ;;
*) echo "other" ;;
esac
}
# Emit stable global-scope IPv6 addresses, one per line.
# Skips: link-local, temporary/privacy (mngtmpaddr), deprecated.
get_ipv6_candidates() {
local filter="${IFACE:+dev $IFACE}"
# shellcheck disable=SC2086
ip -6 addr show $filter 2>/dev/null \
| awk '/inet6/ && /scope global/ && /dynamic mngtmpaddr/ && !/temporary|deprecated/ {
addr = $2; sub(/\/[0-9]+$/, "", addr); print addr
}'
}
# Return the first IPv6 address of a given class from stdin
first_of_class() {
local class="$1"
while IFS= read -r addr; do
[[ $(classify_ipv6 "$addr") == "$class" ]] && echo "$addr" && return
done
true # not finding one is not an error
}
# ---------------------------------------------------------------------------
# MikroTik REST API
# ---------------------------------------------------------------------------
SCHEME=$( $USE_HTTPS && echo "https" || echo "http" )
API_BASE="${SCHEME}://${MIKROTIK_HOST}/rest"
api_call() {
local method="$1" endpoint="$2" body="${3:-}"
local curl_opts=(
--silent --show-error --fail-with-body
-u "${MIKROTIK_USER}:${MIKROTIK_PASS}"
-H "Content-Type: application/json"
-X "$method"
)
$USE_HTTPS && curl_opts+=(--insecure) # self-signed cert common in homelabs
[[ -n "$body" ]] && curl_opts+=(-d "$body")
dbg "API $method ${API_BASE}${endpoint} ${body:+$body}"
curl "${curl_opts[@]}" "${API_BASE}${endpoint}"
}
# ---------------------------------------------------------------------------
# DNS record upsert
# upsert_record NAME ADDRESS TYPE
# TYPE: A or AAAA
# AAAA records are matched by address class (ULA vs GUA) so --ipv6-mode=both
# can maintain two separate records without them overwriting each other.
# ---------------------------------------------------------------------------
upsert_record() {
local name="$1" address="$2" type="$3"
# Fetch existing static records for this hostname
local existing
existing=$(api_call GET "/ip/dns/static?name=${name}" 2>/dev/null || echo "[]")
dbg "Existing records for $name: $existing"
# Find the ID of a matching existing record
local existing_id
if [[ "$type" == "A" ]]; then
# Match an A record by IPv4 address pattern
existing_id=$(jq -r '
.[] | select(
(.address // "" | test("^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$")) and
((.type // "A") == "A")
) | .".id"' <<< "$existing" | head -1)
else
# Match AAAA record by address class (ULA or GUA) to avoid cross-overwrite
local addr_class
addr_class=$(classify_ipv6 "$address")
existing_id=$(jq -r --arg cls "$addr_class" '
def classify:
ascii_downcase |
if startswith("fe80") then "ll"
elif test("^f[cd]") then "ula"
elif test("^[23]") then "gua"
else "other" end;
.[] | select(
.type == "AAAA" and
((.address // "") | classify) == $cls
) | .".id"' <<< "$existing" | head -1)
fi
# Build the JSON payload with jq so values are properly escaped
local body
body=$(jq -n \
--arg name "$name" \
--arg address "$address" \
--arg type "$type" \
--argjson ttl "$TTL" \
'{name: $name, address: $address, type: $type, ttl: ($ttl | tostring)}')
local label="$type$( [[ $type == AAAA ]] && echo " ($(classify_ipv6 "$address"))" || true )"
if [[ -n "$existing_id" ]]; then
info "Updating $label record: $name -> $address (id=$existing_id)"
$DRY_RUN || api_call PATCH "/ip/dns/static/${existing_id}" "$body" >/dev/null
else
info "Creating $label record: $name -> $address"
$DRY_RUN || api_call PUT "/ip/dns/static" "$body" >/dev/null
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
$DRY_RUN && warn "DRY RUN - no changes will be made."
local dns_name
dns_name=$(resolve_dns_name)
[[ -z "$dns_name" ]] && die "Could not determine DNS name. Use --name or --domain."
info "DNS name: $dns_name"
# --- IPv4 ---
if $REGISTER_IPV4; then
local ipv4
ipv4=$(get_ipv4)
if [[ -n "$ipv4" ]]; then
info "Detected IPv4: $ipv4"
upsert_record "$dns_name" "$ipv4" "A"
else
warn "No IPv4 address found - skipping A record."
fi
fi
# --- IPv6 ---
if $REGISTER_IPV6; then
local candidates ula_addr gua_addr
candidates=$(get_ipv6_candidates)
ula_addr=$(first_of_class "ula" <<< "$candidates")
gua_addr=$(first_of_class "gua" <<< "$candidates")
dbg "IPv6 candidates: $(tr '\n' ' ' <<< "$candidates")"
dbg "ULA: ${ula_addr:-(none)} GUA: ${gua_addr:-(none)}"
# Small helper to register or warn for a single address
try_register_aaaa() {
local addr="$1" label="$2"
if [[ -n "$addr" ]]; then
info "Detected $label: $addr"
upsert_record "$dns_name" "$addr" "AAAA"
else
warn "No $label address found - skipping AAAA record."
fi
}
case "$IPV6_MODE" in
ula) try_register_aaaa "$ula_addr" "ULA" ;;
gua) try_register_aaaa "$gua_addr" "GUA" ;;
both) try_register_aaaa "$ula_addr" "ULA"
try_register_aaaa "$gua_addr" "GUA" ;;
prefer-ula) if [[ -n "$ula_addr" ]]; then
try_register_aaaa "$ula_addr" "ULA"
else
try_register_aaaa "$gua_addr" "GUA (ULA not found)"
fi ;;
esac
fi
info "Done."
}
main
# =============================================================================
# DEPLOYMENT NOTES
# =============================================================================
#
# 1. MIKROTIK SETUP
# -----------------
# Create a dedicated API user with minimal permissions:
#
# /user/group/add name=dns-updater policy=read,write,api
# /user/add name=dns-updater group=dns-updater password=<password>
#
# Verify the REST API is reachable:
# curl -u dns-updater:<password> http://192.168.88.1/rest/ip/dns/static
#
#
# 2. INSTALL
# ----------
# sudo install -m 755 mikrotik-dns-register.sh /usr/local/bin/
#
# sudo tee /etc/mikrotik-dns-register.conf <<'EOF'
# MIKROTIK_HOST="192.168.88.1"
# MIKROTIK_USER="dns-updater"
# MIKROTIK_PASS="your-password"
# DNS_DOMAIN="lab.example.com"
# IPV6_MODE="prefer-ula"
# EOF
# sudo chmod 600 /etc/mikrotik-dns-register.conf
#
# NOTE: if your MikroTik host is an IPv6 address, then wrap it in square
# brackets, eg: [ fe80::1 ]
#
#
# 3. SYSTEMD TIMER (runs 30s after boot, then every 5 minutes)
# -------------------------------------------------------------
# /etc/systemd/system/mikrotik-dns-register.service:
#
# [Unit]
# Description=Register host DNS records on MikroTik
# After=network-online.target
# Wants=network-online.target
#
# [Service]
# Type=oneshot
# ExecStart=/usr/local/bin/mikrotik-dns-register.sh
# ProtectSystem=strict
# PrivateTmp=true
# NoNewPrivileges=true
#
# /etc/systemd/system/mikrotik-dns-register.timer:
#
# [Unit]
# Description=Periodically register host DNS on MikroTik
#
# [Timer]
# OnBootSec=30s
# OnUnitActiveSec=5min
# AccuracySec=10s
# Persistent=true
#
# [Install]
# WantedBy=timers.target
#
# sudo systemctl daemon-reload
# sudo systemctl enable --now mikrotik-dns-register.timer
#
#
# 4. OPTIONAL: TRIGGER IMMEDIATELY ON NETWORK CHANGE
# ---------------------------------------------------
# networkd-dispatcher (Debian/Ubuntu: apt install networkd-dispatcher):
#
# sudo ln -s /usr/local/bin/mikrotik-dns-register.sh \
# /etc/networkd-dispatcher/routable.d/50-mikrotik-dns
#
# NetworkManager - /etc/NetworkManager/dispatcher.d/50-mikrotik-dns:
#
# #!/bin/bash
# [[ "$2" == "up" || "$2" == "dhcp4-change" || "$2" == "dhcp6-change" ]] \
# && /usr/local/bin/mikrotik-dns-register.sh
# sudo chmod +x /etc/NetworkManager/dispatcher.d/50-mikrotik-dns
#
#
# 5. CONTAINERS
# -------------
# Set hostname in docker run or compose:
# hostname: myservice.lab.example.com
#
# Or call from cron inside the container:
# */5 * * * * /usr/local/bin/mikrotik-dns-register.sh 2>&1 | logger -t mikrotik-dns
#
# =============================================================================