1618 lines
52 KiB
Bash
1618 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
|
|
|
|
_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 rndc >/dev/null 2>&1 || _die "rndc 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
|
|
|
|
_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
|
|
|
|
# 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
|
|
}
|
|
|
|
_log "Check the zone '$DYNDNS_BIND_ZONE' for DNSSEC capabilities..." 1
|
|
if [ -f "$DYNDNS_BIND_ZONE_FILE_SIGNED" ]; then
|
|
_log "The zone '$DYNDNS_BIND_ZONE' uses DNSSEC..." 1
|
|
zone_uses_dnssec=1
|
|
else
|
|
_log "The zone '$DYNDNS_BIND_ZONE' does not use DNSSEC..." 1
|
|
zone_uses_dnssec=0
|
|
fi
|
|
|
|
{
|
|
printf 'debug\n'
|
|
printf 'server %s\n' "$DYNDNS_BIND_SERVER"
|
|
printf 'zone %s.\n' "$DYNDNS_BIND_ZONE"
|
|
|
|
if [ "$zone_uses_dnssec" -eq 1 ]; then
|
|
printf '; Move %s from secure to insecure temporarily\n' "$DYNDNS_BIND_ZONE_FILE"
|
|
printf 'update delete %s. DNSKEY\n' "$DYNDNS_BIND_ZONE"
|
|
printf 'send\n'
|
|
fi
|
|
|
|
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
|
|
|
|
if ! nsupdate -k "$DYNDNS_BIND_ZONE_KEY" "$nsupdate_file" >/dev/null 2>&1; then
|
|
rm -f "$nsupdate_file"
|
|
_log "Zone updates could not be applied." 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
|
|
|
|
if command -v rndc >/dev/null 2>&1; then
|
|
if rndc -s "$DYNDNS_BIND_SERVER" -k "$DYNDNS_BIND_ZONE_KEY" freeze "$DYNDNS_BIND_ZONE" >/dev/null 2>&1; then
|
|
_log "The zone '$DYNDNS_BIND_ZONE' was frozen." 2
|
|
if rndc -s "$DYNDNS_BIND_SERVER" -k "$DYNDNS_BIND_ZONE_KEY" thaw "$DYNDNS_BIND_ZONE" >/dev/null 2>&1; then
|
|
_log "The zone '$DYNDNS_BIND_ZONE' was reloaded." 2
|
|
else
|
|
_log "Couldn't reload the zone '$DYNDNS_BIND_ZONE'." 4
|
|
_log "Leave the function '_zone_updates_apply()'..." 1
|
|
return 1
|
|
fi
|
|
else
|
|
_log "Couldn't freeze the zone '$DYNDNS_BIND_ZONE'." 4
|
|
_log "Leave the function '_zone_updates_apply()'..." 1
|
|
return 1
|
|
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
|