Files
sduesterhaupt 4e375b130c feat(dyndns): add optional rndc journal synchronization
Add configurable post-update journal synchronization via rndc sync -clean.

Separate RFC 2136 update settings from local RNDC control settings.
Run synchronization only after a successful nsupdate request and only when
DYNDNS_RNDC_SYNC_CLEAN is enabled.

Treat RNDC synchronization failures as warnings because the DNS update has
already been accepted and named retains the authoritative state in its journal.
2026-09-05 13:55:34 +02:00

1642 lines
52 KiB
Bash

#!/bin/sh
###############################################################################
# dyndns.sh
# Dynamic DNS backend management script
# This script manages host entries for a personal dynamic DNS service. It
# supports database-backed hostname registration, validation and removal, and
# provides helper functions for DNS zone update workflows.
# Features:
# - Add, check and remove dynDNS hostnames
# - Load configuration from a vars file
# - Maintain log and temporary runtime directories
# - Validate database connectivity before command execution
# - POSIX-compatible operation for cron and service integration
# Authors: Stephan Düsterhaupt, Ivo Noack aka Insonic
# Copyright (c) 2018-2026 CB-601 - the open tec Elevator
# License: MIT
#
# Project Home: https://dev.town-square.de/cb601/dyndns
#
###############################################################################
# MIT License
# Copyright (c) 2026 CB-601 - the open tec Elevator
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# SDuesterhaupt: 2026-06-18 - Show general usage information and an overview
# of available commands.
_usage() {
_log "Enter the function '_usage()'..." 1
# Collect/show dir status:
err_source="Not defined: vars autodetect failed and no value provided"
work_dir=${DYNDNS:-$err_source}
zone_name=${DYNDNS_BIND_ZONE:-$err_source}
printf '%s\n' \
"dynDNS usage and overview" \
"" \
"USAGE: dyndns [options] COMMAND [command-options]" \
"" \
"A list of commands is shown below. To get detailed usage and help for a command, run:" \
" dyndns help COMMAND" \
"" \
"For a listing of options that can be supplied before the command, use:" \
" dyndns help options" \
"" \
"Commands:" \
" add-hostname [ cmd-opts ]" \
" check-hostname [ cmd-opts ]" \
" remove-hostname [ cmd-opts ]" \
" update-zone [ cmd-opts ]" \
"" \
"DIRECTORY STATUS (commands would take effect on these locations)" \
" DYNDNS: $work_dir" \
" ZONE: $zone_name" \
""
_log "Leave the function '_usage()'..." 1
return 0
} #=> _usage()
# SDuesterhaupt: 2026-06-18 - Show command-specific help text or global option help.
# When called without arguments, show general usage or command-specific help.
_cmd_help() {
text=""
opts=""
case "$1" in
add-hostname)
text="add-hostname [ cmd-opts]\n Add a hostname to a DNS zone."
;;
check-hostname)
text="check-hostname [ cmd-opts]\n Check a hostname of a DNS zone."
;;
remove-hostname)
text="remove-hostname [ cmd-opts]\n Remove a hostname from a DNS zone."
;;
update-zone)
text="update-zone [ cmd-opts]\n Update a DNS zone."
;;
options)
_opt_usage
return 0
;;
"")
_usage
return 0
;;
*)
text="Unknown command: '$1' (try without command for a list of commands)"
;;
esac
# Display the help text
[ -n "$text" ] && printf '%b\n' "$text"
if [ -n "$opts" ]; then
printf '\n%s\n' "cmd-opts is an optional set of command options from this list:"
printf '%s\n' "$opts"
fi
return 0
} #=> _cmd_help()
# SDuesterhaupt: 2026-06-18 - Print descriptions of global command-line options.
_opt_usage() {
printf '%s\n' \
"dynDNS Global Option Flags" \
"" \
"The following options may be provided before the command. Options specified" \
"at runtime override env-vars and any 'vars' file in use. Unless noted," \
"non-empty values to options are mandatory." \
"" \
"General options:" \
" --vars=FILE define a specific 'vars' file to use for dynDNS config" \
"" \
"Add hostname options:" \
" --hostname=ARG set a fully qualified hostname" \
" --email=ARG set an e-mail address" \
" --pass=ARG set a password" \
"" \
"Update zone options:" \
" --key=ARG set the TSIG key to grant the nameserver access" \
" --zone=ARG set the DNS zone name (example.com)" \
""
return 0
} #=> _opt_usage()
# SDuesterhaupt: 2026-06-18 - Abort execution with an error message and exit code.
_die() {
msg=$1
code=${2:-1}
printf '\n%s\n%s\n' "dynDNS error:" "$msg" >&2
if [ "$code" -ne 0 ] && [ -n "$DYNDNS_LOG_FILE" ]; then
ts=$(date '+%Y-%m-%d %H:%M:%S')
printf '%s dyndns [ERROR] %s\n' "$ts" "$msg" >>"$DYNDNS_LOG_FILE"
fi
exit "$code"
} #=> _die()
# SDuesterhaupt: 2026-06-18 - Print an informational notice unless batch mode is enabled.
notice() {
if [ -z "$DYNDNS_BATCH" ]; then
printf '\n%s\n' "$1"
fi
return 0
} #=> notice()
# SDuesterhaupt: 2026-06-18 - Create a temporary file in the session-specific temp directory.
# The function expects DYNDNS_TEMP_DIR_session to be initialized by _vars_setup().
# It returns the path of the created file on stdout.
dyndns_mktemp() {
[ -n "$DYNDNS_TEMP_DIR_session" ] \
|| _die "DYNDNS_TEMP_DIR_session not initialized!" 1
[ -d "$DYNDNS_TEMP_DIR_session" ] \
|| mkdir -p "$DYNDNS_TEMP_DIR_session" \
|| _die "Could not create temporary directory '$DYNDNS_TEMP_DIR_session'" 1
template="$DYNDNS_TEMP_DIR_session/tmp.XXXXXX"
tempfile=$(mktemp "$template") || return 1
# Work around mktemp variants that incorrectly return the literal template.
if [ "$template" = "$tempfile" ]; then
tempfile=$(mktemp -du "$tempfile") || return 1
: >"$tempfile" || return 1
fi
printf '%s\n' "$tempfile"
return 0
} #=> dyndns_mktemp()
# SDuesterhaupt: 2026-06-18 - Remove temporary files and restore terminal echo state.
cleanup() {
[ -n "$DYNDNS_TEMP_DIR_session" ] && rm -rf "$DYNDNS_TEMP_DIR_session"
stty echo 2>/dev/null || true
printf '\n'
return 0
} #=> cleanup()
# SDuesterhaupt: 2026-06-18 - Verify essential DYNDNS environment variables are defined
# Checks the presence of critical env vars for dynDNS runtime.
# Exits fatally if any required variable is undefined.
_vars_source_check() {
_log "Enter the function '_vars_source_check()'..." 1
[ -n "$DYNDNS" ] || _die "DYNDNS env-var undefined" 5
[ -n "$DYNDNS_TEMP_DIR" ] || _die "DYNDNS_TEMP_DIR env-var undefined" 5
[ -n "$DYNDNS_LOG_DIR" ] || _die "DYNDNS_LOG_DIR env-var undefined" 5
[ -n "$DYNDNS_LOG_FILE" ] || _die "DYNDNS_LOG_FILE env-var undefined" 5
[ -n "$DYNDNS_LOG_LEVEL" ] || _die "DYNDNS_LOG_LEVEL env-var undefined" 5
[ -n "$DYNDNS_DB_PROGRAM" ] || _die "DYNDNS_DB_PROGRAM env-var undefined" 5
[ -n "$DYNDNS_SQL_HOST" ] || _die "DYNDNS_SQL_HOST env-var undefined" 5
[ -n "$DYNDNS_SQL_DATABASE" ] || _die "DYNDNS_SQL_DATABASE env-var undefined" 5
[ -n "$DYNDNS_SQL_USER" ] || _die "DYNDNS_SQL_USER env-var undefined" 5
[ -n "$DYNDNS_SQL_PASS" ] || _die "DYNDNS_SQL_PASS env-var undefined" 5
[ -n "$DYNDNS_BIND_ZONE" ] || _die "DYNDNS_BIND_ZONE env-var undefined" 5
[ -n "$DYNDNS_BIND_ZONE_KEY" ] || _die "DYNDNS_BIND_ZONE_KEY env-var undefined" 5
[ -n "$DYNDNS_BIND_ZONE_DIR" ] || _die "DYNDNS_BIND_ZONE_DIR env-var undefined" 5
[ -n "$DYNDNS_BIND_ZONE_FILE" ] || _die "DYNDNS_BIND_ZONE_FILE env-var undefined" 5
case "$DYNDNS_RNDC_SYNC_CLEAN" in
0|1)
;;
*)
_die "DYNDNS_RNDC_SYNC_CLEAN must be either 0 or 1" 5
;;
esac
if [ "$DYNDNS_RNDC_SYNC_CLEAN" = "1" ]; then
[ -n "$DYNDNS_RNDC_SERVER" ] || _die "DYNDNS_RNDC_SERVER env-var undefined" 5
[ -n "$DYNDNS_RNDC_KEY" ] || _die "DYNDNS_RNDC_KEY env-var undefined" 5
fi
_log "Leave the function '_vars_source_check()'..." 1
return 0
} #=> _vars_source_check()
# SDuesterhaupt: 2026-06-18 - Verify dynDNS runtime directories, files and external tools.
# Checks existence of required directories/files and command availability.
# Calls _vars_source_check internally.
_verify_runtime_init() {
_log "Enter the function '_verify_runtime_init()'..." 1
help_note="Run dyndns without commands for usage and command help."
_vars_source_check
[ -d "$DYNDNS_LOG_DIR" ] || _die "DYNDNS_LOG_DIR missing. $help_note" 5
[ -d "$DYNDNS_BIND_ZONE_DIR" ] || _die "DYNDNS_BIND_ZONE_DIR missing. $help_note" 5
#[ -f "$DYNDNS_BIND_ZONE_FILE" ] || _die "DYNDNS_BIND_ZONE_FILE missing. $help_note" 5
[ -x "$DYNDNS_DB_PROGRAM" ] \
|| command -v "$DYNDNS_DB_PROGRAM" >/dev/null 2>&1 \
|| _die "Database client '$DYNDNS_DB_PROGRAM' not available. $help_note" 5
command -v sed >/dev/null 2>&1 || _die "sed not available. $help_note" 5
command -v grep >/dev/null 2>&1 || _die "grep not available. $help_note" 5
command -v awk >/dev/null 2>&1 || _die "awk not available. $help_note" 5
command -v nsupdate >/dev/null 2>&1 || _die "nsupdate not available. $help_note" 5
if [ "$DYNDNS_RNDC_SYNC_CLEAN" = "1" ]; then
command -v rndc >/dev/null 2>&1 || _die "rndc not available. $help_note" 5
fi
_log "Leave the function '_verify_runtime_init()'..." 1
return 0
} #=> _verify_runtime_init()
# SDuesterhaupt: 2026-06-18 - Ensure the log directory exists before logging starts.
_init_log() {
if [ ! -d "$DYNDNS_LOG_DIR" ]; then
printf "The log directory '%s' doesn't exist. Try to create it...\n" \
"$DYNDNS_LOG_DIR"
if mkdir -p "$DYNDNS_LOG_DIR" 2>/dev/null; then
printf "Log directory '%s' created.\n" "$DYNDNS_LOG_DIR"
else
_die "Could not create log directory '$DYNDNS_LOG_DIR'" 1
fi
fi
return 0
} #=> _init_log()
# SDuesterhaupt: 2026-06-18 - Initialize database connection and verify access
_init_DBconnection() {
_log "Enter the function '_init_DBconnection()'..." 1
[ -n "$DYNDNS_SQL_HOST" ] || _die "DYNDNS_SQL_HOST is not set." 5
[ -n "$DYNDNS_SQL_DATABASE" ] || _die "DYNDNS_SQL_DATABASE is not set." 5
[ -n "$DYNDNS_SQL_USER" ] || _die "DYNDNS_SQL_USER is not set." 5
[ -n "$DYNDNS_SQL_PASS" ] || _die "DYNDNS_SQL_PASS is not set." 5
if _db_exec 'SELECT 1;' >/dev/null 2>&1; then
_log "The database '$DYNDNS_SQL_DATABASE' is accessible." 2
_log "Leave the function '_init_DBconnection()'..." 1
return 0
fi
printf '%s\n' "TRACE: _db_exec failed in _init_DBconnection" >&2
_die "The database '$DYNDNS_SQL_DATABASE' is not accessible." 5
} #=> _init_DBconnection()
# SDuesterhaupt: 2026-06-18 - Load configuration from a vars file and apply built-in defaults.
#
# Search order:
# 1. File specified by DYNDNS_VARS_FILE
# 2. File named 'vars' below the configured DYNDNS directory
# 3. File named 'vars' next to the script itself
#
# Existing environment variables take precedence over built-in defaults.
# The function also initializes a per-run session temp directory.
_vars_setup() {
# Try to locate a 'vars' file in order of location preference.
# If one is found, source it
prog_file=$0
prog_dir=$(dirname "$(readlink -f "$prog_file" 2>/dev/null || printf '%s' "$prog_file")")
vars=""
[ -f "$DYNDNS_VARS_FILE" ] && vars=$DYNDNS_VARS_FILE
[ -z "$vars" ] && [ -n "$DYNDNS" ] && [ -f "$DYNDNS/vars" ] && vars=$DYNDNS/vars
[ -z "$vars" ] && [ -f "$prog_dir/vars" ] && vars=$prog_dir/vars
if [ -z "$DYNDNS_NO_VARS" ] && [ -n "$vars" ]; then
DYNDNS_CALLER=1
export DYNDNS_CALLER
# shellcheck source=/dev/null
. "$vars"
notice "Note: using dynDNS configuration from: $vars"
fi
# Core paths
set_var DYNDNS "$prog_dir"
set_var DYNDNS_TEMP_DIR "$DYNDNS/tmp"
# Logging
set_var DYNDNS_LOG_DIR "$DYNDNS/log"
set_var DYNDNS_LOG_FILE "$DYNDNS_LOG_DIR/dyndns_${DYNDNS_BIND_ZONE:-default}.log"
set_var DYNDNS_LOG_UPDATE_FILE "$DYNDNS_LOG_DIR/lastZoneUpdate_${DYNDNS_BIND_ZONE:-default}.log"
set_var DYNDNS_LOG_LEVEL 3
set_var DYNDNS_BATCH ""
# Database access
set_var DYNDNS_SQL_HOST "localhost"
set_var DYNDNS_SQL_GROUP "dyndns"
set_var DYNDNS_SQL_DATABASE "dyndns_database"
set_var DYNDNS_SQL_USER "dyndns_user"
set_var DYNDNS_SQL_PASS "strong_passphrase"
set_var DYNDNS_SQL_OPTIONS ""
set_var DYNDNS_DB_PROGRAM "/usr/bin/mariadb"
# DNS/BIND defaults
set_var DYNDNS_BIND_SERVER "127.0.0.1"
set_var DYNDNS_BIND_SERVICE "named.service"
set_var DYNDNS_BIND_ZONE "example24.com"
set_var DYNDNS_BIND_ZONE_KEY "keyfile"
set_var DYNDNS_BIND_ZONE_DIR "/var/named/zones"
set_var DYNDNS_BIND_ZONE_FILE "$DYNDNS_BIND_ZONE_DIR/$DYNDNS_BIND_ZONE.zone"
set_var DYNDNS_BIND_ZONE_FILE_SIGNED "$DYNDNS_BIND_ZONE_DIR/$DYNDNS_BIND_ZONE.zone.signed"
set_var DYNDNS_BIND_ZONE_TTL 300
# RNDC post-update zone synchronization defaults
#
# The RNDC control interface is separate from the authoritative DNS
# listener used by nsupdate. Keep the default disabled so existing
# installations do not gain a new administrative post-update action
# unless it is explicitly enabled in their vars file.
set_var DYNDNS_RNDC_SERVER "127.0.0.1"
set_var DYNDNS_RNDC_KEY ""
set_var DYNDNS_RNDC_SYNC_CLEAN 0
# Create a per-run temp directory name. The directory itself may be created
# lazily later by dyndns_mktemp().
if [ -z "$DYNDNS_TEMP_DIR_session" ]; then
if [ -d "$DYNDNS_TEMP_DIR" ]; then
DYNDNS_TEMP_DIR_session=$(mktemp -du "$DYNDNS_TEMP_DIR/dyn-dns-$$.XXXXXX")
else
mkdir -p "$DYNDNS_TEMP_DIR" \
|| _die "Cannot create $DYNDNS_TEMP_DIR (permission?)" 1
DYNDNS_TEMP_DIR_session=$(mktemp -du "$DYNDNS_TEMP_DIR/dyn-dns-$$.XXXXXX")
rm -rf "$DYNDNS_TEMP_DIR"
fi
export DYNDNS_TEMP_DIR_session
fi
return 0
} #=> _vars_setup()
# SDuesterhaupt: 2026-06-18 - Set an environment variable to a default value if it
# is currently unset or empty.
set_var() {
var=$1
shift
eval "$var=\${$var:-\"\$*\"}"
eval "export $var"
} #=> set_var()
# SDuesterhaupt: 2026-06-18 - Write a message to the dynDNS log file.
#
# Severity levels:
# 0 -> OFF
# 1 -> DEBUG
# 2 -> INFO
# 3 -> WARNING
# 4 -> ERROR
# 5 -> CRITICAL
#
# @param1: Log message.
# @param2: Severity level.
_log() {
msg=$1
level=${2:-0}
set -- "" "DEBUG" "INFO" "WARNING" "ERROR" "CRITICAL"
shift
level_name=""
if [ "$level" -ge 1 ] 2>/dev/null; then
level_name=$(eval "printf '%s' \"\${$level}\"")
fi
level_tag="[$level_name]"
level_tag_len=${#level_tag}
pad_width=10
pad_count=$((pad_width - level_tag_len))
[ "$pad_count" -lt 1 ] && pad_count=1
pad=$(printf '%*s' "$pad_count" '')
# Write the log entry only if the message is not empty
if [ -n "$msg" ] && [ "$level" -ge "$DYNDNS_LOG_LEVEL" ] 2>/dev/null; then
ts=$(date '+%Y-%m-%d %H:%M:%S')
printf '%s dyndns %s%s%s\n' \
"$ts" "$level_tag" "$pad" "$msg" >>"$DYNDNS_LOG_FILE"
fi
if [ "$level" -ge 3 ] 2>/dev/null; then
printf '%s\n' "$msg" >&2
fi
return 0
} #=> _log()
# SDuesterhaupt: 2026-06-18 - Check whether DYNDNS_MEMBER_HOSTNAME is a valid hostname
#
# Returns:
# 0 if valid
# 1 if invalid
_hostname_is_valid() {
_log "Enter the function '_hostname_is_valid()'..." 1
hostname=$DYNDNS_MEMBER_HOSTNAME
case "$hostname" in
''|.*|*..*|*.-*|*-.|*-|*.)
_log "The expression '$hostname' is not a valid hostname." 3
_log "Leave the function '_hostname_is_valid()'..." 1
return 1
;;
esac
if printf '%s\n' "$hostname" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,}$'; then
_log "The expression '$hostname' is a valid hostname." 2
_log "Leave the function '_hostname_is_valid()'..." 1
return 0
fi
_log "The expression '$hostname' is not a valid hostname." 3
_log "Leave the function '_hostname_is_valid()'..." 1
return 1
} #=> _hostname_is_valid()
# SDuesterhaupt: 2026-06-18 - Extract base domain from DYNDNS_MEMBER_HOSTNAME
# Prints:
# example.org
#
# Returns:
# 0 on success
# 1 on failure
_hostname_get_domain() {
_log "Enter the function '_hostname_get_domain()'..." 1
hostname=$DYNDNS_MEMBER_HOSTNAME
if ! _hostname_is_valid >/dev/null 2>&1; then
_log "Cannot extract domain from invalid hostname '$hostname'." 4
_log "Leave the function '_hostname_get_domain()'..." 1
return 1
fi
domain=$(printf '%s\n' "$hostname" | sed 's/^.*\.\([^.][^.]*\.[^.][^.]*\)$/\1/')
if [ -n "$domain" ] && [ "$domain" != "$hostname" ]; then
printf '%s\n' "$domain"
_log "Leave the function '_hostname_get_domain()'..." 1
return 0
fi
_log "Could not extract domain from '$hostname'." 4
_log "Leave the function '_hostname_get_domain()'..." 1
return 1
} #=> _hostname_get_domain()
# SDuesterhaupt: 2026-06-18 - Extract subdomain from DYNDNS_MEMBER_HOSTNAME
#
# Prints:
# host or sub.host
#
# Returns:
# 0 on success
# 1 on failure
_hostname_get_subdomain() {
_log "Enter the function '_hostname_get_subdomain()'..." 1
hostname=$DYNDNS_MEMBER_HOSTNAME
if ! _hostname_is_valid >/dev/null 2>&1; then
_log "Cannot extract subdomain from invalid hostname '$hostname'." 4
_log "Leave the function '_hostname_get_subdomain()'..." 1
return 1
fi
subdomain=$(printf '%s\n' "$hostname" | sed 's/\.[^.][^.]*\.[^.][^.]*$//')
if [ -n "$subdomain" ] && [ "$subdomain" != "$hostname" ]; then
printf '%s\n' "$subdomain"
_log "Leave the function '_hostname_get_subdomain()'..." 1
return 0
fi
_log "Could not extract subdomain from '$hostname'." 4
_log "Leave the function '_hostname_get_subdomain()'..." 1
return 1
} #=> _hostname_get_subdomain()
# SDuesterhaupt: 2026-06-18 - Compatibility wrapper around hostname helper functions
#
# @param1: check | get
# @param2: hostname | domain | subdomain
#
# Returns:
# 0 on success
# 1 on failure
_Hostname() {
_log "Enter the function '_Hostname()'..." 1
method=$1
part=$2
case "$method" in
check)
case "$part" in
hostname|fqdn)
_hostname_is_valid
return $?
;;
*)
_die "Invalid action '$part' in function _Hostname()." 5
;;
esac
;;
get)
case "$part" in
domain)
_hostname_get_domain
return $?
;;
subdomain)
_hostname_get_subdomain
return $?
;;
*)
_die "Invalid action '$part' in function _Hostname()." 5
;;
esac
;;
*)
_die "Invalid method '$method' in function _Hostname()." 5
;;
esac
} #=> _Hostname()
# SDuesterhaupt: 2026-06-18 - Build the mysql command line
#
# Prints a shell-escaped mysql command line to stdout.
# Authentication is taken either from a mysql option group suffix
# or from explicit user/password settings.
_db_cmd() {
if [ -n "$DYNDNS_SQL_GROUP" ]; then
printf '%s\n' \
"\"$DYNDNS_DB_PROGRAM\" --defaults-group-suffix=$DYNDNS_SQL_GROUP $DYNDNS_SQL_OPTIONS -h \"$DYNDNS_SQL_HOST\" -D \"$DYNDNS_SQL_DATABASE\" -Ns"
else
printf '%s\n' \
"\"$DYNDNS_DB_PROGRAM\" $DYNDNS_SQL_OPTIONS -h \"$DYNDNS_SQL_HOST\" -u \"$DYNDNS_SQL_USER\" -p\"$DYNDNS_SQL_PASS\" -D \"$DYNDNS_SQL_DATABASE\" -Ns"
fi
return 0
} #=> _db_cmd()
# SDuesterhaupt: 2026-06-18 - Execute an SQL statement
#
# @param1: SQL statement
#
# Writes query result to stdout and returns mysql exit code.
_db_exec() {
sql=$1
if [ -z "$sql" ]; then
_log "Empty SQL statement passed to _db_exec()." 4
return 1
fi
db_cmd=$(_db_cmd) || return 1
eval "$db_cmd -e \"\$sql\""
return $?
} #=> _db_exec()
# SDuesterhaupt: 2026-06-18 - Execute a scalar SQL query
#
# @param1: SQL query
#
# Prints query result to stdout.
# Returns 0 on success, 1 on failure.
_db_query_scalar() {
sql=$1
if [ -z "$sql" ]; then
_log "Empty SQL statement passed to _db_query_scalar()." 4
return 1
fi
_db_exec "$sql" | awk 'NR == 1 { print; exit }'
return $?
} #=> _db_query_scalar()
# SDuesterhaupt: 2026-06-18 - Escape string for safe SQL single-quoted usage
#
# @param1: Raw string
#
# Prints escaped string to stdout.
_sql_escape() {
printf '%s' "$1" | sed "s/'/''/g"
} #=> _sql_escape()
# SDuesterhaupt: 2026-06-20 - Check whether hostname child tables are protected by
# foreign keys with ON DELETE CASCADE.
#
# Required relationships:
# connections.id -> members.id
# login_attempts.id -> members.id
#
# Returns:
# 0 if both foreign keys exist with DELETE_RULE = 'CASCADE'
# 1 otherwise
_hostname_db_has_delete_cascade() {
result=$(_db_query_scalar "
SELECT COUNT(*)
FROM (
SELECT
kcu.TABLE_NAME,
kcu.COLUMN_NAME,
kcu.REFERENCED_TABLE_NAME,
kcu.REFERENCED_COLUMN_NAME,
rc.DELETE_RULE
FROM information_schema.KEY_COLUMN_USAGE kcu
JOIN information_schema.REFERENTIAL_CONSTRAINTS rc
ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
WHERE kcu.CONSTRAINT_SCHEMA = '$(_sql_escape "$DYNDNS_SQL_DATABASE")'
AND (
(kcu.TABLE_NAME = 'connections'
AND kcu.COLUMN_NAME = 'id'
AND kcu.REFERENCED_TABLE_NAME = 'members'
AND kcu.REFERENCED_COLUMN_NAME = 'id')
OR
(kcu.TABLE_NAME = 'login_attempts'
AND kcu.COLUMN_NAME = 'id'
AND kcu.REFERENCED_TABLE_NAME = 'members'
AND kcu.REFERENCED_COLUMN_NAME = 'id')
)
AND rc.DELETE_RULE = 'CASCADE'
) AS fk_check;
" 2>/dev/null)
[ "$result" = "2" ]
} #=> _hostname_db_has_delete_cascade()
# SDuesterhaupt: 2026-06-18 - Generate SHA-512 hex digest
#
# @param1: Raw string
#
# Prints digest to stdout.
_sha512_hex() {
if command -v sha512sum >/dev/null 2>&1; then
printf '%s' "$1" | sha512sum | awk '{print $1}'
else
printf '%s' "$1" | openssl dgst -sha512 | sed 's/^.*= //'
fi
} #=> _sha512_hex()
# SDuesterhaupt: 2026-06-18 - Manage hostname entries in the database
#
# @param1: Method: add | check | remove
#
# Uses:
# DYNDNS_MEMBER_HOSTNAME
# DYNDNS_MEMBER_EMAIL
# DYNDNS_MEMBER_PASS
#
# Returns:
# 0 on success
# 1 on lookup or operation failure
_HostnameDB() {
_log "Enter the function '_HostnameDB()'..." 1
method=$1
return_flag=1
member_id=""
db_count=0
_log "Method '$method'..." 1
case "$method" in
add)
_log "Add the hostname '$DYNDNS_MEMBER_HOSTNAME' to the database..." 2
# Get username = subdomain
username=$(_Hostname get subdomain) || {
_log "Could not extract subdomain from '$DYNDNS_MEMBER_HOSTNAME'." 4
_log "Leave the function '_HostnameDB()'..." 1
return 1
}
# Get domain
domain=$(_Hostname get domain) || {
_log "Could not extract domain from '$DYNDNS_MEMBER_HOSTNAME'." 4
_log "Leave the function '_HostnameDB()'..." 1
return 1
}
# Create a random salt
random_seed=$(openssl rand -hex 16 2>/dev/null) || {
_log "Could not generate random salt seed." 4
_log "Leave the function '_HostnameDB()'..." 1
return 1
}
# Create salted password
random_salt=$(_sha512_hex "$random_seed")
password_hash=$(_sha512_hex "${DYNDNS_MEMBER_PASS}.${random_salt}")
# Get timestamp
timestamp=$(date +%s)
sql_username=$(_sql_escape "$username")
sql_domain=$(_sql_escape "$domain")
sql_email=$(_sql_escape "$DYNDNS_MEMBER_EMAIL")
sql_password_hash=$(_sql_escape "$password_hash")
sql_random_salt=$(_sql_escape "$random_salt")
sql_hostname=$(_sql_escape "$DYNDNS_MEMBER_HOSTNAME")
# Add new entry to database.members
if _db_exec "
INSERT INTO members (username, domain, email, password, salt, timestamp)
VALUES ('$sql_username', '$sql_domain', '$sql_email', '$sql_password_hash', '$sql_random_salt', '$timestamp');
" >/dev/null 2>&1; then
_log "The hostname '$DYNDNS_MEMBER_HOSTNAME' was added to '$DYNDNS_SQL_DATABASE.members'." 2
else
_log "The hostname '$DYNDNS_MEMBER_HOSTNAME' could not be added to '$DYNDNS_SQL_DATABASE.members'." 4
_log "Leave the function '_HostnameDB()'..." 1
return 1
fi
# Get the id from database.members
member_id=$(_db_query_scalar "
SELECT id
FROM members
WHERE CONCAT(members.username, '.', members.domain) = '$sql_hostname'
LIMIT 1;
" 2>/dev/null)
if [ -z "$member_id" ]; then
_log "Could not determine member id for '$DYNDNS_MEMBER_HOSTNAME'." 4
_log "Leave the function '_HostnameDB()'..." 1
return 1
fi
# Add new entry to database.connections
if _db_exec "
INSERT INTO connections (id, IP, IPv6_flag, IP_locked, timestamp)
VALUES ('$member_id', '127.0.0.1', '0', '0', '$timestamp');
" >/dev/null 2>&1; then
_log "The id '$member_id' was added to '$DYNDNS_SQL_DATABASE.connections'." 2
else
_log "The id '$member_id' could not be added to '$DYNDNS_SQL_DATABASE.connections'." 4
_log "Leave the function '_HostnameDB()'..." 1
return 1
fi
# Add new entry to database.login_attempts
if _db_exec "
INSERT INTO login_attempts (id, count, timestamp)
VALUES ('$member_id', '0', '$timestamp');
" >/dev/null 2>&1; then
_log "The id '$member_id' was added to '$DYNDNS_SQL_DATABASE.login_attempts'." 2
return_flag=0
else
_log "The id '$member_id' could not be added to '$DYNDNS_SQL_DATABASE.login_attempts'." 4
return_flag=1
fi
;;
check)
_log "Check whether the hostname '$DYNDNS_MEMBER_HOSTNAME' is stored in the database..." 2
sql_hostname=$(_sql_escape "$DYNDNS_MEMBER_HOSTNAME")
# Count the member IDs in the database where domain="$DYNDNS_MEMBER_HOSTNAME"
db_count=$(_db_query_scalar "
SELECT COUNT(id)
FROM members
WHERE CONCAT(members.username, '.', members.domain) = '$sql_hostname';
" 2>/dev/null)
case "$db_count" in
''|*[!0-9]*)
_log "Could not determine database count for '$DYNDNS_MEMBER_HOSTNAME'." 4
return_flag=1
;;
*)
if [ "$db_count" -ge 1 ]; then
_log "The hostname '$DYNDNS_MEMBER_HOSTNAME' is stored in the database." 2
return_flag=0
else
_log "The hostname '$DYNDNS_MEMBER_HOSTNAME' could not be found in database." 2
return_flag=1
fi
;;
esac
;;
remove)
_log "Remove the hostname '$DYNDNS_MEMBER_HOSTNAME' from the database..." 2
sql_hostname=$(_sql_escape "$DYNDNS_MEMBER_HOSTNAME")
member_id=$(_db_query_scalar "
SELECT id
FROM members
WHERE CONCAT(members.username, '.', members.domain) = '$sql_hostname'
LIMIT 1;
" 2>/dev/null)
if [ -z "$member_id" ]; then
_log "No database id found for hostname '$DYNDNS_MEMBER_HOSTNAME'." 3
_log "Leave the function '_HostnameDB()'..." 1
return 1
fi
if _hostname_db_has_delete_cascade; then
_log "Detected ON DELETE CASCADE for hostname child tables. Using parent delete path." 2
if _db_exec "DELETE FROM members WHERE id = '$member_id';" >/dev/null 2>&1; then
_log "The hostname '$DYNDNS_MEMBER_HOSTNAME' was removed from '$DYNDNS_SQL_DATABASE.members'." 2
return_flag=0
else
_log "The hostname '$DYNDNS_MEMBER_HOSTNAME' could not be removed from '$DYNDNS_SQL_DATABASE.members'." 4
return_flag=1
fi
else
_log "ON DELETE CASCADE not detected. Using compatibility fallback delete path." 3
if _db_exec "DELETE FROM connections WHERE id = '$member_id';" >/dev/null 2>&1; then
_log "The id '$member_id' was removed from '$DYNDNS_SQL_DATABASE.connections'." 2
else
_log "The id '$member_id' could not be removed from '$DYNDNS_SQL_DATABASE.connections'." 4
_log "Leave the function '_HostnameDB()'..." 1
return 1
fi
if _db_exec "DELETE FROM login_attempts WHERE id = '$member_id';" >/dev/null 2>&1; then
_log "The id '$member_id' was removed from '$DYNDNS_SQL_DATABASE.login_attempts'." 2
else
_log "The id '$member_id' could not be removed from '$DYNDNS_SQL_DATABASE.login_attempts'." 4
_log "Leave the function '_HostnameDB()'..." 1
return 1
fi
if _db_exec "DELETE FROM members WHERE id = '$member_id';" >/dev/null 2>&1; then
_log "The hostname '$DYNDNS_MEMBER_HOSTNAME' was removed from '$DYNDNS_SQL_DATABASE.members'." 2
return_flag=0
else
_log "The hostname '$DYNDNS_MEMBER_HOSTNAME' could not be removed from '$DYNDNS_SQL_DATABASE.members'." 4
return_flag=1
fi
fi
;;
*)
_die "Invalid method '$method' in function _HostnameDB()." 5
;;
esac
_log "Leave the function '_HostnameDB()'..." 1
return "$return_flag"
} #=> _HostnameDB()
# SDuesterhaupt: 2026-06-18 - Add a hostname if it is valid and currently unused
#
# Returns:
# 0 on success
# 1 on failure
_add_hostname() {
_log "Enter the function '_add_hostname()'..." 1
# Check whether the hostname is a valid FQDN.
if ! _hostname_is_valid >/dev/null 2>&1; then
_log "The provided hostname '$DYNDNS_MEMBER_HOSTNAME' is not valid. Cannot add it." 3
_log "Leave the function '_add_hostname()'..." 1
return 1
fi
# Check whether DYNDNS_MEMBER_EMAIL is not empty.
[ -n "$DYNDNS_MEMBER_EMAIL" ] || {
_log "The member e-mail address is empty. Cannot add hostname '$DYNDNS_MEMBER_HOSTNAME'." 4
_log "Leave the function '_add_hostname()'..." 1
return 1
}
# Check whether DYNDNS_MEMBER_PASS is not empty.
[ -n "$DYNDNS_MEMBER_PASS" ] || {
_log "The member password is empty. Cannot add hostname '$DYNDNS_MEMBER_HOSTNAME'." 4
_log "Leave the function '_add_hostname()'..." 1
return 1
}
# Check whether the hostname is still free.
if _HostnameDB check; then
_log "Cannot add the hostname '$DYNDNS_MEMBER_HOSTNAME'. The domain already exists." 2
_log "Leave the function '_add_hostname()'..." 1
return 1
fi
# Add hostname to database.
if _HostnameDB add; then
_log "Hostname '$DYNDNS_MEMBER_HOSTNAME' was added successfully." 2
_log "Leave the function '_add_hostname()'..." 1
return 0
fi
_log "Hostname '$DYNDNS_MEMBER_HOSTNAME' could not be added." 4
_log "Leave the function '_add_hostname()'..." 1
return 1
} #=> _add_hostname()
# SDuesterhaupt: 2026-06-18 - Check whether a valid hostname exists in the database
#
# Returns:
# 0 if hostname exists
# 1 if hostname is invalid or absent
_check_hostname() {
_log "Enter the function '_check_hostname()'..." 1
# Check whether the hostname is a valid FQDN.
if ! _hostname_is_valid >/dev/null 2>&1; then
_log "The provided hostname '$DYNDNS_MEMBER_HOSTNAME' is not valid. Cannot check it." 3
_log "Leave the function '_check_hostname()'..." 1
return 1
fi
# Check whether the hostname is stored in the database
if _HostnameDB check; then
_log "Hostname '$DYNDNS_MEMBER_HOSTNAME' exists in the database." 2
_log "Leave the function '_check_hostname()'..." 1
return 0
fi
_log "Hostname '$DYNDNS_MEMBER_HOSTNAME' does not exist in the database." 2
_log "Leave the function '_check_hostname()'..." 1
return 1
} #=> _check_hostname()
# SDuesterhaupt: 2026-06-18 - Remove a hostname if it is valid and exists
#
# Returns:
# 0 on success
# 1 on failure
_remove_hostname() {
_log "Enter the function '_remove_hostname()'..." 1
# Check whether the hostname is a valid FQDN.
if ! _hostname_is_valid >/dev/null 2>&1; then
_log "The provided hostname '$DYNDNS_MEMBER_HOSTNAME' is not valid. Cannot remove it." 3
_log "Leave the function '_remove_hostname()'..." 1
return 1
fi
# Check whether the hostname exists
if ! _HostnameDB check; then
_log "Cannot remove the hostname '$DYNDNS_MEMBER_HOSTNAME'. The domain does not exist." 2
_log "Leave the function '_remove_hostname()'..." 1
return 1
fi
# Remove the hostname from database
if _HostnameDB remove; then
_log "Hostname '$DYNDNS_MEMBER_HOSTNAME' was removed successfully." 2
_log "Leave the function '_remove_hostname()'..." 1
return 0
fi
_log "Hostname '$DYNDNS_MEMBER_HOSTNAME' could not be removed." 4
_log "Leave the function '_remove_hostname()'..." 1
return 1
} #=> _remove_hostname()
# SDuesterhaupt: 2026-06-18 - Get dynamic DNS records from database
#
# Output format:
# subdomain<TAB>hostname<TAB>ip<TAB>record_type
#
# Returns:
# 0 on success
# 1 on failure
_zone_db_records_get() {
_log "Enter the function '_zone_db_records_get()'..." 1
_db_exec "
SELECT
members.username,
CONCAT(members.username, '.', members.domain) AS hostname,
connections.IP,
CASE
WHEN connections.IPv6_flag = 1 THEN 'AAAA'
ELSE 'A'
END AS record_type
FROM connections
JOIN members ON connections.id = members.id
WHERE members.domain = '$DYNDNS_BIND_ZONE'
AND connections.IP_locked = 0;
" 2>/dev/null
rc=$?
if [ "$rc" -ne 0 ]; then
_log "Could not obtain DNS records from database for zone '$DYNDNS_BIND_ZONE'." 4
_log "Leave the function '_zone_db_records_get()'..." 1
return 1
fi
_log "Leave the function '_zone_db_records_get()'..." 1
return 0
} #=> _zone_db_records_get()
# SDuesterhaupt: 2026-06-18 - Extract existing A and AAAA records from zone file
#
# Output format:
# subdomain<TAB>hostname<TAB>ip<TAB>record_type
#
# Returns:
# 0 on success
# 1 on failure
_zone_file_records_get() {
_log "Enter the function '_zone_file_records_get()'..." 1
if [ ! -f "$DYNDNS_BIND_ZONE_FILE" ]; then
_log "The file '$DYNDNS_BIND_ZONE_FILE' is not available." 4
_log "Leave the function '_zone_file_records_get()'..." 1
return 1
fi
awk -v zone="$DYNDNS_BIND_ZONE" '
BEGIN {
OFS = "\t"
origin = ""
}
/^[[:space:]]*$/ { next }
/^[[:space:]]*;/ { next }
/^\$ORIGIN[[:space:]]+/ {
origin = $2
sub(/\.$/, "", origin)
next
}
/^\$TTL[[:space:]]+/ { next }
{
owner = ""
rtype = ""
target = ""
if (NF >= 3 && ($2 == "A" || $2 == "AAAA")) {
owner = $1
rtype = $2
target = $3
} else if (NF >= 4 && $2 == "IN" && ($3 == "A" || $3 == "AAAA")) {
owner = $1
rtype = $3
target = $4
} else if (NF >= 4 && ($3 == "A" || $3 == "AAAA")) {
owner = $1
rtype = $3
target = $4
} else if (NF >= 5 && $3 == "IN" && ($4 == "A" || $4 == "AAAA")) {
owner = $1
rtype = $4
target = $5
} else {
next
}
sub(/\.$/, "", owner)
sub(/\.$/, "", target)
if (owner == "" || owner == "@" || owner == "*" || owner == zone) {
next
}
fqdn = owner
if (index(owner, ".") == 0) {
if (origin != "" && origin != ".") {
fqdn = owner "." origin
} else {
fqdn = owner "." zone
}
}
sub(/\.$/, "", fqdn)
if (fqdn !~ ("\\." zone "$")) {
next
}
subdomain = fqdn
sub("\\." zone "$", "", subdomain)
if (subdomain == "" || subdomain == "*" || subdomain ~ /^\*\./) {
next
}
print subdomain, fqdn, target, rtype
}
' "$DYNDNS_BIND_ZONE_FILE"
rc=$?
if [ "$rc" -ne 0 ]; then
_log "Could not parse zone file '$DYNDNS_BIND_ZONE_FILE'." 4
_log "Leave the function '_zone_file_records_get()'..." 1
return 1
fi
_log "Leave the function '_zone_file_records_get()'..." 1
return 0
} #=> _zone_file_records_get()
# SDuesterhaupt: 2026-06-19 - Compare database records with zone-file records
#
# Input files:
# $1 = db records file (subdomain<TAB>hostname<TAB>ip<TAB>record_type)
# $2 = zone records file (subdomain<TAB>hostname<TAB>ip<TAB>record_type)
#
# +----------+-------------------+-----------+-----------+
# | username | hostname | IP | IPv6_flag |
# +----------+-------------------+-----------+-----------+
# | sub1 | sub1.example24.de | 127.0.0.1 | 0 |
# | sub2 | sub2.example24.de | 127.0.0.2 | 0 |
# | sub3 | sub3.example24.de | ::1 | 1 |
# +----------+-------------------+-----------+-----------+
#
# Output format:
# del<TAB>owner<TAB>type
# add<TAB>owner<TAB>ttl<TAB>type<TAB>target
#
# Returns:
# 0 on success
# 1 on failure
_zone_updates_build() {
_log "Enter the function '_zone_updates_build()'..." 1
db_file=$1
zone_file=$2
if [ ! -f "$db_file" ] || [ ! -f "$zone_file" ]; then
_log "Temporary input files for zone update build are missing." 4
_log "Leave the function '_zone_updates_build()'..." 1
return 1
fi
tab=$(printf '\t')
# 1) Obtain hosts from DB: update or create new
while IFS="$tab" read -r db_sub db_host db_ip db_type; do
[ -n "$db_sub" ] || continue
# passendes RRset im Zonenfile suchen (nach Host + Typ)
zone_match=$(
awk -F "$tab" -v host="$db_host" -v rtype="$db_type" '
$2 == host && $4 == rtype { print $0; exit }
' "$zone_file"
)
if [ -n "$zone_match" ]; then
zone_old_ip=$(printf '%s\n' "$zone_match" | awk -F "$tab" '{print $3}')
_log "The subdomain '$db_sub' already exists in the zone file." 2
if [ "$db_ip" != "$zone_old_ip" ]; then
_log "The IP of the hostname '$db_host' does need to be updated: ZONE '$zone_old_ip' != DB '$db_ip'." 2
# Alte RRsets für diesen Host bereinigen (A, AAAA, CNAME)
printf 'del\t%s\tA\n' "$db_host"
printf 'del\t%s\tAAAA\n' "$db_host"
printf 'del\t*.%s\tCNAME\n' "$db_host"
# Neue Records setzen (A/AAAA + Wildcard-CNAME)
printf 'add\t%s\t%s\t%s\t%s\n' \
"$db_host" "$DYNDNS_BIND_ZONE_TTL" "$db_type" "$db_ip"
printf 'add\t*.%s\t%s\tCNAME\t%s\n' \
"$db_host" "$DYNDNS_BIND_ZONE_TTL" "$db_host"
else
_log "The IP of the hostname '$db_host' does not need to be updated: ZONE '$zone_old_ip' = DB '$db_ip'." 2
fi
else
_log "Try to add the subdomain '$db_sub' to the zone file. ..." 2
# Neuer Host: A/AAAA + Wildcard-CNAME
printf 'add\t%s\t%s\t%s\t%s\n' \
"$db_host" "$DYNDNS_BIND_ZONE_TTL" "$db_type" "$db_ip"
printf 'add\t*.%s\t%s\tCNAME\t%s\n' \
"$db_host" "$DYNDNS_BIND_ZONE_TTL" "$db_host"
fi
done < "$db_file"
# 2) Hosts aus Zone: verwaiste Records löschen (kein DB-Eintrag mehr)
# Wir löschen wieder das komplette RRset (A, AAAA, CNAME) wie im alten Skript.
while IFS="$tab" read -r z_sub z_host z_ip z_type; do
[ -n "$z_sub" ] || continue
db_match=$(
awk -F "$tab" -v host="$z_host" -v rtype="$z_type" '
$2 == host && $4 == rtype { print $0; exit }
' "$db_file"
)
if [ -z "$db_match" ]; then
_log "Try to delete the subdomain '$z_sub' from the zone file. ..." 2
printf 'del\t%s\tA\n' "$z_host"
printf 'del\t%s\tAAAA\n' "$z_host"
printf 'del\t*.%s\tCNAME\n' "$z_host"
fi
done < "$zone_file"
_log "Leave the function '_zone_updates_build()'..." 1
return 0
} #=> _zone_updates_build()
# SDuesterhaupt: 2026-06-19 - Apply dynamic DNS updates using nsupdate
#
# @param1: update file generated by _zone_updates_build
#
# Returns:
# 0 on success
# 1 on failure
_zone_updates_apply() {
_log "Enter the function '_zone_updates_apply()'..." 1
updates_file=$1
nsupdate_file=$(dyndns_mktemp) || {
_log "Could not create temporary nsupdate file." 4
_log "Leave the function '_zone_updates_apply()'..." 1
return 1
}
{
printf 'debug\n'
printf 'server %s\n' "$DYNDNS_BIND_SERVER"
printf 'zone %s.\n' "$DYNDNS_BIND_ZONE"
awk -F '\t' -v zone_file="$DYNDNS_BIND_ZONE_FILE" '
$1 == "del" {
del_owner[++del_count] = $2
del_type[del_count] = $3
next
}
$1 == "add" {
add_owner[++add_count] = $2
add_ttl[add_count] = $3
add_type[add_count] = $4
add_target[add_count] = $5
next
}
END {
now = strftime("%d.%m.%Y %H:%M:%S")
if (del_count > 0 || add_count > 0) {
printf "; Dynamic update of the zone %s - %s\n", zone_file, now
}
if (del_count > 0) {
printf "; Delete the zone records which need an update\n"
for (i = 1; i <= del_count; i++) {
# printf "prereq yxrrset %s %s\n", del_owner[i], del_type[i]
printf "update delete %s %s\n", del_owner[i], del_type[i]
}
printf "; send\n\n"
}
if (add_count > 0) {
printf "; Add the updated zone records\n"
for (i = 1; i <= add_count; i++) {
# printf "prereq nxrrset %s %s\n", add_owner[i], add_type[i]
printf "update add %s %s %s %s\n", add_owner[i], add_ttl[i], add_type[i], add_target[i]
}
printf "; send\n"
}
if (del_count > 0 || add_count > 0) {
printf "\nsend\n"
}
}
' "$updates_file"
} > "$nsupdate_file" || {
rm -f "$nsupdate_file"
_log "Could not build nsupdate instruction file." 4
_log "Leave the function '_zone_updates_apply()'..." 1
return 1
}
cp "$nsupdate_file" "$DYNDNS_LOG_UPDATE_FILE" 2>/dev/null || true
nsupdate_output=$(nsupdate -d -k "$DYNDNS_BIND_ZONE_KEY" "$nsupdate_file" 2>&1)
nsupdate_rc=$?
if [ "$nsupdate_rc" -ne 0 ]; then
{
printf '%s\n' "nsupdate output (exit code: $nsupdate_rc):"
printf '%s\n' "$nsupdate_output"
} >> "$DYNDNS_LOG_FILE"
cp "$nsupdate_file" \
"$DYNDNS_LOG_UPDATE_FILE.failed" 2>/dev/null || true
rm -f "$nsupdate_file"
_log "Zone updates could not be applied (nsupdate exit code: $nsupdate_rc)." 4
_log "Leave the function '_zone_updates_apply()'..." 1
return 1
fi
#nsupdate_output=$(nsupdate -v -k "$DYNDNS_BIND_ZONE_KEY" "$nsupdate_file" 2>&1)
#nsupdate_rc=$?
#if [ "$nsupdate_rc" -ne 0 ]; then
# printf '%s\n' "$nsupdate_output" >>"$DYNDNS_LOG_FILE"
# cp "$nsupdate_file" "$DYNDNS_LOG_UPDATE_FILE.failed" 2>/dev/null || true
# rm -f "$nsupdate_file"
# _log "Zone updates could not be applied." 4
# _log "Leave the function '_zone_updates_apply()'..." 1
# return 1
#fi
rm -f "$nsupdate_file"
_log "Zone updates were applied successfully." 2
# Synchronize dynamic zone journal changes into the master zone file.
#
# BIND keeps successful RFC 2136 updates in the zone journal until a zone dump
# occurs. sync -clean writes the active journal state to the master zone file
# and removes the journal after a successful synchronization.
#
# A synchronization failure is intentionally non-fatal: nsupdate has already
# succeeded and named keeps the authoritative runtime state in the journal.
if [ "$DYNDNS_RNDC_SYNC_CLEAN" = "1" ]; then
if rndc \
-s "$DYNDNS_RNDC_SERVER" \
-k "$DYNDNS_RNDC_KEY" \
sync -clean "$DYNDNS_BIND_ZONE" >/dev/null 2>&1; then
_log "The zone '$DYNDNS_BIND_ZONE' was synchronized and journals were cleaned." 2
else
_log "Zone update succeeded, but rndc sync -clean failed for '$DYNDNS_BIND_ZONE'." 3
fi
fi
_log "Leave the function '_zone_updates_apply()'..." 1
return 0
} #=> _zone_updates_apply()
# SDuesterhaupt: 2026-06-18 - Update the dynamic DNS zone based on database content
#
# Normalized record format:
# subdomain<TAB>hostname<TAB>ip<TAB>record_type
#
# Returns:
# 0 on success
# 1 on failure
_update_zone_dynamic() {
_log "Enter the function '_update_zone_dynamic()'..." 1
db_file=$(dyndns_mktemp) || {
_log "Could not create temporary file for database records." 4
_log "Leave the function '_update_zone_dynamic()'..." 1
return 1
}
zone_file=$(dyndns_mktemp) || {
_log "Could not create temporary file for zone records." 4
_log "Leave the function '_update_zone_dynamic()'..." 1
return 1
}
updates_file=$(dyndns_mktemp) || {
_log "Could not create temporary file for zone updates." 4
_log "Leave the function '_update_zone_dynamic()'..." 1
return 1
}
_zone_db_records_get > "$db_file" || {
_log "Failed to obtain current DNS records from database." 4
_log "Leave the function '_update_zone_dynamic()'..." 1
return 1
}
record_count=$(wc -l < "$db_file" 2>/dev/null)
_log "Database zone records were written to '$db_file' ($record_count entries)." 1
_zone_file_records_get > "$zone_file" || {
_log "Failed to obtain current DNS records from zone file." 4
_log "Leave the function '_update_zone_dynamic()'..." 1
return 1
}
record_count=$(wc -l < "$zone_file" 2>/dev/null)
_log "Zone file records were written to '$zone_file' ($record_count entries)." 1
# DEBUG: dump temp files
#cp "$db_file" "$DYNDNS_LOG_DIR/db_records_${DYNDNS_BIND_ZONE}.tmp"
#cp "$zone_file" "$DYNDNS_LOG_DIR/zone_records_${DYNDNS_BIND_ZONE}.tmp"
_zone_updates_build "$db_file" "$zone_file" > "$updates_file" || {
_log "Failed to generate DNS update instructions." 4
_log "Leave the function '_update_zone_dynamic()'..." 1
return 1
}
record_count=$(wc -l < "$updates_file" 2>/dev/null)
_log "DNS update instructions were written to '$updates_file' ($record_count entries)." 1
# DEBUG: dump temp files
#cp "$updates_file" "$DYNDNS_LOG_DIR/updates_${DYNDNS_BIND_ZONE}.tmp"
if [ ! -s "$updates_file" ]; then
_log "No DNS changes are required for zone '$DYNDNS_BIND_ZONE'." 2
_log "Leave the function '_update_zone_dynamic()'..." 1
return 0
fi
_log "Generated DNS update instructions:" 1
while IFS= read -r line; do
_log "$line" 1
done < "$updates_file"
_zone_updates_apply "$updates_file" || {
_log "Applying DNS updates failed." 4
_log "Leave the function '_update_zone_dynamic()'..." 1
return 1
}
_log "Leave the function '_update_zone_dynamic()'..." 1
return 0
} #=> _update_zone_dynamic()
########################################
# Invocation entry point:
NL='
'
# Be secure with a restrictive umask
[ -z "$DYNDNS_NO_UMASK" ] && umask 077
# Ignore some env vars
DYNDNS_PASSIN=
DYNDNS_PASSOUT=
# Parse options
while :; do
opt=${1%%=*}
val=${1#*=}
empty_ok=""
case "$opt" in
--email|-E)
export DYNDNS_MEMBER_EMAIL="$val"
;;
--hostname|-H)
export DYNDNS_MEMBER_HOSTNAME="$val"
;;
--key|-K)
export DYNDNS_BIND_ZONE_KEY="$val"
;;
--pass|-P)
export DYNDNS_MEMBER_PASS="$val"
;;
--vars|-V)
export DYNDNS_VARS_FILE="$val"
;;
--zone|-Z)
export DYNDNS_BIND_ZONE="$val"
;;
*)
break
;;
esac
if [ -z "$empty_ok" ] && { [ "$val" = "$1" ] || [ -z "$val" ]; }; then
_die "Missing value to option: $opt" 1
fi
shift
done
# SDuesterhaupt: 2019-07-16 - Intelligent env-var detection and auto-loading
_vars_setup
# SDuesterhaupt: 2019-07-28 - Create the log directory
_init_log
# SDuesterhaupt: 2019-07-16 - Register cleanup on EXIT
trap "cleanup" EXIT
# SDuesterhaupt: 2019-07-16 - When SIGHUP, SIGINT, SIGQUIT, SIGABRT and SIGTERM,
# explicitly exit to signal EXIT (non-bash shells)
trap "exit 1" 1
trap "exit 2" 2
trap "exit 3" 3
trap "exit 6" 6
trap "exit 15" 15
# SDuesterhaupt: 2019-07-16 - determine how we were called, then hand off to the function responsible
cmd=$1
[ -n "$1" ] && shift # scrape off command
case "$cmd" in
""|help|-h|--help|--usage)
_cmd_help "$1"
exit 0
;;
add-hostname|check-hostname|remove-hostname|update-zone)
;;
*)
_die "Unknown command '$cmd'. Run without commands for usage help." 1
;;
esac
# SDuesterhaupt: 2019-07-16 - Verify runtime prerequisites
_verify_runtime_init
# SDuesterhaupt: 2019-07-30 - Check the DB connection
_init_DBconnection
#_log "Command dispatcher sees cmd='$cmd'" 1
#printf '%s\n' "TRACE: dispatch cmd='$cmd'" >&2
case "$cmd" in
add-hostname)
_add_hostname "$@"
exit $?
;;
check-hostname)
_check_hostname "$@"
exit $?
;;
remove-hostname)
_remove_hostname "$@"
exit $?
;;
update-zone)
_update_zone_dynamic "$@"
exit $?
;;
esac
# vim: ft=sh nu ai sw=8 ts=8 noet