Key takeaways

  • Install ISC BIND consistently across Debian/Ubuntu and RHEL-family Linux.
  • Generate a forward DNS zone from CSV records and validate it with named-checkzone.
  • Validate DNS records with dig and export the result as a CSV operations report.
  • Forward BIND query and operational logs to Splunk using rsyslog imfile and omfwd.

Build A Reproducible And Observable Linux DNS Server

ISC BIND is still one of the most common DNS services used in enterprise Linux environments. The challenge is not only installing it, but making the setup repeatable across Debian and RHEL systems, validating the zone, testing expected records, and sending DNS activity into a SIEM for authorized monitoring.

This guide uses four Bash scripts:

ScriptPurpose
install_dns_server.shInstalls BIND and DNS utilities on Debian or RHEL-family Linux.
configure_dns_server.shCreates BIND options, a forward zone, and DNS records from CSV.
bulk_dns_test.shRuns multiple dig lookups and writes a CSV test report.
configure_dns_splunk_rsyslog.shEnables BIND log files and forwards them to Splunk through rsyslog.

The tested lab host was AlmaLinux with BIND running as named on 172.19.4.155.

Responsible Use And Scope

This guide is intended for administrators building DNS services in labs, internal networks, or approved enterprise environments. Run the scripts only on systems you manage, and review firewall, recursion, and forwarding settings before exposing DNS service to other hosts.

The examples use private lab domains and sample IP addresses. Replace them with organization-approved values and keep Splunk forwarding aligned with your logging and retention policy.

Architecture

flowchart LR records["CSV zone records"] --> config["configure_dns_server.sh"] config --> dnsService["ISC BIND DNS service"] dnsService --> zone["Authoritative zone file"] zone --> validation["named-checkzone validation"] queries["DNS clients and bulk_dns_test.sh"] --> dnsService dnsService --> queryLog["dns_queries.log"] dnsService --> generalLog["dns_general.log"] queryLog --> rsyslog["rsyslog imfile"] generalLog --> rsyslog rsyslog --> splunk["Splunk syslog input"]

Platform Mapping

The scripts detect the distribution family from /etc/os-release and use the correct package names, service names, and configuration paths.

Distribution familyPackagesServiceMain configZone path
Debian/Ubuntubind9 bind9-utils dnsutilsbind9/etc/bind/named.conf.options/etc/bind/zones
RHEL/CentOS/Rocky/Alma/Fedorabind bind-utilsnamed/etc/named.conf/var/named

Repository Layout

DNS/
|-- install_dns_server.sh
|-- configure_dns_server.sh
|-- configure_dns_splunk_rsyslog.sh
|-- bulk_dns_test.sh
|-- sample_records.csv
|-- sample_queries.txt
|-- README.md
`-- Parser/
    |-- app-isc_bind_ta.conf
    |-- app-dest-rewrite-isc_bind_sourcetype.conf
    |-- generate_isc_bind_ta_logs.sh
    |-- test_isc_bind_parser.sh
    `-- test_isc_bind_parser.ps1

Make the scripts executable:

chmod +x install_dns_server.sh \
  configure_dns_server.sh \
  configure_dns_splunk_rsyslog.sh \
  bulk_dns_test.sh

Install BIND

Run the installer as root:

sudo ./install_dns_server.sh

The script:

1. Detects Debian or RHEL family Linux. 2. Installs the correct BIND packages. 3. Enables and starts the DNS service. 4. Opens TCP/UDP 53 when firewalld or ufw is active. 5. Runs named-checkconf.

Expected RHEL-family result:

[INFO] Detected rhel family. DNS service will be named.
[INFO] Installing BIND packages with dnf.
[INFO] Enabling and starting named.
[INFO] Checking named configuration syntax.
[INFO] DNS server installation completed.

IPv6 root lookup warnings such as network unreachable resolving './NS/IN' are not fatal when the host has no working IPv6 route.

Configure A DNS Zone

Create records in CSV format:

name,type,value,ttl,priority
@,A,192.168.10.20,3600,
www,CNAME,@,3600,
mail,A,192.168.10.30,3600,
@,MX,mail.example.local.,3600,10
txttest,TXT,hello world,3600,

Run the zone configuration:

sudo ./configure_dns_server.sh \
  --zone example.local \
  --server-ip 172.19.4.155 \
  --records sample_records.csv \
  --forwarders "1.1.1.1 8.8.8.8" \
  --allow-query "any" \
  --allow-recursion "localnets localhost"

The script writes BIND options, creates the zone file, registers the zone, validates the configuration, and restarts the service.

Expected validation:

[INFO] Detected rhel family.
[INFO] Configuring zone example.local on 172.19.4.155.
[INFO] Validating BIND configuration.
zone example.local/IN: loaded serial 2026042101
OK
[INFO] Restarting named.
[INFO] DNS server configuration completed.

The generated zone contains the SOA, NS, DNS server A record, and the CSV records:

$TTL 3600
@ IN SOA ns1.example.local. admin.example.local. (
        2026042101 ; serial
        3600       ; refresh
        1800       ; retry
        604800     ; expire
        86400 )    ; minimum

@       IN NS   ns1.example.local.
ns1     IN A    172.19.4.155
@                    3600 IN A     192.168.10.20
www                  3600 IN CNAME example.local.
mail                 3600 IN A     192.168.10.30
@                    3600 IN MX    10 mail.example.local.
txttest              3600 IN TXT   "hello world"

For production-like internal DNS, prefer a zone such as example.internal, corp.example.com, or lab.example.net. The .local suffix is reserved for multicast DNS and causes dig to display an mDNS warning.

