Vyos Script to Update DDNS Peers

Vyos Script to Update DDNS Peers

December 15, 2025

Vyos seems like a great project but it’s documentation gives you an odd feeling as if you are looking at Lorem Ipsum stances. According to T4930 it’s supposed to support dynamic dns for wireguard peers but sadly it still doesn’t. So here we are, this script goes through a given list of peers and updates the config. Tested on 2025-11 Stream of Vyos.

Revised Dec 16, 2025: Forgot to add the necessary exit at the end of the script. Without the exit statement, vyos keeps creating a new mountpoint on each script-template sourcing until you get the following warning:

fusermount: too many FUSE filesystems mounted; mount_max=N can be set in /etc/fuse.conf

#!/bin/vbash

# Script to dynamically update WireGuard peer IP addresses based on DNS resolution.
# Designed to run as a scheduled task (cron job) on a VyOS router.
# https://echoesofthings.com

# --- Installation ---
# save the script as /config/script/wireguard-ddns-update.sh
# chmod 0755 /config/script/wireguard-ddns-update.sh
# then setup the scheduler under config
# set system task-scheduler task wireguard-ddns-update crontab-spec "*/1 * * * *"
# set system task-scheduler task wireguard-ddns-update executable path /config/scripts/wireguard-ddns-update.sh
# commit
# save
# to check if it's scheduled, use
# show system task-scheduler

# --- Configuration Variables ---
# Set to 'true' to enable logs for successful IP resolution and no-change events.
# Set to 'false' to only log IP changes and errors (recommended for cron jobs).
VERBOSE_LOGGING="false"

# Define all peers in an array of triplets: "WG_INTERFACE PEER_HOSTNAME WG_PEER_NAME"
# Example:
# - Peer 1: on wg01, resolves office-a-ddns.otherdomain.net, peer name is office-peer-a
# - Peer 2: on wg02, resolves office-b-ddns.otherdomain.net, peer name is office-peer-b
# - add more triplets as needed
declare -a PEER_LIST=(
    "wg01 office-a-ddns.otherdomain.net office-peer-a"
    "wg01 office-b-ddns.otherdomain.net office-peer-b"
)

# Ensure the script is run under the correct group for configuration commands
if [ "$(id -g -n)" != 'vyattacfg' ] ; then
    exec sg vyattacfg -c "/bin/vbash $(readlink -f $0) $@"
fi
source /opt/vyatta/etc/functions/script-template

# Function to log messages using the system 'logger' utility
log_message() {
    local message="$1"
    local level="$2"
    local log_text="[WireGuard-DDNS] ${message}"

    if [ "${VERBOSE_LOGGING}" = "true" ] || [ "${level}" = "CRITICAL" ]; then
        # Use logger utility to write to system logs
        logger -t "wireguard-ddns" "${log_text}"
    fi
}

# Use a flag to track if any configuration changes were made, to avoid unnecessary 'save'
CONFIG_CHANGED="false"

# Start the main loop to process each peer entry
for peer_entry in "${PEER_LIST[@]}"; do

    # Split the array entry into three separate variables
    WG_INTERFACE=$(echo "$peer_entry" | awk '{print $1}')
    PEER_HOSTNAME=$(echo "$peer_entry" | awk '{print $2}')
    WG_PEER_NAME=$(echo "$peer_entry" | awk '{print $3}')

    log_message "--- Starting check for peer: ${WG_PEER_NAME} on ${WG_INTERFACE} (${PEER_HOSTNAME}) ---"

    # 1. Resolve the hostname to the new IP address
    NEW_IP=$(getent hosts "${PEER_HOSTNAME}" | awk '{ print $1 }')

    # 2. Check if IP resolution was successful and valid
    if [[ -z "${NEW_IP}" ]] || ! [[ "${NEW_IP}" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
        log_message "ERROR: Failed to resolve hostname ${PEER_HOSTNAME} or received invalid IP: ${NEW_IP}" "CRITICAL"
        continue # Skip to the next peer in the list
    fi

    # 3. Get the currently configured IP address
    # Primary method: parse the configuration command line.
    OLD_CONFIG_LINE=$(run show configuration commands | egrep "interfaces wireguard ${WG_INTERFACE} peer ${WG_PEER_NAME} address\b")

    if [ -n "${OLD_CONFIG_LINE}" ]; then
        OLD_IP=$(echo "${OLD_CONFIG_LINE}" | awk '{ print $8 }' | tr -d '\'\" | xargs)
    else
        log_message "WARNING: Could not find old IP in configuration commands. Checking 'show interfaces' operational state." "CRITICAL"
        # Fallback: Parse the operational status. Get the 2nd field, strip port, quotes, and whitespace.
        OLD_IP=$(run show interfaces wireguard "${WG_INTERFACE}" endpoints | awk '{ print $2 }' | awk -F ':' '{ print $1 }' | tr -d '\'\" | xargs)
    fi

    log_message "Resolved IP: ${NEW_IP}"
    log_message "Configured IP: ${OLD_IP}"

    # 4. Compare the new IP with the old IP
    if [ "${NEW_IP}" != "${OLD_IP}" ]
    then
        log_message "IP address change detected for ${WG_PEER_NAME} on ${WG_INTERFACE}: ${OLD_IP} -> ${NEW_IP}. Marking for commit..." "CRITICAL"

        # Set the address change command
        configure
        # Check if the interface/peer path exists before applying
        if run show configuration commands | grep "interfaces wireguard ${WG_INTERFACE} peer ${WG_PEER_NAME}" &> /dev/null; then
            set interfaces wireguard "${WG_INTERFACE}" peer "${WG_PEER_NAME}" address "${NEW_IP}"
            # This is a key change: we check for commit success inside the loop
            # and set the flag to save the changes *after* the loop completes.
            if commit; then
                log_message "Configuration committed successfully for ${WG_PEER_NAME}." "CRITICAL"
                CONFIG_CHANGED="true" # Mark that we need to save the config
            else
                log_message "ERROR: Commit failed for ${WG_PEER_NAME}. Rolling back..." "CRITICAL"
                rollback
            fi
        else
            log_message "ERROR: Interface ${WG_INTERFACE} or peer ${WG_PEER_NAME} not found in configuration." "CRITICAL"
        fi
    else
        log_message "IP address for ${WG_PEER_NAME} is unchanged: ${NEW_IP}. No action required."
    fi

done

# FINAL STEP: Save the configuration once, only if changes were successfully committed
if [ "${CONFIG_CHANGED}" = "true" ]; then
    log_message "All necessary configuration changes committed. Saving running configuration." "CRITICAL"
    save
fi

# the exit is necessary, every time we source the script-template it creates a
# FUSE mount point
exit
Last updated on