diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e7d00c --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Ignore everything by default +* + +# Allow directories +!*/ + +# Allow Git internals +!/.gitignore + +# Allow shell scripts +!*.sh + +# Allow Markdown files +!*.md + +# Allow vars.example file +!vars.example + +# Allow LICENSE and CHANGELOG +!LICENSE +!CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c680773 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 1.0.0 - 2026-06-20 + +### Added + +- Added a POSIX-compliant dynDNS management script for database-backed hostname lifecycle operations and DNS zone updates. +- Added the commands `add-hostname`, `check-hostname`, `remove-hostname`, and `update-zone`. +- Added hostname validation as well as subdomain and domain extraction helpers. +- Added database helper functions for SQL execution, scalar queries, and SQL string escaping. +- Added member provisioning in the database, including related records in `members`, `connections`, and `login_attempts`. +- Added salted SHA-512 password hashing for stored member credentials. +- Added runtime checks for required commands, directories, files, and environment variables. +- Added structured logging, temporary file handling, and cleanup helpers. +- Added dynamic DNS zone synchronization by comparing database records with the current zone file. +- Added DNS update generation and application through `nsupdate`, including `rndc` freeze and thaw handling. + +### Changed + +- Implemented hostname removal with automatic detection of foreign keys using `ON DELETE CASCADE`. +- Added a compatibility fallback for explicit child-table deletion when cascading foreign keys are not available. + +### Fixed + +- Fixed hostname removal compatibility across different database schema states by supporting both cascading and non-cascading delete paths. diff --git a/LICENSE b/LICENSE index 6943fb8..e7b1b31 100644 --- a/LICENSE +++ b/LICENSE @@ -1,18 +1,21 @@ MIT License -Copyright (c) 2026 cb601 +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: +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 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. +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. diff --git a/README.md b/README.md index 5843f7a..6485653 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,270 @@ -# dyndns +# dynDNS -dynDNS is a POSIX-compliant shell script for managing database-backed dynamic DNS hostnames and synchronizing them with a BIND zone. It handles hostname lifecycle operations (add, check, remove), maintains related database records and generates secure nsupdate transactions with optional DNSSEC and rndc integration. \ No newline at end of file +Lightweight dynDNS backend for BIND-based dynamic DNS updates. + +This repository contains only the backend components of the dynDNS service: the main `dyndns.sh` script and a `vars.example` configuration template. It is designed as a backend component behind a web frontend or API that manages member accounts and IP assignments. + +## Outline + +- [Features](#features) +- [Installation](#installation) +- [MariaDB Initialization](#mariadb-initialization) +- [Database Schema](#database-schema) +- [Configuration](#configuration) +- [Directory Layout](#directory-layout) +- [CRON Job Example](#cron-job-example) +- [License](#license) +- [Authors](#authors) +- [Project Home](#project-home) + +## Features + +- Dynamic DNS backend script for BIND-based environments +- Database-driven record management +- A and AAAA record handling +- Wildcard CNAME generation for managed subdomains +- `nsupdate` integration with TSIG authentication +- DNSSEC-aware update workflow +- Simple configuration through a separate vars file +- Designed for self-hosted Linux environments + +## Installation + +1. Clone the repository. +2. Copy `vars.example` to a site-specific vars file. +3. Adjust paths, database settings, BIND zone settings, and TSIG key references. +4. Make `dyndns.sh` executable. +5. Run the script manually once before automation. + +Example: + +```sh +sudo groupadd --system dyndns +sudo install -d -m 0750 -o root -g dyndns /opt/dyndns +sudo git clone https://dev.town-square.de/cb601/dyndns.git /opt/dyndns + +cd /opt/dyndns + +sudo cp vars.example vars +sudo chown root:dyndns /opt/dyndns/dyndns.sh /opt/dyndns/vars +sudo chmod 0750 /opt/dyndns/dyndns.sh +sudo chmod 0640 /opt/dyndns/vars + +sudo /opt/dyndns/dyndns.sh help +``` + +The `dyndns.sh` script is owned by root and readable by the dyndns group only. This keeps the contents of the `vars` file (including database credentials) restricted to the backend. + +## MariaDB Initialization + +Before running the dynDNS backend for the first time, create the application database and a dedicated database user. Using a dedicated user with privileges limited to the dynDNS database is the recommended setup. + +Connect to MariaDB as an administrative user: + +```sh +mariadb -u root -p +``` + +Alternatively, when using the system root account and a configured `/root/.my.cnf`: + +```sh +sudo mariadb +``` + +Create the database and grant access to a dedicated backend user: + +```sql +CREATE DATABASE dyndns; + +CREATE USER 'dyndnsuser'@'localhost' IDENTIFIED BY ''; +CREATE USER 'dyndnsuser'@'::1' IDENTIFIED BY ''; +CREATE USER 'dyndnsuser'@'127.0.0.1' IDENTIFIED BY ''; + +GRANT ALL PRIVILEGES ON dyndns.* TO 'dyndnsuser'@'localhost'; +GRANT ALL PRIVILEGES ON dyndns.* TO 'dyndnsuser'@'::1'; +GRANT ALL PRIVILEGES ON dyndns.* TO 'dyndnsuser'@'127.0.0.1'; + +FLUSH PRIVILEGES; +``` + +This setup allows local access for the dedicated dynDNS user from `localhost`, `127.0.0.1`, and `::1`, and keeps the dynDNS backend user restricted to the `dyndns` database and local connections only. + +After database initialization, adjust the matching settings in your vars file: + +```sh +DYNDNS_SQL_HOST="localhost" +DYNDNS_SQL_DATABASE="dyndns" +DYNDNS_SQL_USER="dyndnsuser" +DYNDNS_SQL_PASS="" +``` + +Replace `` with a site‑specific, strong password. + +A first manual connection test is recommended before enabling cron-based automation, so database connectivity and privileges can be verified early. + +## Database Schema + +The dynDNS backend expects a MariaDB schema with three core tables: + +- `members` for account and hostname ownership data +- `connections` for the currently assigned IP address per member +- `login_attempts` for tracking login attempts + +The following definitions reflect the currently used database layout. + +### Table: `members` + +Stores dynDNS account identity and hostname ownership data. + +```sql +CREATE TABLE `members` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `username` varchar(63) NOT NULL, + `domain` varchar(253) NOT NULL, + `email` varchar(254) NOT NULL, + `password` varchar(255) NOT NULL, + `salt` varchar(255) NOT NULL, + `timestamp` bigint(20) unsigned NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `username` (`username`,`domain`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +Field overview: + +- `id`: Internal primary key +- `username`: Host-specific account name, usually the subdomain part +- `domain`: Managed DNS zone name +- `email`: Contact address of the member +- `password`: Stored password hash +- `salt`: Per-user salt used for password hashing +- `timestamp`: Record timestamp stored as unsigned Unix time (seconds since epoch) +- `UNIQUE (username, domain)`: Ensures that a hostname can only exist once per zone + +### Table: `connections` + +Stores the currently active IP address assignment for a dynDNS member. + +```sql +CREATE TABLE `connections` ( + `id` int(11) NOT NULL, + `IP` varchar(45) NOT NULL, + `IPv6_flag` tinyint(1) unsigned NOT NULL DEFAULT 0, + `IP_locked` tinyint(1) unsigned NOT NULL DEFAULT 0, + `timestamp` bigint(20) unsigned NOT NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_connections_members` + FOREIGN KEY (`id`) REFERENCES `members` (`id`) + ON DELETE CASCADE + ON UPDATE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +Field overview: + +- `id`: Shared primary key and foreign key referencing `members.id` +- `IP`: Current IP address in text form; `varchar(45)` supports both IPv4 and IPv6 +- `IPv6_flag`: `0` for A records, `1` for AAAA records +- `IP_locked`: Prevents automated DNS updates when set +- `timestamp`: Record timestamp stored as unsigned Unix time (seconds since epoch) + +### Table: `login_attempts` + +Stores the login attempt counter for a member. + +```sql +CREATE TABLE `login_attempts` ( + `id` int(11) NOT NULL, + `count` smallint(5) unsigned NOT NULL DEFAULT 0, + `timestamp` bigint(20) unsigned NOT NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_login_attempts_members` + FOREIGN KEY (`id`) REFERENCES `members` (`id`) + ON DELETE CASCADE + ON UPDATE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +Field overview: + +- `id`: Shared primary key and foreign key referencing `members.id` +- `count`: Number of tracked login attempts +- `timestamp`: Record timestamp stored as unsigned Unix time (seconds since epoch) + +### Referential Integrity + +The schema uses explicit foreign key constraints from `connections.id` and `login_attempts.id` to `members.id`. Both child tables use `ON DELETE CASCADE`, so dependent records are removed automatically when a member is deleted. This ensures database-level referential integrity and matches the intended one-to-one relationship design. + +When deleting a member, the application only needs to remove the parent row from `members`; dependent rows in `connections` and `login_attempts` are removed automatically by the database. + +### Notes + +- The schema uses `utf8mb4` with `utf8mb4_unicode_ci`. +- `username` uses `varchar(63)`, which matches the maximum length of a single DNS label. +- `domain` uses `varchar(253)`, which matches the typical maximum length of a FQDN without trailing root dot. +- IP addresses are stored as text in `varchar(45)`, which is sufficient for IPv4 and IPv6 storage. + +## Configuration + +The backend is configured through a separate configuration file derived from `vars.example`. + +Typical settings include: + +- Base paths and log paths +- Database connection settings +- DNS zone name and zone file paths +- BIND server endpoint +- TSIG key file used for `nsupdate` and `rndc` +- Default TTL values + +Recommended workflow: + +1. Start from `vars.example`. +2. Create an environment-specific copy, for example `vars`. +3. Keep secrets and local overrides out of version control. +4. Validate database access and BIND update permissions before enabling automation. + +## Directory Layout + +```text +. +├── dyndns.sh +├── vars.example +├── README.md +├── Changelog.md +├── LICENSE +└── .gitignore +``` + +## CRON Job Example + +The following example assumes the job is installed in the crontab of a dedicated service user or root, with sufficient read access to the vars file and execute permissions on `/opt/dyndns/dyndns.sh`. + +```sh +*/5 * * * * /opt/dyndns/dyndns.sh update-zone >/dev/null 2>&1 +``` + +Running the job every 5 minutes is a reasonable default for typical dynDNS use cases. + +If a non-default vars file is used: + +```sh +*/5 * * * * /opt/dyndns/dyndns.sh --vars /opt/dyndns/vars update-zone >/dev/null 2>&1 +``` + +## License + +[MIT](https://dev.town-square.de/cb601/dyndns/src/branch/main/LICENSE) + +See `LICENSE` for details. + +## Authors + +CB-601 - the open tec Elevator + +- Stephan Düsterhaupt ([XMPP](xmpp:me@jabber.stephanduesterhaupt.de)) +- Ivo Noack aka Insonic ([XMPP](xmpp:me@jabber.ivonoack.de)) + +## Project Home + +Project Home: [https://dev.town-square.de/cb601/dyndns](https://dev.town-square.de/cb601/dyndns) diff --git a/dyndns.sh b/dyndns.sh new file mode 100644 index 0000000..350cda7 --- /dev/null +++ b/dyndns.sh @@ -0,0 +1,1614 @@ +#!/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 +# Copyright (c) 2018-2026 CB-601 - the open tec Elevator +# License: MIT + +############################################################################### + +# 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: +# subdomainhostnameiprecord_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: +# subdomainhostnameiprecord_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 (subdomainhostnameiprecord_type) +# $2 = zone records file (subdomainhostnameiprecord_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: +# delownertype +# addownerttltypetarget +# +# 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: +# subdomainhostnameiprecord_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 diff --git a/vars.example b/vars.example new file mode 100644 index 0000000..b4579b3 --- /dev/null +++ b/vars.example @@ -0,0 +1,137 @@ +# dynDNS configuration example +# Copy this file to 'vars' and adjust as needed. +# Warning: do not edit vars.example directly! + +# ------------------------------------------------------------------ +# GENERAL +# ------------------------------------------------------------------ + +# Base directory of dynDNS configuration (defaults to script directory) +#set_var DYNDNS "${0%/*}" + +# Batch mode: +# Leave empty for interactive/normal console output. +# Set to any non-empty string to suppress notices on stdout. +#set_var DYNDNS_BATCH "" + +# ------------------------------------------------------------------ +# RUNTIME DIRECTORIES +# ------------------------------------------------------------------ + +# Temporary working directory used for per-run temp files and directories +#set_var DYNDNS_TEMP_DIR "$DYNDNS/tmp" + +# ------------------------------------------------------------------ +# LOGGING +# ------------------------------------------------------------------ + +# Directory for dynDNS log files +#set_var DYNDNS_LOG_DIR "$DYNDNS/log" + +# Main log file +#set_var DYNDNS_LOG_FILE "$DYNDNS_LOG_DIR/dyndns_${DYNDNS_BIND_ZONE:-default}.log" + +# File used to track the last zone update timestamp/state +#set_var DYNDNS_LOG_UPDATE_FILE "$DYNDNS_LOG_DIR/lastZoneUpdate_${DYNDNS_BIND_ZONE:-default}.log" + +# Log level controls verbosity of logging output: +# 0 = off : Disable all logging output +# 1 = debug : Detailed diagnostics for troubleshooting and development +# 2 = info : Informational messages about normal operations +# 3 = warning : Warnings about unusual but non-fatal conditions +# 4 = error : Errors affecting functionality +# 5 = critical : Severe failures requiring immediate attention +#set_var DYNDNS_LOG_LEVEL 3 + +# ------------------------------------------------------------------ +# DATABASE ACCESS +# ------------------------------------------------------------------ + +# Database client program. +# Example values: +# /usr/bin/mariadb +# mariadb +# /usr/bin/mysql +#set_var DYNDNS_DB_PROGRAM "/usr/bin/mariadb" + +# Database host +#set_var DYNDNS_SQL_HOST "localhost" + +# Optional client option group suffix. +# If set, dyndns uses: +# --defaults-group-suffix=$DYNDNS_SQL_GROUP +# and usually reads credentials from my.cnf / client config. +#set_var DYNDNS_SQL_GROUP "dyndns" + +# Database name +#set_var DYNDNS_SQL_DATABASE "dyndns_database" + +# Database login credentials. +# These are mainly used when DYNDNS_SQL_GROUP is unset/empty. +#set_var DYNDNS_SQL_USER "dyndns_user" +#set_var DYNDNS_SQL_PASS "strong_passphrase" + +# Additional database client options, if needed. +# Example: +# "--protocol=tcp" +# "--socket=/run/mysqld/mysqld.sock" +#set_var DYNDNS_SQL_OPTIONS "" + +# ------------------------------------------------------------------ +# DNS / BIND +# ------------------------------------------------------------------ + +# Authoritative DNS server used for update operations +#set_var DYNDNS_BIND_SERVER "127.0.0.1" + +# Service name used when reloading/reconfiguring the name server +#set_var DYNDNS_BIND_SERVICE "named.service" + +# DNS zone handled by dynDNS +#set_var DYNDNS_BIND_ZONE "example24.com" + +# TSIG key file or key identifier used for nsupdate access +#set_var DYNDNS_BIND_ZONE_KEY "keyfile" + +# Directory containing the primary zone files +#set_var DYNDNS_BIND_ZONE_DIR "/var/named/zones" + +# Main unsigned zone file +#set_var DYNDNS_BIND_ZONE_FILE "$DYNDNS_BIND_ZONE_DIR/$DYNDNS_BIND_ZONE.zone" + +# Signed zone file, if DNSSEC signing is used externally +#set_var DYNDNS_BIND_ZONE_FILE_SIGNED "$DYNDNS_BIND_ZONE_DIR/$DYNDNS_BIND_ZONE.zone.signed" + +# Default TTL for generated/managed records +#set_var DYNDNS_BIND_ZONE_TTL 300 + +# ------------------------------------------------------------------ +# MEMBER DEFAULTS +# ------------------------------------------------------------------ + +# These values are typically supplied at runtime by command line options +# or frontend/backend integration, but they may also be preset here. + +# Fully qualified hostname of the dynDNS member +#set_var DYNDNS_MEMBER_HOSTNAME "host.example24.com" + +# Contact e-mail address of the dynDNS member +#set_var DYNDNS_MEMBER_EMAIL "admin@example24.com" + +# Password or shared secret used during member creation/authentication +#set_var DYNDNS_MEMBER_PASS "strong_passphrase" + +# ------------------------------------------------------------------ +# OPTIONAL COMMAND HOOKS +# ------------------------------------------------------------------ + +# Reserved section for optional local extensions. +# Only enable such commands if your dyndns.sh implementation actually evaluates them. + +# Commands to run before a zone update +# Example: +# set_var DYNDNS_CMD_PRE "/usr/local/libexec/dyndns-pre-update.sh" + +# Commands to run after a successful zone update +# Example: +# set_var DYNDNS_CMD_POST "/usr/local/libexec/dyndns-post-update.sh"