Validate DNS Resolution

Query the DNS server directly:

dig @172.19.4.155 example.local A
dig @172.19.4.155 www.example.local CNAME
dig @172.19.4.155 mail.example.local A

Successful answers should include:

status: NOERROR
flags: qr aa
SERVER: 172.19.4.155#53

The aa flag confirms the server is answering authoritatively for the zone.

Bulk Test DNS Records

Create a query file:

example.local,A
www.example.local,CNAME
mail.example.local,A
example.local,MX
txttest.example.local,TXT

Run the bulk test:

./bulk_dns_test.sh \
  --server 172.19.4.155 \
  --file sample_queries.txt \
  --output dns_test_report.csv

The CSV output includes query name, record type, status, elapsed time, response code, and answer text.

query,type,status,elapsed_ms,rcode,answer
"example.local","A","PASS","18","NOERROR","example.local. 3600 IN A 192.168.10.20"

Use this report as evidence in implementation records or change validation.

Forward BIND Logs To Splunk With rsyslog

The rsyslog integration uses a two-step pattern:

1. BIND writes query and general DNS logs into local files. 2. rsyslog tails those files with imfile and forwards events to Splunk using omfwd.

Run the forwarding script:

sudo ./configure_dns_splunk_rsyslog.sh \
  --splunk-host 10.10.10.50 \
  --splunk-port 514 \
  --protocol tcp

Replace 10.10.10.50 with the Splunk indexer, heavy forwarder, or syslog collector.

The script creates:

FilePurpose
/var/log/named/dns_queries.logDNS query events.
/var/log/named/dns_general.logBIND client, config, default, and security events.
/etc/rsyslog.d/60-bind-splunk.confrsyslog file input and forwarding rules.

BIND Logging Configuration

The forwarding script creates a BIND logging include file.

RHEL-family path:

/etc/named.splunk-logging.conf

Debian-family path:

/etc/bind/named.conf.splunk-logging

The generated logging block uses print-time, print-category, and print-severity so Splunk receives useful context.

logging {
        channel splunk_dns_queries {
                file "/var/log/named/dns_queries.log" versions 5 size 20m;
                severity info;
                print-time yes;
                print-category yes;
                print-severity yes;
        };

        channel splunk_dns_general {
                file "/var/log/named/dns_general.log" versions 5 size 20m;
                severity info;
                print-time yes;
                print-category yes;
                print-severity yes;
        };

        category queries { splunk_dns_queries; };
        category client { splunk_dns_general; };
        category config { splunk_dns_general; };
        category default { splunk_dns_general; };
        category security { splunk_dns_general; };
};

rsyslog Forwarding Configuration

The generated rsyslog configuration tails both BIND log files and forwards them to Splunk.

module(load="imfile" PollingInterval="10")

input(type="imfile"
      File="/var/log/named/dns_queries.log"
      Tag="named:"
      Facility="local7"
      Severity="info"
      PersistStateInterval="100")

input(type="imfile"
      File="/var/log/named/dns_general.log"
      Tag="named:"
      Facility="local7"
      Severity="info"
      PersistStateInterval="100")

action(type="omfwd"
       Target="10.10.10.50"
       Port="514"
       Protocol="tcp"
       Template="RSYSLOG_SyslogProtocol23Format"
       Queue.Type="LinkedList"
       Queue.FileName="bind_splunk_tcp"
       Queue.SaveOnShutdown="on"
       Action.ResumeRetryCount="-1")

The queue settings help rsyslog survive temporary Splunk or network outages without immediately dropping events.

Splunk Receiver Setup

On Splunk, create a TCP or UDP data input that matches the selected protocol and port.

Recommended metadata:

SettingValue
Indexdns or network
Sourcetypeisc:bind when using the ISC BIND TA, otherwise syslog or bind:dns
HostDNS server hostname from syslog

If using Splunk Connect for Syslog and the Splunk Add-on for ISC BIND, route events into the TA sourcetypes:

BIND categorySplunk sourcetype
queriesisc:bind:query
query-errorsisc:bind:queryerror
lame-serversisc:bind:lameserver
notify, xfer-in, xfer-out, transferisc:bind:transfer
networkisc:bind:network
other named eventsisc:bind

The parser assets are in the Parser/ directory:

sudo cp Parser/app-isc_bind_ta.conf /opt/sc4s/local/config/app_parsers/syslog/app-isc_bind_ta.conf
sudo systemctl restart sc4s

For SC4S sourcetype correction after built-in routing, install the rewriter:

sudo mkdir -p /opt/sc4s/local/config/app_parsers/rewriters
sudo cp Parser/app-dest-rewrite-isc_bind_sourcetype.conf \
  /opt/sc4s/local/config/app_parsers/rewriters/app-dest-rewrite-isc_bind_sourcetype.conf
sudo systemctl restart sc4s

End-To-End Validation

Validate BIND:

sudo named-checkconf
sudo named-checkzone example.local /var/named/example.local.zone
sudo systemctl status named

Validate rsyslog:

sudo rsyslogd -N1
sudo systemctl status rsyslog

Generate DNS activity:

dig @172.19.4.155 example.local A
dig @172.19.4.155 www.example.local CNAME
dig @172.19.4.155 mail.example.local A

Confirm local logs:

sudo tail -f /var/log/named/dns_queries.log
sudo tail -f /var/log/named/dns_general.log

Search in Splunk:

index=dns host=Blink
| stats count by sourcetype

For ISC BIND TA validation:

sourcetype=isc:bind:*
| table _time sourcetype src src_port query record_type record_class response_code reply_code dest dest_port vendor_action action severity

