|
#!/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 "$@" |