A MikroTik DNS updater

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
#
# =============================================================================