Troubleshooting

If BIND fails with missing ';' before 'localhost', verify ACL values are rendered as BIND lists:

allow-recursion { localnets; localhost; };

This is invalid:

allow-recursion { localnets localhost; };

If Splunk does not receive events, verify network reachability:

nc -vz 10.10.10.50 514

For UDP, use tcpdump on the DNS server or the collector:

sudo tcpdump -nn host 10.10.10.50 and port 514

If BIND cannot write logs on RHEL-family systems with SELinux enabled, restore the log context:

sudo restorecon -Rv /var/log/named

If semanage is available, persist the context:

sudo semanage fcontext -a -t named_log_t '/var/log/named(/.*)?'
sudo restorecon -Rv /var/log/named

Production Hardening

Avoid open recursion on internet-facing DNS servers. Restrict these values to trusted subnets:

allow-query { 172.19.0.0/20; localhost; };
allow-recursion { 172.19.0.0/20; localhost; };

Keep zone transfers disabled unless secondary DNS servers are explicitly configured:

allow-transfer { none; };
allow-update { none; };

Use TCP forwarding for rsyslog when possible. UDP is simple, but TCP is easier to verify and less likely to lose events during bursts.

Rollback

The scripts create timestamped backups before replacing BIND and rsyslog files. Restore the previous file and restart services:

sudo cp /etc/named.conf.bak.YYYYMMDDHHMMSS /etc/named.conf
sudo systemctl restart named
sudo systemctl restart rsyslog

On Debian-family systems, replace named with bind9.

Complete Code Appendix

The following code blocks make the article standalone. Save each block with the shown file name in the same working directory, then run the commands from the previous sections.

BIND Installer: install_dns_server.sh

#!/usr/bin/env bash
set -euo pipefail

log() {
  printf '[INFO] %s\n' "$*"
}

warn() {
  printf '[WARN] %s\n' "$*" >&2
}

die() {
  printf '[ERROR] %s\n' "$*" >&2
  exit 1
}

usage() {
  cat <<'USAGE'
Usage:
  sudo ./install_dns_server.sh

Installs BIND DNS server packages on Debian-family or RHEL-family Linux,
enables the DNS service, opens TCP/UDP 53 when firewalld or ufw is active,
and validates the base BIND configuration.

Options:
  -h, --help    Show help.
USAGE
}

parse_args() {
  while [ "$#" -gt 0 ]; do
    case "$1" in
      -h|--help)
        usage; exit 0 ;;
      *)
        die "Unknown option: $1" ;;
    esac
  done
}

require_root() {
  if [ "${EUID:-$(id -u)}" -ne 0 ]; then
    die "Run this script as root or with sudo."
  fi
}

detect_os() {
  if [ ! -r /etc/os-release ]; then
    die "Cannot detect Linux distribution because /etc/os-release is missing."
  fi

  # shellcheck disable=SC1091
  . /etc/os-release
  OS_ID="${ID:-unknown}"
  OS_LIKE="${ID_LIKE:-}"

  case "$OS_ID $OS_LIKE" in
    *debian*|*ubuntu*)
      FAMILY="debian"
      DNS_SERVICE="bind9"
      DNS_PACKAGES=(bind9 bind9-utils dnsutils)
      ;;
    *rhel*|*fedora*|*centos*|*rocky*|*almalinux*|*ol*)
      FAMILY="rhel"
      DNS_SERVICE="named"
      DNS_PACKAGES=(bind bind-utils)
      ;;
    *)
      die "Unsupported distribution: ${PRETTY_NAME:-$OS_ID}. Supported families: Debian/Ubuntu and RHEL/CentOS/Rocky/AlmaLinux/Oracle Linux."
      ;;
  esac
}

install_packages() {
  case "$FAMILY" in
    debian)
      log "Installing BIND packages with apt."
      apt-get update
      DEBIAN_FRONTEND=noninteractive apt-get install -y "${DNS_PACKAGES[@]}"
      ;;
    rhel)
      if command -v dnf >/dev/null 2>&1; then
        log "Installing BIND packages with dnf."
        dnf install -y "${DNS_PACKAGES[@]}"
      elif command -v yum >/dev/null 2>&1; then
        log "Installing BIND packages with yum."
        yum install -y "${DNS_PACKAGES[@]}"
      else
        die "Neither dnf nor yum was found."
      fi
      ;;
  esac
}

enable_service() {
  log "Enabling and starting ${DNS_SERVICE}."
  systemctl enable --now "$DNS_SERVICE"
}

configure_firewall() {
  if command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld; then
    log "Opening DNS service in firewalld."
    firewall-cmd --permanent --add-service=dns
    firewall-cmd --reload
  elif command -v ufw >/dev/null 2>&1 && ufw status | grep -qi '^Status: active'; then
    log "Opening DNS service in ufw."
    ufw allow 53/tcp
    ufw allow 53/udp
  else
    warn "No active firewalld/ufw firewall detected. Open TCP/UDP 53 manually if another firewall is used."
  fi
}

verify_installation() {
  log "Checking named configuration syntax."
  named-checkconf

  log "Service status:"
  systemctl --no-pager --full status "$DNS_SERVICE" || true
}

main() {
  parse_args "$@"
  require_root
  detect_os
  log "Detected ${FAMILY} family. DNS service will be ${DNS_SERVICE}."
  install_packages
  enable_service
  configure_firewall
  verify_installation
  log "DNS server installation completed."
}

main "$@"

BIND Zone Configurator: configure_dns_server.sh

