Skip to content

Instantly share code, notes, and snippets.

@troykelly
Last active January 17, 2025 04:36
Show Gist options
  • Save troykelly/64949ab42f670d3a5853f5ae8332b0f5 to your computer and use it in GitHub Desktop.
Save troykelly/64949ab42f670d3a5853f5ae8332b0f5 to your computer and use it in GitHub Desktop.
This script is designed to validate and, if necessary, correct the system’s configured Fully Qualified Domain Name (FQDN) on Debian/Ubuntu (and similar) systems.

check_fqdn.sh

This script is designed to validate and, if necessary, correct the system’s configured Fully Qualified Domain Name (FQDN) on Debian/Ubuntu (and similar) systems. It:

  1. Checks if the current operating system is a Debian/Ubuntu (APT-based) distribution.
  2. Ensures required system commands are installed, attempting silent installation via apt-get if necessary.
  3. Identifies the default route’s interface for both IPv4 and IPv6.
  4. Retrieves the primary global (i.e., non-local) IPv4 and IPv6 addresses on those interfaces.
  5. Performs reverse DNS lookups on those local addresses to determine their FQDNs.
  6. Ensures that the forward DNS record for that FQDN matches both IP addresses.
  7. Ensures the local system’s hostname is set to that FQDN.

Requirements

• Root or sudo privileges (for installing packages or modifying the system hostname).
• A Debian/Ubuntu or derivative operating system that uses apt-get.
• Basic DNS utilities (host, dig).


Usage

  1. Fetch the script and execute it directly:
    curl -s https://gist.githubusercontent.com/troykelly/64949ab42f670d3a5853f5ae8332b0f5/raw/check_fqdn.sh | bash

  2. For additional options:
    ./check_fqdn.sh --help

  3. For JSON output (useful for machine parsing):
    ./check_fqdn.sh --json


Exit Codes

Exit Code Meaning
0 Success.
1 Unsupported operating system.
2 Missing commands could not be installed.
3 Unable to determine or parse default IPv4 route/interface.
4 Unable to determine or parse default IPv6 route/interface.
5 DNS resolution failure (reverse lookup).
6 DNS records mismatch (forward vs. reverse).
7 Unable to update system hostname.
8 Failed during package installation process.
130 Script interrupted by SIGINT.
131 Script terminated by SIGTERM.

Risks and Considerations

• The script attempts to install missing packages silently.
• Changing the system-wide hostname might disrupt certain applications or services if they rely on the original hostname.
• Reverse and forward DNS must be configured correctly in the environment (i.e., your DNS servers must have valid and consistent records).
• If the script is run on a non-Debian/Ubuntu system, it will fail gracefully.


Support

This script is provided as-is. Use in production environments requires thorough testing beforehand. Always ensure you have backups or snapshots before changing critical system settings such as hostnames.

