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.
1642 lines
52 KiB
Bash
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
|