#!/usr/bin/env bash
set -euo pipefail

ZONE_NAME=""
SERVER_IP=""
FORWARDERS="1.1.1.1 8.8.8.8"
ALLOW_QUERY="any"
ALLOW_RECURSION="localnets localhost"
RECORDS_FILE=""
DRY_RUN="false"

log() {
  printf '[INFO] %s\n' "$*"
}

die() {
  printf '[ERROR] %s\n' "$*" >&2
  exit 1
}

usage() {
  cat <<'USAGE'
Usage:
  sudo ./configure_dns_server.sh --zone example.local --server-ip 192.168.10.10 --records records.csv

Options:
  --zone NAME              DNS zone name to create, for example example.local.
  --server-ip IP           IPv4 address of this DNS server.
  --records FILE           CSV records file. Format: name,type,value,ttl,priority
  --forwarders "IP IP"     Upstream DNS forwarders. Default: "1.1.1.1 8.8.8.8"
  --allow-query ACL        BIND allow-query ACL. Default: any
  --allow-recursion ACL    BIND allow-recursion ACL. Default: localnets localhost
  --dry-run                Print detected settings and generated files without writing.
  -h, --help               Show help.

CSV examples:
  @,A,192.168.10.20,3600,
  www,CNAME,@,3600,
  mail,A,192.168.10.30,3600,
  @,MX,mail.example.local.,3600,10
  txttest,TXT,"hello world",3600,
USAGE
}

require_root() {
  if [ "$DRY_RUN" = "false" ] && [ "${EUID:-$(id -u)}" -ne 0 ]; then
    die "Run this script as root or with sudo. Use --dry-run to preview without root."
  fi
}

parse_args() {
  while [ "$#" -gt 0 ]; do
    case "$1" in
      --zone)
        ZONE_NAME="${2:-}"; shift 2 ;;
      --server-ip)
        SERVER_IP="${2:-}"; shift 2 ;;
      --records)
        RECORDS_FILE="${2:-}"; shift 2 ;;
      --forwarders)
        FORWARDERS="${2:-}"; shift 2 ;;
      --allow-query)
        ALLOW_QUERY="${2:-}"; shift 2 ;;
      --allow-recursion)
        ALLOW_RECURSION="${2:-}"; shift 2 ;;
      --dry-run)
        DRY_RUN="true"; shift ;;
      -h|--help)
        usage; exit 0 ;;
      *)
        die "Unknown option: $1" ;;
    esac
  done
}

validate_args() {
  [ -n "$ZONE_NAME" ] || die "--zone is required."
  [ -n "$SERVER_IP" ] || die "--server-ip is required."
  [[ "$SERVER_IP" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || die "--server-ip must be an IPv4 address."

  if [ -n "$RECORDS_FILE" ] && [ ! -r "$RECORDS_FILE" ]; then
    die "Records file is not readable: $RECORDS_FILE"
  fi
}

detect_os() {
  if [ ! -r /etc/os-release ]; then
    die "Cannot detect Linux distribution because /etc/os-release is missing."
  fi

  # shellcheck disable=SC1091
  . /etc/os-release
  OS_ID="${ID:-unknown}"
  OS_LIKE="${ID_LIKE:-}"

  case "$OS_ID $OS_LIKE" in
    *debian*|*ubuntu*)
      FAMILY="debian"
      DNS_SERVICE="bind9"
      CONF_DIR="/etc/bind"
      ZONE_DIR="/etc/bind/zones"
      OPTIONS_FILE="/etc/bind/named.conf.options"
      LOCAL_ZONES_FILE="/etc/bind/named.conf.local"
      ZONE_FILE="${ZONE_DIR}/db.${ZONE_NAME}"
      ZONE_OWNER="bind:bind"
      ;;
    *rhel*|*fedora*|*centos*|*rocky*|*almalinux*|*ol*)
      FAMILY="rhel"
      DNS_SERVICE="named"
      CONF_DIR="/etc"
      ZONE_DIR="/var/named"
      OPTIONS_FILE="/etc/named.conf"
      LOCAL_ZONES_FILE="/etc/named.rfc1912.zones"
      ZONE_FILE="${ZONE_DIR}/${ZONE_NAME}.zone"
      ZONE_OWNER="root:named"
      ;;
    *)
      die "Unsupported distribution. Supported families: Debian/Ubuntu and RHEL/CentOS/Rocky/AlmaLinux/Oracle Linux."
      ;;
  esac
}

backup_file() {
  local file="$1"
  if [ -f "$file" ]; then
    cp -a "$file" "${file}.bak.$(date +%Y%m%d%H%M%S)"
  fi
}

format_bind_list() {
  local input="$1"
  local output=""
  local item
  input="${input//;/ }"
  for item in $input; do
    output="${output} ${item};"
  done
  printf '%s' "$output"
}

write_options() {
  local forwarder_block allow_query_block allow_recursion_block
  forwarder_block="$(format_bind_list "$FORWARDERS")"
  allow_query_block="$(format_bind_list "$ALLOW_QUERY")"
  allow_recursion_block="$(format_bind_list "$ALLOW_RECURSION")"

  if [ "$DRY_RUN" = "true" ]; then
    log "Would write BIND options to ${OPTIONS_FILE}."
    return
  fi

  backup_file "$OPTIONS_FILE"

  case "$FAMILY" in
    debian)
      cat >"$OPTIONS_FILE" <<EOF