#!/usr/bin/env bash
#
# check_fqdn.sh
#
# This script verifies (and if needed, updates) the system's FQDN to match
# the local interface’s reverse DNS record(s) for both IPv4 and IPv6. It is intended
# for Debian/Ubuntu-based systems that have apt-get for package management.
#
# Usage:
# ./check_fqdn.sh [--json] [--help]
#
# Example (one-line execution):
# curl -s https://gist.githubusercontent.com/troykelly/64949ab42f670d3a5853f5ae8332b0f5/raw/check_fqdn.sh | bash
#
# Exit Codes:
# 0 Success
# 1 Unsupported operating system
# 2 Missing commands could not be installed
# 3 Unable to determine or parse default IPv4 route/interface
# 4 Unable to determine or parse default IPv6 route/interface
# 5 DNS resolution failure (reverse lookup)
# 6 DNS records mismatch (forward vs. reverse)
# 7 Unable to update system hostname
# 8 Failed during package installation process
# 130 Script interrupted by SIGINT
# 131 Script terminated by SIGTERM
set -Eeuo pipefail
# ------------------------------------------------------------------------------
# Error codes
# ------------------------------------------------------------------------------
readonly ERR_UNSUPPORTED_OS=1
readonly ERR_MISSING_COMMANDS=2
readonly ERR_DEFAULT_IPV4=3
readonly ERR_DEFAULT_IPV6=4
readonly ERR_DNS_RESOLUTION=5
readonly ERR_DNS_MISMATCH=6
readonly ERR_HOSTNAME_UPDATE=7
readonly ERR_INSTALLATION_FAILURE=8
# ------------------------------------------------------------------------------
# Trap signals for clean exits
# ------------------------------------------------------------------------------
trap 'clean_exit 130 "Script interrupted by SIGINT."' INT
trap 'clean_exit 131 "Script terminated by SIGTERM."' TERM
# ------------------------------------------------------------------------------
# Global variables
# ------------------------------------------------------------------------------
JSON_OUTPUT=false
LOG_MESSAGES=()
# ------------------------------------------------------------------------------
# Functions
# ------------------------------------------------------------------------------
##
# Print an informational log message.
#
# Args:
# $1: String containing the log message.
function log_info {
LOG_MESSAGES+=("[INFO] $1")
}
##
# Print an error-level log message.
#
# Args:
# $1: String containing the error message.
function log_error {
LOG_MESSAGES+=("[ERROR] $1")
}
##
# Clean exit function to handle signal-based or error-based termination.
#
# Args:
# $1: Numeric exit code.
# $2: Exit message.
function clean_exit {
local exit_code="$1"
local exit_message="$2"
LOG_MESSAGES+=("[EXIT] ${exit_message}")
output_logs "${exit_code}"
exit "${exit_code}"
}
##
# Print usage information.
function usage {
cat <<EOF
Usage: $0 [OPTIONS]
Options:
--json Output results in JSON for machine processing.
--help Display this help message.
Description:
This script verifies (and if needed, updates) the system's FQDN to match the
local interface’s reverse DNS record(s) for both IPv4 and IPv6 on
Debian/Ubuntu-based systems. It ensures required utilities are installed,
checks DNS records, and adjusts the hostname using hostnamectl if necessary.
EOF
}
##
# If JSON output was requested, print logs in JSON format; otherwise, print text.
#
# Args:
# $1: Numeric exit code to embed in JSON or display after messages.
function output_logs {
local final_exit_code="$1"
if [[ "${JSON_OUTPUT}" == true ]]; then
echo -n '{"exit_code":'
echo -n "${final_exit_code}"
echo -n ',"logs":['
local is_first=true
for message in "${LOG_MESSAGES[@]}"; do
if [[ "${is_first}" == true ]]; then
is_first=false
else
echo -n ','
fi
local escaped_message
escaped_message=$(echo -n "${message}" | sed 's/\\/\\\\/g; s/"/\\"/g')
echo -n "\"${escaped_message}\""
done
echo -n ']}'
echo
else
for message in "${LOG_MESSAGES[@]}"; do
echo "${message}"
done
echo "Exit Code: ${final_exit_code}"
fi
}
##
# Check if the OS is supported (i.e., Debian or Ubuntu or apt-get-based).
# If not supported, exit with ERR_UNSUPPORTED_OS.
function check_os {
if [[ ! -f /etc/os-release ]]; then
log_error "Unable to determine operating system. /etc/os-release not found."
clean_exit "${ERR_UNSUPPORTED_OS}" "Unsupported operating system."
fi
# shellcheck disable=SC1091
. /etc/os-release
if ! command -v apt-get &>/dev/null; then
log_error "apt-get not available. Only Debian/Ubuntu derivatives are supported."
clean_exit "${ERR_UNSUPPORTED_OS}" "Unsupported operating system."
fi
log_info "OS check passed. ID=${ID:-unknown}, VERSION_ID=${VERSION_ID:-unknown}"
}
##
# Ensure a command exists on the system by attempting to install one or more
# fallback packages if the command is not found.
#
# Args:
# $1: The command to verify (e.g., "host").
# $2...$n: One or more package names to install if the command is missing.
#
# Behaviour:
# 1. If $1 exists, return immediately.
# 2. Otherwise, attempt to install each fallback package in turn.
# 3. If installation succeeds and the command becomes available, return.
# 4. If no packages succeed, exit with ERR_MISSING_COMMANDS.
function require_command {
local cmd="$1"
shift
local -a packages=("$@")
# If the command is already present, all good.
if command -v "${cmd}" &>/dev/null; then
log_info "Command '${cmd}' is present."
return
fi
# If the command is missing, attempt to install from each provided package in turn.
local installed_successfully=false
for pkg in "${packages[@]}"; do
log_info "Command '${cmd}' not found. Attempting to install package '${pkg}'."
if sudo apt-get update -qq && sudo apt-get install -y -qq "${pkg}"; then
if command -v "${cmd}" &>/dev/null; then
log_info "Command '${cmd}' now found after installing '${pkg}'."
installed_successfully=true
break
else
log_error "Command '${cmd}' still not found after installing '${pkg}'."
fi
else
log_error "Failed to install package '${pkg}'."
fi
done
# If none of the fallback packages provided the command, exit.
if [[ "${installed_successfully}" == false ]]; then
clean_exit "${ERR_MISSING_COMMANDS}" "Missing command '${cmd}'."
fi
}
##
# Obtain the interface that the default IPv4 route uses, if present.
# Returns:
# Prints interface name to stdout.
function get_default_iface_ipv4 {
local iface_v4
iface_v4=$(ip -4 route show default 2>/dev/null | awk '/default via/ {print $5; exit}' || true)
if [[ -z "${iface_v4}" ]]; then
return 1
fi
echo "${iface_v4}"
return 0
}
##
# Obtain the primary (non-local) IPv4 address on a given interface.
#
# Args:
# $1: The interface name (e.g. enp0s3).
# Returns:
# Prints the IPv4 address (e.g. 192.168.1.10) to stdout or returns an error.
function get_local_ipv4_address {
local iface_v4="$1"
local ip_v4
ip_v4=$(ip -4 addr show dev "${iface_v4}" scope global 2>/dev/null \
| awk '/inet / {print $2}' \
| cut -d/ -f1 \
| head -n1 || true)
if [[ -z "${ip_v4}" ]]; then
return 1
fi
echo "${ip_v4}"
}
##
# Obtain the interface that the default IPv6 route uses, if present.
# Returns:
# Prints interface name to stdout.
function get_default_iface_ipv6 {
local iface_v6
iface_v6=$(ip -6 route show default 2>/dev/null | awk '/default via/ {print $5; exit}' || true)
if [[ -z "${iface_v6}" ]]; then
return 1
fi
echo "${iface_v6}"
return 0
}
##
# Obtain a global (non-link-local) IPv6 address from a given interface.
#
# Args:
# $1: The interface name (e.g. enp0s3).
# Returns:
# Prints desired IPv6 address to stdout or returns an error.
function get_local_ipv6_address {
local iface_v6="$1"
local ip6
ip6=$(ip -6 addr show dev "${iface_v6}" 2>/dev/null \
| grep -E 'inet6 [0-9a-fA-F:]+' \
| awk '{print $2}' \
| cut -d/ -f1 \
| grep -Ev '^(fe80|fc|fd|::1)' \
| head -n1 || true)
if [[ -z "${ip6}" ]]; then
return 1
fi
echo "${ip6}"
}
##
# Perform a reverse DNS lookup on an IP address.
#
# Args:
# $1: IP address.
# Returns:
# Echo the retrieved FQDN (removing trailing period).
function reverse_dns_lookup {
local ip="$1"
local host_out
host_out=$(host "${ip}" 2>&1 || true)
if echo "${host_out}" | grep -q 'not found'; then
return 1
fi
local fqdn
fqdn=$(echo "${host_out}" \
| awk '/pointer/ {print $5}' \
| sed 's/\.$//' \
| head -n1)
echo "${fqdn}"
}
##
# Check forward DNS for the given FQDN, verifying it gives the expected IPs.
#
# Args:
# $1: FQDN
# $2: IPv4 (optional)
# $3: IPv6 (optional)
# Returns:
# 0 on perfect match, 1 if mismatch or not found.
function verify_forward_dns {
local fqdn="$1"
local expect_v4="${2:-}"
local expect_v6="${3:-}"
# If an expected IPv4 is supplied, check for a matching A record.
if [[ -n "${expect_v4}" ]]; then
local found_v4
found_v4=$(dig +short A "${fqdn}" \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' || true)
# If the FQDN has an A record, ensure at least one matches the local IPv4.
if ! echo "${found_v4}" | grep -qx "${expect_v4}"; then
return 1
fi
fi
# If an expected IPv6 is supplied, check for a matching AAAA record.
if [[ -n "${expect_v6}" ]]; then
local found_v6
found_v6=$(dig +short AAAA "${fqdn}" | grep -Ev '^(fc|fd|fe80|::1)' || true)
# Check for an exact match in the returned set for AAAA.
if ! echo "${found_v6}" | grep -qx "${expect_v6}"; then
return 1
fi
fi
return 0
}
##
# Check and update the system hostname if needed.
#
# Args:
# $1: The FQDN to set if the hostname is not already correct.
function ensure_hostname {
local fqdn="$1"
local current_fqdn
current_fqdn=$(hostname -f 2>/dev/null || echo "")
if [[ "${current_fqdn}" == "${fqdn}" ]]; then
log_info "System hostname is already set to '${fqdn}'. No update required."
return
fi
log_info "Updating system hostname to '${fqdn}'."
if ! sudo hostnamectl set-hostname "${fqdn}"; then
log_error "Failed to update hostname to '${fqdn}'."
clean_exit "${ERR_HOSTNAME_UPDATE}" "Unable to update system hostname."
fi
log_info "Successfully updated system hostname to '${fqdn}'."
}
# ------------------------------------------------------------------------------
# main
# ------------------------------------------------------------------------------
##
# Main script logic.
#
# Parses CLI flags, checks OS, ensures required commands, determines local IPs,
# performs reverse/forward DNS checks, and updates hostname as needed.
function main {
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--help)
usage
exit 0
;;
--json)
JSON_OUTPUT=true
shift
;;
*)
log_error "Unrecognised argument: $1"
usage
exit 0
;;
esac
done
log_info "Starting check_fqdn.sh script."
# Step 1: Check OS
check_os
# Step 2: Ensure required commands.
# Provide fallback packages if needed, especially for DNS tools which may have
# different package names (e.g., 'dnsutils', 'bind9-dnsutils', 'bind9-host').
require_command "hostnamectl" "systemd"
require_command "ip" "iproute2"
require_command "host" "dnsutils" "bind9-dnsutils" "bind9-host"
require_command "dig" "dnsutils" "bind9-dnsutils" "bind9-host"
require_command "awk" "gawk" # Typically 'awk' is present, but we include fallback.
# Step 3: Determine local IPv4 interface and address
local iface_v4
iface_v4=$(get_default_iface_ipv4 || true)
if [[ -z "${iface_v4}" ]]; then
log_error "Unable to determine default IPv4 interface."
clean_exit "${ERR_DEFAULT_IPV4}" "Unable to parse default IPv4 route/interface."
fi
local local_ipv4
local_ipv4=$(get_local_ipv4_address "${iface_v4}" || true)
if [[ -z "${local_ipv4}" ]]; then
log_error "Unable to find a valid local IPv4 address on interface '${iface_v4}'."
clean_exit "${ERR_DEFAULT_IPV4}" "Unable to determine or parse local IPv4 address."
fi
log_info "Local IPv4 on '${iface_v4}': ${local_ipv4}"
# Reverse DNS on local IPv4
local fqdn_v4
fqdn_v4=$(reverse_dns_lookup "${local_ipv4}" || true)
if [[ -z "${fqdn_v4}" ]]; then
log_error "Reverse DNS lookup failed for local IPv4 address ${local_ipv4}."
clean_exit "${ERR_DNS_RESOLUTION}" "DNS resolution failure for local IPv4."
fi
log_info "Local IPv4 reverse DNS FQDN: ${fqdn_v4}"
# Step 4: Determine local IPv6 interface and address
local iface_v6
iface_v6=$(get_default_iface_ipv6 || true)
if [[ -z "${iface_v6}" ]]; then
log_error "Unable to determine default IPv6 interface."
clean_exit "${ERR_DEFAULT_IPV6}" "Unable to parse default IPv6 route/interface."
fi
local local_ipv6
local_ipv6=$(get_local_ipv6_address "${iface_v6}" || true)
if [[ -z "${local_ipv6}" ]]; then
log_error "Unable to find a valid global IPv6 address on interface '${iface_v6}'."
clean_exit "${ERR_DEFAULT_IPV6}" "Unable to determine or parse local IPv6 address."
fi
log_info "Local IPv6 on '${iface_v6}': ${local_ipv6}"
# Reverse DNS on local IPv6
local fqdn_v6
fqdn_v6=$(reverse_dns_lookup "${local_ipv6}" || true)
if [[ -z "${fqdn_v6}" ]]; then
log_error "Reverse DNS lookup failed for local IPv6 address ${local_ipv6}."
clean_exit "${ERR_DNS_RESOLUTION}" "DNS resolution failure for local IPv6."
fi
log_info "Local IPv6 reverse DNS FQDN: ${fqdn_v6}"
# Step 5: Compare the FQDNs from IPv4 and IPv6. Expect them to match.
if [[ "${fqdn_v4}" != "${fqdn_v6}" ]]; then
log_error "IPv4 reverse FQDN (${fqdn_v4}) and IPv6 reverse FQDN (${fqdn_v6}) differ."
clean_exit "${ERR_DNS_MISMATCH}" "Mismatch between IPv4 and IPv6 reverse DNS."
fi
local final_fqdn="${fqdn_v4}"
log_info "Unified local FQDN validated: ${final_fqdn}"
# Step 6: Verify forward DNS
if ! verify_forward_dns "${final_fqdn}" "${local_ipv4}" "${local_ipv6}"; then
log_error "Forward DNS record mismatch for FQDN '${final_fqdn}' with IPs '${local_ipv4}' and '${local_ipv6}'."
clean_exit "${ERR_DNS_MISMATCH}" "DNS records mismatch."
fi
log_info "Forward DNS records match the local IPs for '${final_fqdn}'."
# Step 7: Ensure system hostname
ensure_hostname "${final_fqdn}"
# Step 8: Complete success
log_info "All checks passed successfully. The system FQDN is now set (or already was) to '${final_fqdn}'."
clean_exit 0 "Success."
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment