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:
| Script | Purpose |
|---|---|
install_dns_server.sh | Installs BIND and DNS utilities on Debian or RHEL-family Linux. |
configure_dns_server.sh | Creates BIND options, a forward zone, and DNS records from CSV. |
bulk_dns_test.sh | Runs multiple dig lookups and writes a CSV test report. |
configure_dns_splunk_rsyslog.sh | Enables 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
Platform Mapping
The scripts detect the distribution family from /etc/os-release and use the correct package names, service names, and configuration paths.
| Distribution family | Packages | Service | Main config | Zone path |
|---|---|---|---|---|
| Debian/Ubuntu | bind9 bind9-utils dnsutils | bind9 | /etc/bind/named.conf.options | /etc/bind/zones |
| RHEL/CentOS/Rocky/Alma/Fedora | bind bind-utils | named | /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.ps1Make 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.shThe 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 ASuccessful answers should include:
status: NOERROR
flags: qr aa
SERVER: 172.19.4.155#53The 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,TXTRun the bulk test:
./bulk_dns_test.sh \
--server 172.19.4.155 \
--file sample_queries.txt \
--output dns_test_report.csvThe 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 tcpReplace 10.10.10.50 with the Splunk indexer, heavy forwarder, or syslog collector.
The script creates:
| File | Purpose |
|---|---|
/var/log/named/dns_queries.log | DNS query events. |
/var/log/named/dns_general.log | BIND client, config, default, and security events. |
/etc/rsyslog.d/60-bind-splunk.conf | rsyslog file input and forwarding rules. |
BIND Logging Configuration
The forwarding script creates a BIND logging include file.
RHEL-family path:
/etc/named.splunk-logging.confDebian-family path:
/etc/bind/named.conf.splunk-loggingThe 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:
| Setting | Value |
|---|---|
| Index | dns or network |
| Sourcetype | isc:bind when using the ISC BIND TA, otherwise syslog or bind:dns |
| Host | DNS 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 category | Splunk sourcetype |
|---|---|
queries | isc:bind:query |
query-errors | isc:bind:queryerror |
lame-servers | isc:bind:lameserver |
notify, xfer-in, xfer-out, transfer | isc:bind:transfer |
network | isc:bind:network |
other named events | isc: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 sc4sFor 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 namedValidate rsyslog:
sudo rsyslogd -N1
sudo systemctl status rsyslogGenerate 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 AConfirm local logs:
sudo tail -f /var/log/named/dns_queries.log
sudo tail -f /var/log/named/dns_general.logSearch in Splunk:
index=dns host=Blink
| stats count by sourcetypeFor 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 514For UDP, use tcpdump on the DNS server or the collector:
sudo tcpdump -nn host 10.10.10.50 and port 514If BIND cannot write logs on RHEL-family systems with SELinux enabled, restore the log context:
sudo restorecon -Rv /var/log/namedIf 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 rsyslogOn 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.