options {
        directory "/var/cache/bind";

        recursion yes;
        allow-query {${allow_query_block} };
        allow-recursion {${allow_recursion_block} };
        forwarders {${forwarder_block} };

        dnssec-validation auto;
        listen-on { any; };
        listen-on-v6 { any; };
};
EOF
      ;;
    rhel)
      cat >"$OPTIONS_FILE" <<EOF
options {
        listen-on port 53 { any; };
        listen-on-v6 port 53 { any; };
        directory       "/var/named";
        dump-file       "/var/named/data/cache_dump.db";
        statistics-file "/var/named/data/named_stats.txt";
        memstatistics-file "/var/named/data/named_mem_stats.txt";
        secroots-file   "/var/named/data/named.secroots";
        recursing-file  "/var/named/data/named.recursing";

        recursion yes;
        allow-query {${allow_query_block} };
        allow-recursion {${allow_recursion_block} };
        forwarders {${forwarder_block} };

        dnssec-validation yes;
};

logging {
        channel default_debug {
                file "data/named.run";
                severity dynamic;
        };
};

zone "." IN {
        type hint;
        file "named.ca";
};

include "/etc/named.rfc1912.zones";
include "/etc/named.root.key";
EOF
      ;;
  esac
}

ensure_fqdn() {
  local value="$1"
  if [ "$value" = "@" ]; then
    printf '%s.' "$ZONE_NAME"
  elif [[ "$value" == *"." ]]; then
    printf '%s' "$value"
  else
    printf '%s.%s.' "$value" "$ZONE_NAME"
  fi
}

generate_zone_records() {
  printf '$TTL 3600\n'
  printf '@ IN SOA ns1.%s. admin.%s. (\n' "$ZONE_NAME" "$ZONE_NAME"
  printf '        %s ; serial\n' "$(date +%Y%m%d01)"
  printf '        3600       ; refresh\n'
  printf '        1800       ; retry\n'
  printf '        604800     ; expire\n'
  printf '        86400 )    ; minimum\n\n'
  printf '@       IN NS   ns1.%s.\n' "$ZONE_NAME"
  printf 'ns1     IN A    %s\n' "$SERVER_IP"

  if [ -z "$RECORDS_FILE" ]; then
    return
  fi

  while IFS=, read -r name type value ttl priority extra; do
    name="$(printf '%s' "$name" | xargs)"
    type="$(printf '%s' "$type" | tr '[:lower:]' '[:upper:]' | xargs)"
    value="$(printf '%s' "$value" | sed 's/^"//; s/"$//' | xargs)"
    ttl="$(printf '%s' "${ttl:-3600}" | xargs)"
    priority="$(printf '%s' "${priority:-}" | xargs)"

    [ -n "$name" ] || continue
    [[ "$name" =~ ^# ]] && continue
    [ "$name" = "name" ] && [ "$type" = "TYPE" ] && continue

    case "$type" in
      A|AAAA)
        printf '%-20s %s IN %-5s %s\n' "$name" "$ttl" "$type" "$value"
        ;;
      CNAME|NS)
        printf '%-20s %s IN %-5s %s\n' "$name" "$ttl" "$type" "$(ensure_fqdn "$value")"
        ;;
      MX)
        [ -n "$priority" ] || priority="10"
        printf '%-20s %s IN MX    %s %s\n' "$name" "$ttl" "$priority" "$(ensure_fqdn "$value")"
        ;;
      TXT)
        printf '%-20s %s IN TXT   "%s"\n' "$name" "$ttl" "$value"
        ;;
      *)
        die "Unsupported DNS record type in ${RECORDS_FILE}: ${type}"
        ;;
    esac
  done <"$RECORDS_FILE"
}

write_zone() {
  if [ "$DRY_RUN" = "true" ]; then
    log "Generated zone file for ${ZONE_NAME}:"
    generate_zone_records
    return
  fi

  mkdir -p "$ZONE_DIR"
  generate_zone_records >"$ZONE_FILE"
  chown "$ZONE_OWNER" "$ZONE_FILE" 2>/dev/null || true
  chmod 0640 "$ZONE_FILE"
}

write_zone_include() {
  local zone_block
  zone_block=$(cat <<EOF
zone "${ZONE_NAME}" IN {
        type master;
        file "${ZONE_FILE}";
        allow-update { none; };
};
EOF
)

  if [ "$DRY_RUN" = "true" ]; then
    log "Would append this zone block to ${LOCAL_ZONES_FILE}:"
    printf '%s\n' "$zone_block"
    return
  fi

  touch "$LOCAL_ZONES_FILE"
  backup_file "$LOCAL_ZONES_FILE"

  if grep -q "zone \"${ZONE_NAME}\"" "$LOCAL_ZONES_FILE"; then
    log "Zone ${ZONE_NAME} already exists in ${LOCAL_ZONES_FILE}; leaving existing block in place."
  else
    printf '\n%s\n' "$zone_block" >>"$LOCAL_ZONES_FILE"
  fi
}

verify_and_reload() {
  if [ "$DRY_RUN" = "true" ]; then
    return
  fi

  log "Validating BIND configuration."
  named-checkconf
  named-checkzone "$ZONE_NAME" "$ZONE_FILE"

  log "Restarting ${DNS_SERVICE}."
  systemctl restart "$DNS_SERVICE"
  systemctl --no-pager --full status "$DNS_SERVICE" || true
}

main() {
  parse_args "$@"
  require_root
  validate_args
  detect_os

  log "Detected ${FAMILY} family."
  log "Configuring zone ${ZONE_NAME} on ${SERVER_IP}."
  write_options
  write_zone
  write_zone_include
  verify_and_reload
  log "DNS server configuration completed."
}

main "$@"

Bulk DNS Tester: bulk_dns_test.sh

#!/usr/bin/env bash
set -euo pipefail

DNS_SERVER=""
QUERY_FILE=""
DEFAULT_TYPE="A"
TIMEOUT="3"
OUTPUT_FILE="dns_bulk_test_results.csv"

log() {
  printf '[INFO] %s\n' "$*"
}

die() {
  printf '[ERROR] %s\n' "$*" >&2
  exit 1
}

usage() {
  cat <<'USAGE'
Usage:
  ./bulk_dns_test.sh --server 192.168.10.10 --file queries.txt --output report.csv

Options:
  --server IP_OR_NAME       DNS server to query.
  --file FILE              Query file. One query per line: name[,type]
  --type TYPE              Default record type when not provided in file. Default: A
  --timeout SECONDS        Per-query timeout. Default: 3
  --output FILE            CSV output file. Default: dns_bulk_test_results.csv
  -h, --help               Show help.

Query file examples:
  example.local,A
  www.example.local,CNAME
  mail.example.local,MX
  192.168.10.20
USAGE
}

parse_args() {
  while [ "$#" -gt 0 ]; do
    case "$1" in
      --server)
        DNS_SERVER="${2:-}"; shift 2 ;;
      --file)
        QUERY_FILE="${2:-}"; shift 2 ;;
      --type)
        DEFAULT_TYPE="${2:-}"; shift 2 ;;
      --timeout)
        TIMEOUT="${2:-}"; shift 2 ;;
      --output)
        OUTPUT_FILE="${2:-}"; shift 2 ;;
      -h|--help)
        usage; exit 0 ;;
      *)
        die "Unknown option: $1" ;;
    esac
  done
}

validate_args() {
  [ -n "$DNS_SERVER" ] || die "--server is required."
  [ -n "$QUERY_FILE" ] || die "--file is required."
  [ -r "$QUERY_FILE" ] || die "Query file is not readable: $QUERY_FILE"

  if ! command -v dig >/dev/null 2>&1; then
    die "dig is required. Install dnsutils on Debian/Ubuntu or bind-utils on RHEL family systems."
  fi
}

csv_escape() {
  local value="${1:-}"
  value="${value//\"/\"\"}"
  printf '"%s"' "$value"
}

run_query() {
  local query_name="$1"
  local query_type="$2"
  local start_epoch end_epoch elapsed_ms status answer rcode

  start_epoch="$(date +%s%3N 2>/dev/null || date +%s000)"
  set +e
  answer="$(dig @"$DNS_SERVER" "$query_name" "$query_type" +time="$TIMEOUT" +tries=1 +noall +answer 2>&1)"
  rcode="$(dig @"$DNS_SERVER" "$query_name" "$query_type" +time="$TIMEOUT" +tries=1 +noall +comments 2>/dev/null | awk -F'[:,]' '/HEADER/ {gsub(/ /, "", $2); print $2; exit}')"
  set -e
  end_epoch="$(date +%s%3N 2>/dev/null || date +%s000)"
  elapsed_ms=$((end_epoch - start_epoch))

  if printf '%s\n' "$answer" | grep -qi 'connection timed out\|no servers could be reached'; then
    status="TIMEOUT"
  elif [ -n "$answer" ] && [ "${rcode:-NOERROR}" = "NOERROR" ]; then
    status="PASS"
  elif [ "${rcode:-}" = "NXDOMAIN" ]; then
    status="NXDOMAIN"
  elif [ -n "${rcode:-}" ]; then
    status="$rcode"
  else
    status="NOANSWER"
  fi

  {
    csv_escape "$query_name"; printf ','
    csv_escape "$query_type"; printf ','
    csv_escape "$status"; printf ','
    csv_escape "$elapsed_ms"; printf ','
    csv_escape "$rcode"; printf ','
    csv_escape "$answer"; printf '\n'
  } >>"$OUTPUT_FILE"
}

main() {
  local total=0 pass=0 fail=0 line query_name query_type status

  parse_args "$@"
  validate_args

  printf 'query,type,status,elapsed_ms,rcode,answer\n' >"$OUTPUT_FILE"

  while IFS= read -r line || [ -n "$line" ]; do
    line="$(printf '%s' "$line" | xargs)"
    [ -n "$line" ] || continue
    [[ "$line" =~ ^# ]] && continue

    query_name="$(printf '%s' "$line" | cut -d',' -f1 | xargs)"
    query_type="$(printf '%s' "$line" | cut -s -d',' -f2 | xargs)"
    query_type="${query_type:-$DEFAULT_TYPE}"

    run_query "$query_name" "$query_type"
    status="$(tail -n 1 "$OUTPUT_FILE" | awk -F',' '{gsub(/"/, "", $3); print $3}')"

    total=$((total + 1))
    if [ "$status" = "PASS" ]; then
      pass=$((pass + 1))
    else
      fail=$((fail + 1))
    fi
  done <"$QUERY_FILE"

  log "Bulk DNS test completed."
  log "Total: ${total}, Pass: ${pass}, Non-pass: ${fail}"
  log "Report: ${OUTPUT_FILE}"
}

main "$@"

Splunk rsyslog Forwarder: configure_dns_splunk_rsyslog.sh

#!/usr/bin/env bash
set -euo pipefail

SPLUNK_HOST=""
SPLUNK_PORT="514"
PROTOCOL="tcp"
BIND_LOG_DIR="/var/log/named"
QUERY_LOG="dns_queries.log"
GENERAL_LOG="dns_general.log"
DRY_RUN="false"

log() {
  printf '[INFO] %s\n' "$*"
}

warn() {
  printf '[WARN] %s\n' "$*" >&2
}

die() {
  printf '[ERROR] %s\n' "$*" >&2
  exit 1
}

usage() {
  cat <<'USAGE'
Usage:
  sudo ./configure_dns_splunk_rsyslog.sh --splunk-host 10.10.10.50 --splunk-port 514 --protocol tcp

Options:
  --splunk-host HOST       Splunk indexer, heavy forwarder, or syslog receiver IP/FQDN.
  --splunk-port PORT       Splunk/syslog listener port. Default: 514
  --protocol tcp|udp       Forwarding protocol. Default: tcp
  --bind-log-dir DIR       BIND log directory. Default: /var/log/named
  --dry-run                Show actions without writing files.
  -h, --help               Show help.

Splunk side requirement:
  Configure Splunk to receive syslog on the selected TCP/UDP port, or point this
  script at an intermediate syslog collector that forwards into Splunk.
USAGE
}

parse_args() {
  while [ "$#" -gt 0 ]; do
    case "$1" in
      --splunk-host)
        SPLUNK_HOST="${2:-}"; shift 2 ;;
      --splunk-port)
        SPLUNK_PORT="${2:-}"; shift 2 ;;
      --protocol)
        PROTOCOL="${2:-}"; shift 2 ;;
      --bind-log-dir)
        BIND_LOG_DIR="${2:-}"; shift 2 ;;
      --dry-run)
        DRY_RUN="true"; shift ;;
      -h|--help)
        usage; exit 0 ;;
      *)
        die "Unknown option: $1" ;;
    esac
  done
}

require_root() {
  if [ "$DRY_RUN" = "false" ] && [ "${EUID:-$(id -u)}" -ne 0 ]; then
    die "Run this script as root or with sudo. Use --dry-run to preview without root."
  fi
}

validate_args() {
  [ -n "$SPLUNK_HOST" ] || die "--splunk-host is required."
  [[ "$SPLUNK_PORT" =~ ^[0-9]+$ ]] || die "--splunk-port must be numeric."

  case "$PROTOCOL" in
    tcp|udp) ;;
    *) die "--protocol must be tcp or udp." ;;
  esac
}

detect_os() {
  if [ ! -r /etc/os-release ]; then
    die "Cannot detect Linux distribution because /etc/os-release is missing."
  fi

  # shellcheck disable=SC1091
  . /etc/os-release
  OS_ID="${ID:-unknown}"
  OS_LIKE="${ID_LIKE:-}"

  case "$OS_ID $OS_LIKE" in
    *debian*|*ubuntu*)
      FAMILY="debian"
      DNS_SERVICE="bind9"
      NAMED_CONF="/etc/bind/named.conf"
      BIND_LOGGING_CONF="/etc/bind/named.conf.splunk-logging"
      BIND_USER="bind"
      BIND_GROUP="bind"
      RSYSLOG_PACKAGE="rsyslog"
      ;;
    *rhel*|*fedora*|*centos*|*rocky*|*almalinux*|*ol*)
      FAMILY="rhel"
      DNS_SERVICE="named"
      NAMED_CONF="/etc/named.conf"
      BIND_LOGGING_CONF="/etc/named.splunk-logging.conf"
      BIND_USER="named"
      BIND_GROUP="named"
      RSYSLOG_PACKAGE="rsyslog"
      ;;
    *)
      die "Unsupported distribution. Supported families: Debian/Ubuntu and RHEL/CentOS/Rocky/AlmaLinux/Oracle Linux."
      ;;
  esac
}

backup_file() {
  local file="$1"
  if [ -f "$file" ]; then
    cp -a "$file" "${file}.bak.$(date +%Y%m%d%H%M%S)"
  fi
}

install_rsyslog() {
  if command -v rsyslogd >/dev/null 2>&1; then
    log "rsyslog is already installed."
    return
  fi

  case "$FAMILY" in
    debian)
      log "Installing rsyslog with apt."
      apt-get update
      DEBIAN_FRONTEND=noninteractive apt-get install -y "$RSYSLOG_PACKAGE"
      ;;
    rhel)
      if command -v dnf >/dev/null 2>&1; then
        log "Installing rsyslog with dnf."
        dnf install -y "$RSYSLOG_PACKAGE"
      elif command -v yum >/dev/null 2>&1; then
        log "Installing rsyslog with yum."
        yum install -y "$RSYSLOG_PACKAGE"
      else
        die "Neither dnf nor yum was found."
      fi
      ;;
  esac
}

prepare_bind_log_files() {
  if [ "$DRY_RUN" = "true" ]; then
    log "Would create ${BIND_LOG_DIR}/${QUERY_LOG} and ${BIND_LOG_DIR}/${GENERAL_LOG}."
    return
  fi

  mkdir -p "$BIND_LOG_DIR"
  touch "${BIND_LOG_DIR}/${QUERY_LOG}" "${BIND_LOG_DIR}/${GENERAL_LOG}"
  chown -R "${BIND_USER}:${BIND_GROUP}" "$BIND_LOG_DIR" 2>/dev/null || true
  chmod 0750 "$BIND_LOG_DIR"
  chmod 0640 "${BIND_LOG_DIR}/${QUERY_LOG}" "${BIND_LOG_DIR}/${GENERAL_LOG}"

  if command -v semanage >/dev/null 2>&1; then
    semanage fcontext -a -t named_log_t "${BIND_LOG_DIR}(/.*)?" 2>/dev/null || true
    restorecon -Rv "$BIND_LOG_DIR" >/dev/null 2>&1 || true
  elif command -v chcon >/dev/null 2>&1 && [ "$FAMILY" = "rhel" ]; then
    chcon -R -t named_log_t "$BIND_LOG_DIR" 2>/dev/null || true
  fi
}

strip_existing_logging_block() {
  local source_file="$1"
  local temp_file
  temp_file="$(mktemp)"

  awk '
    /^[[:space:]]*logging[[:space:]]*\{/ {
      skip=1
      depth=0
    }
    skip {
      depth += gsub(/\{/, "{")
      depth -= gsub(/\}/, "}")
      if (depth <= 0 && /};/) {
        skip=0
      }
      next
    }
    { print }
  ' "$source_file" >"$temp_file"

  cat "$temp_file" >"$source_file"
  rm -f "$temp_file"
}

ensure_bind_include() {
  if grep -qF "include \"${BIND_LOGGING_CONF}\";" "$NAMED_CONF"; then
    return
  fi
  printf '\ninclude "%s";\n' "$BIND_LOGGING_CONF" >>"$NAMED_CONF"
}

write_bind_logging_config() {
  if [ "$DRY_RUN" = "true" ]; then
    log "Would write BIND logging config to ${BIND_LOGGING_CONF} and include it from ${NAMED_CONF}."
    return
  fi

  [ -f "$NAMED_CONF" ] || die "BIND main configuration not found: $NAMED_CONF"
  backup_file "$NAMED_CONF"
  backup_file "$BIND_LOGGING_CONF"

  strip_existing_logging_block "$NAMED_CONF"

  cat >"$BIND_LOGGING_CONF" <<EOF
logging {
        channel splunk_dns_queries {
                file "${BIND_LOG_DIR}/${QUERY_LOG}" versions 5 size 20m;
                severity info;
                print-time yes;
                print-category yes;
                print-severity yes;
        };

        channel splunk_dns_general {
                file "${BIND_LOG_DIR}/${GENERAL_LOG}" versions 5 size 20m;
                severity info;
                print-time yes;
                print-category yes;
                print-severity yes;
        };

        category queries { splunk_dns_queries; };
        category client { splunk_dns_general; };
        category config { splunk_dns_general; };
        category default { splunk_dns_general; };
        category security { splunk_dns_general; };
};
EOF

  ensure_bind_include
}

write_rsyslog_config() {
  local protocol_line queue_name
  queue_name="bind_splunk_${PROTOCOL}"

  if [ "$PROTOCOL" = "tcp" ]; then
    protocol_line='Protocol="tcp"'
  else
    protocol_line='Protocol="udp"'
  fi

  if [ "$DRY_RUN" = "true" ]; then
    log "Would write rsyslog forwarding config to /etc/rsyslog.d/60-bind-splunk.conf."
    return
  fi

  backup_file /etc/rsyslog.d/60-bind-splunk.conf

  cat >/etc/rsyslog.d/60-bind-splunk.conf <<EOF
module(load="imfile" PollingInterval="10")

input(type="imfile"
      File="${BIND_LOG_DIR}/${QUERY_LOG}"
      Tag="named:"
      Facility="local7"
      Severity="info"
      PersistStateInterval="100")

input(type="imfile"
      File="${BIND_LOG_DIR}/${GENERAL_LOG}"
      Tag="named:"
      Facility="local7"
      Severity="info"
      PersistStateInterval="100")

action(type="omfwd"
       Target="${SPLUNK_HOST}"
       Port="${SPLUNK_PORT}"
       ${protocol_line}
       Template="RSYSLOG_SyslogProtocol23Format"
       Queue.Type="LinkedList"
       Queue.FileName="${queue_name}"
       Queue.SaveOnShutdown="on"
       Action.ResumeRetryCount="-1")
EOF
}

verify_and_restart() {
  if [ "$DRY_RUN" = "true" ]; then
    return
  fi

  log "Validating BIND configuration."
  named-checkconf

  log "Validating rsyslog configuration."
  rsyslogd -N1

  log "Restarting ${DNS_SERVICE} and rsyslog."
  systemctl restart "$DNS_SERVICE"
  systemctl enable --now rsyslog
  systemctl restart rsyslog

  log "Recent DNS service status:"
  systemctl --no-pager --full status "$DNS_SERVICE" || true

  log "Recent rsyslog status:"
  systemctl --no-pager --full status rsyslog || true
}

main() {
  parse_args "$@"
  require_root
  validate_args
  detect_os

  log "Detected ${FAMILY} family."
  log "Forwarding BIND logs to ${SPLUNK_HOST}:${SPLUNK_PORT}/${PROTOCOL}."
  install_rsyslog
  prepare_bind_log_files
  write_bind_logging_config
  write_rsyslog_config
  verify_and_restart
  log "DNS log forwarding configuration completed."
}

main "$@"

Sample DNS Records: sample_records.csv

name,type,value,ttl,priority
@,A,192.168.10.20,3600,
www,CNAME,@,3600,
mail,A,192.168.10.30,3600,
@,MX,mail.example.local.,3600,10
txttest,TXT,hello world,3600,

Sample Bulk Test Queries: sample_queries.txt

example.local,A
www.example.local,CNAME
mail.example.local,A
example.local,MX
txttest.example.local,TXT

Summary

This workflow turns DNS deployment into a repeatable SIEM-friendly build: BIND is installed, zones are generated from CSV, records are tested in bulk, and query activity is forwarded to Splunk through rsyslog. The same pattern works for a small lab, a technical writeup, or a controlled enterprise rollout where DNS telemetry needs to land in Splunk with predictable metadata.