Key takeaways
- Configure an authoritative BIND zone dedicated to controlled DNS TXT status messages.
- Publish a short authorized status value with nsupdate and TSIG authentication.
- Retrieve the TXT value from an approved remote device with dig against the DNS server.
- Forward BIND update/query logs and endpoint audit logs into Splunk through rsyslog.
Build A Controlled DNS TXT Messaging Workflow
DNS is usually treated as infrastructure plumbing, but TXT records can also carry small operational status values. This guide builds an authorized lab workflow where a trusted sender publishes a short TXT value and an approved remote device retrieves it through a normal DNS query.
The design is intentionally narrow:
- It supports one TXT value up to 200 characters.
- It requires TSIG authentication for updates.
- It logs sender, receiver, and BIND server activity.
- It forwards those logs to Splunk with rsyslog.
This is useful for lab validation, operational signaling, status handoff, and controlled internal workflows. It is intentionally scoped for small authorized TXT messages, not high-volume transfer or unmanaged resolver behavior.
Responsible Use And Scope
This article is written for controlled lab validation and internal operations. Use the workflow only on DNS zones, servers, and endpoints that you own or administer. Keep TXT values short, non-sensitive, and auditable, and route all update and query activity into the approved logging path.
The workflow is not a general file-transfer design and should not be deployed as an unmanaged messaging channel. In production environments, review the design with DNS, network, and security operations teams before enabling dynamic updates.
Architecture
Message Flow
1. The DNS server hosts a dedicated zone such as text.example.local. 2. The sender uses nsupdate with a TSIG key to create or replace a TXT record. 3. The record name can be workflow-specific, for example inbox.text.example.local. 4. The remote device reads the TXT value with dig. 5. BIND logs DNS updates, update security events, queries, and security events. 6. The sender and receiver scripts write local JSONL audit events. 7. rsyslog tails all log files and forwards them to Splunk.
Repository Layout
DNS-Data/
|-- configure_dns_text_zone.sh
|-- dns_text_send.sh
|-- dns_text_receive.sh
|-- configure_splunk_rsyslog.sh
|-- README.md
`-- publishable-dns-text-transfer-splunk.md| Script | Purpose |
|---|---|
configure_dns_text_zone.sh | Creates a BIND zone for TXT-message publishing and enables BIND logging. |
dns_text_send.sh | Publishes a short message into a TXT record through authenticated dynamic DNS update. |
dns_text_receive.sh | Retrieves the message from the TXT record on the remote device. |
configure_splunk_rsyslog.sh | Forwards BIND, sender, and receiver logs to Splunk through rsyslog. |
Platform Assumptions
The final scripts support both Debian/Ubuntu style BIND and RHEL-family style BIND. The lab validation in this article used a RHEL-family named deployment with:
| Role | Host/IP used in validation |
|---|---|
| DNS server | dnsserver, 192.168.52.128 |
| Sender | 192.168.52.129 |
| Receiver | 192.168.52.130 |
| DNS zone | text.example.local |
| TXT record | inbox.text.example.local |
The script detects the BIND configuration style from the host.
| Component | Debian/Ubuntu | RHEL/Alma/Rocky |
|---|---|---|
| BIND service | bind9 | named |
| Main config | /etc/bind/named.conf or /etc/bind/named.conf.options | /etc/named.conf |
| Local zone include | /etc/bind/named.conf.local | /etc/named.rfc1912.zones |
| Zone file | /etc/bind/zones/db.text.example.local | /var/named/dynamic/db.text.example.local.zone |
| BIND text-transfer log | /var/log/named/dns-text-transfer.log | /var/log/named/dns-text-transfer.log |
| Sender audit log | /var/log/dns-text-transfer/sender.log | /var/log/dns-text-transfer/sender.log |
| Receiver audit log | /var/log/dns-text-transfer/receiver.log | /var/log/dns-text-transfer/receiver.log |
| rsyslog forwarding rule | /etc/rsyslog.d/60-dns-text-transfer-splunk.conf | /etc/rsyslog.d/60-dns-text-transfer-splunk.conf |
Prerequisites
On the DNS server:
# Debian/Ubuntu
sudo apt-get update
sudo apt-get install -y bind9 bind9utils dnsutils rsyslog
# RHEL/Alma/Rocky
sudo dnf install -y bind bind-utils rsyslogOn the sender:
# Debian/Ubuntu
sudo apt-get update
sudo apt-get install -y dnsutils
# RHEL/Alma/Rocky
sudo dnf install -y bind-utilsOn the receiver:
# Debian/Ubuntu
sudo apt-get update
sudo apt-get install -y dnsutils
# RHEL/Alma/Rocky
sudo dnf install -y bind-utilsMake the scripts executable after copying them to Linux:
chmod +x configure_dns_text_zone.sh \
dns_text_send.sh \
dns_text_receive.sh \
configure_splunk_rsyslog.sh
Create A TSIG Key
The sender should not be allowed to update DNS records anonymously. Use TSIG so BIND can authenticate update requests.
Generate the key:
tsig-keygen -a hmac-sha256 text-transfer-key > text-transfer.keyProtect the key file:
chmod 600 text-transfer.keyThe generated file looks similar to this:
key "text-transfer-key" {
algorithm hmac-sha256;
secret "BASE64_SECRET_VALUE";
};Copy text-transfer.key only to the authorized sender and the DNS server. The remote receiver does not need the TSIG key because it only performs DNS lookups.
Configure The DNS TXT Transfer Zone
Preview the BIND changes first:
sudo ./configure_dns_text_zone.sh \
--zone text.example.local \
--server-ip 192.168.52.128 \
--tsig-name text-transfer-key \
--tsig-secret 'BASE64_SECRET_VALUE' \
--dry-runApply the configuration:
sudo ./configure_dns_text_zone.sh \
--zone text.example.local \
--server-ip 192.168.52.128 \
--tsig-name text-transfer-key \
--tsig-secret 'BASE64_SECRET_VALUE' \
--applyOn the validated RHEL-family host, the script detected:
[2026-04-22T09:46:56Z] Detected BIND service named; zone directory /var/named/dynamicThe script:
1. Creates the zone file, such as /var/named/dynamic/db.text.example.local.zone on RHEL or /etc/bind/zones/db.text.example.local on Debian. 2. Adds a managed zone block to /etc/named.rfc1912.zones on RHEL or /etc/bind/named.conf.local on Debian. 3. Grants the TSIG key permission to update TXT records under the zone. 4. Enables BIND update, update-security, query, and security logging. 5. Handles RHEL's single top-level logging {} requirement by writing one combined logging block in /etc/named.conf. 6. Adds log rotation for /var/log/named/dns-text-transfer.log. 7. Runs BIND validation and reloads the DNS service.
Generated BIND Zone
The generated zone file is intentionally small:
$TTL 60
@ IN SOA ns1.text.example.local. admin.text.example.local. (
2026042209 ; serial
60 ; refresh
30 ; retry
604800 ; expire
60 ) ; negative cache ttl
@ IN NS ns1.text.example.local.
ns1 IN A 192.168.52.128The low TTL keeps message lookups responsive while avoiding aggressive query behavior.
Generated BIND Zone Policy
The script adds a managed block similar to this:
key "text-transfer-key" {
algorithm hmac-sha256;
secret "BASE64_SECRET_VALUE";
};
zone "text.example.local" {
type master;
file "/var/named/dynamic/db.text.example.local.zone";
update-policy {
grant text-transfer-key zonesub TXT;
};
allow-query { any; };
};The important control is update-policy. The TSIG key is granted permission to update TXT records inside the zone, not arbitrary DNS record types.
Send Text Through DNS
From the sender:
./dns_text_send.sh \
--zone text.example.local \
--record inbox \
--dns-server 192.168.52.128 \
--key-file ./text-transfer.key \
--message "Hello remote device"This publishes:
inbox.text.example.local. 60 IN TXT "Hello remote device"The sender script creates an nsupdate request:
server 192.168.52.128
zone text.example.local
update delete inbox.text.example.local. TXT
update add inbox.text.example.local. 60 TXT "Hello remote device"
sendTo preview the update without sending it:
./dns_text_send.sh \
--zone text.example.local \
--record inbox \
--dns-server 192.168.52.128 \
--key-file ./text-transfer.key \
--message "Hello remote device" \
--dry-runTo send content from a file:
printf 'maintenance window complete' > message.txt
./dns_text_send.sh \
--zone text.example.local \
--record inbox \
--dns-server 192.168.52.128 \
--key-file ./text-transfer.key \
--message-file message.txtThe script caps messages at 200 characters. This keeps the design aligned with small operational messages rather than bulk transfer.
Receive Text On The Remote Device
From the remote device:
./dns_text_receive.sh \
--zone text.example.local \
--record inbox \
--dns-server 192.168.52.128Expected output:
Hello remote deviceTo view the raw TXT value returned by dig:
./dns_text_receive.sh \
--zone text.example.local \
--record inbox \
--dns-server 192.168.52.128 \
--rawYou can also test manually:
dig @192.168.52.128 inbox.text.example.local TXT +short
Audit Logs
The sender writes JSONL events:
{"time":"2026-04-22T07:15:00Z","component":"dns_text_send","zone":"text.example.local","record":"inbox","dns_server":"192.168.52.128","status":"sent","details":"published 19 characters to inbox.text.example.local."}The receiver writes JSONL events:
{"time":"2026-04-22T07:16:00Z","component":"dns_text_receive","zone":"text.example.local","record":"inbox","dns_server":"192.168.52.128","status":"received","details":"read message from inbox.text.example.local."}BIND writes update and query events:
22-Apr-2026 07:15:00.000 update: info: client @0x... 192.168.10.25#12345/key text-transfer-key: updating zone 'text.example.local/IN': deleting rrset at 'inbox.text.example.local' TXT
22-Apr-2026 07:15:00.001 update: info: client @0x... 192.168.10.25#12345/key text-transfer-key: updating zone 'text.example.local/IN': adding an RR at 'inbox.text.example.local' TXT "Hello remote device"
Forward Logs To Splunk
Run the rsyslog configuration script on systems that should forward logs to Splunk.
Preview:
sudo ./configure_splunk_rsyslog.sh \
--splunk-host 10.10.10.50 \
--splunk-port 514 \
--protocol tcp \
--dry-runApply:
sudo ./configure_splunk_rsyslog.sh \
--splunk-host 10.10.10.50 \
--splunk-port 514 \
--protocol tcp \
--applyThe script tails these files:
| File | Source |
|---|---|
/var/log/named/dns-text-transfer.log | BIND update, update-security, query, and security logs. |
/var/log/dns-text-transfer/sender.log | Sender JSONL audit logs. |
/var/log/dns-text-transfer/receiver.log | Receiver JSONL audit logs. |
Generated rsyslog Configuration
The script creates /etc/rsyslog.d/60-dns-text-transfer-splunk.conf.
input(type="imfile"
File="/var/log/named/dns-text-transfer.log"
Tag="dns-text-transfer-bind:"
Severity="info"
Facility="local6"
PersistStateInterval="100")
input(type="imfile"
File="/var/log/dns-text-transfer/sender.log"
Tag="dns-text-transfer-sender:"
Severity="info"
Facility="local6"
PersistStateInterval="100")
input(type="imfile"
File="/var/log/dns-text-transfer/receiver.log"
Tag="dns-text-transfer-receiver:"
Severity="info"
Facility="local6"
PersistStateInterval="100")
local6.* action(type="omfwd"
target="10.10.10.50"
port="514"
protocol="tcp"
template="DnsTextTransferSplunkFormat"
action.resumeRetryCount="-1"
queue.type="LinkedList"
queue.filename="dns_text_transfer_splunk"
queue.maxdiskspace="1g"
queue.saveonshutdown="on")The linked-list queue and retry settings help rsyslog tolerate temporary Splunk or network outages.
The final version intentionally does not add module(load="imfile") because many RHEL-family rsyslog builds already load that module from the main configuration. Loading it twice causes validation failure.
Splunk Receiver Setup
Create a TCP or UDP syslog input in Splunk matching the protocol and port selected in the script.
Recommended metadata:
| Setting | Value |
|---|---|
| Index | infra_dns or network |
| Sourcetype | dns:text-transfer |
| Host | DNS server, sender, or receiver hostname |
| Transport | TCP preferred for reliability |
If forwarding through a syslog collector or Splunk Connect for Syslog, route the tags:
| rsyslog tag | Suggested sourcetype |
|---|---|
dns-text-transfer-bind: | dns:text-transfer:bind |
dns-text-transfer-sender: | dns:text-transfer:sender |
dns-text-transfer-receiver: | dns:text-transfer:receiver |
Splunk Searches
Show all DNS text-transfer events forwarded by rsyslog:
index=* "dns-text-transfer"
| table _time host sourcetype _rawShow BIND TXT update events:
index=* "updating zone 'text.example.local/IN'" "adding an RR" "TXT"
| table _time host sourcetype _rawExtract the DNS TXT record and transferred message:
index=* "updating zone 'text.example.local/IN'" "adding an RR" "TXT"
| rex field=_raw "'(?<dns_record>[^']+)' TXT \"(?<message>[^\"]+)\""
| table _time host sourcetype dns_record messageExample extracted results from the validated lab:
| host | sourcetype | dns_record | message |
|---|---|---|---|
dnsserver | nix:syslog | inbox.text.example.local | Hello remote device |
dnsserver | nix:syslog | inbox.text.example.local | maintenance window complete |
dnsserver | nix:syslog | inbox.text.example.local | Splunk validation test |
dnsserver | nix:syslog | inbox.text.example.local | Splunk forwarding fixed |
Show receiver query events:
index=* "query: inbox.text.example.local IN TXT"
| table _time host _rawDetect failed sender or receiver operations:
index=* ("dns_text_send" OR "dns_text_receive") "\"status\":\"failed\""
| stats count by host component detailsMonitor unusual volume:
index=* "text.example.local"
| bin _time span=5m
| stats count by _time host sourcetype
| where count > 50
End-To-End Validation
Validate BIND:
sudo named-checkconf
sudo named-checkzone text.example.local /var/named/dynamic/db.text.example.local.zone
sudo systemctl status namedSend a test message:
./dns_text_send.sh \
--zone text.example.local \
--record inbox \
--dns-server 192.168.52.128 \
--key-file ./text-transfer.key \
--message "Splunk validation test"Receive it:
./dns_text_receive.sh \
--zone text.example.local \
--record inbox \
--dns-server 192.168.52.128Check local DNS:
dig @192.168.52.128 inbox.text.example.local TXT +shortExpected output:
"Splunk validation test"Check local logs:
sudo tail -f /var/log/named/dns-text-transfer.log
sudo tail -f /var/log/dns-text-transfer/sender.log
sudo tail -f /var/log/dns-text-transfer/receiver.logValidate rsyslog:
sudo rsyslogd -N1
sudo systemctl restart rsyslog
sudo systemctl status rsyslogConfirm network reachability to Splunk:
nc -vz 10.10.10.50 514For UDP validation, use packet capture on the sending host or collector:
sudo tcpdump -nn host 10.10.10.50 and port 514
Troubleshooting
Fix BIND logging redefined
On RHEL-family systems, /etc/named.conf often already contains a default logging { ... }; block. BIND allows only one top-level logging block. If validation fails with:
/etc/named.conf:30: 'logging' redefined near 'logging'back up the file, remove duplicate logging blocks and stale logging includes, then append one clean logging block:
sudo cp -a /etc/named.conf /etc/named.conf.bak.$(date +%Y%m%d%H%M%S)
sudo awk '
/^[[:space:]]*logging[[:space:]]*\{/ { skip=1; depth=0 }
skip {
depth += gsub(/\{/, "{")
depth -= gsub(/\}/, "}")
if (depth <= 0) skip=0
next
}
{ print }
' /etc/named.conf | sudo grep -vE '^[[:space:]]*include[[:space:]]+".*(splunk-logging|dns-text-transfer-logging).*";[[:space:]]*$' | sudo tee /etc/named.conf.clean >/dev/null
sudo mv /etc/named.conf.clean /etc/named.conf
sudo tee -a /etc/named.conf >/dev/null <<'EOF'
logging {
channel default_debug {
file "data/named.run";
severity dynamic;
};
channel dns_text_transfer_file {
file "/var/log/named/dns-text-transfer.log" versions 10 size 50m;
severity info;
print-time yes;
print-severity yes;
print-category yes;
};
category update { dns_text_transfer_file; };
category update-security { dns_text_transfer_file; };
category queries { dns_text_transfer_file; };
category security { dns_text_transfer_file; };
};
EOF
grep -qF 'include "/etc/named.rfc1912.zones";' /etc/named.conf || \
echo 'include "/etc/named.rfc1912.zones";' | sudo tee -a /etc/named.conf
sudo named-checkconf
sudo systemctl reload namedSuccessful reload output should include:
reloading configuration succeeded
reloading zones succeeded
zone text.example.local/IN: loaded serial 2026042209
server reload successfulFix rsyslog Duplicate imfile
If rsyslog validation fails with:
module 'imfile' already in this config, cannot be addedthe main rsyslog configuration already loads imfile. Remove the duplicate module line from the generated forwarding file:
sudo cp -a /etc/rsyslog.d/60-dns-text-transfer-splunk.conf \
/etc/rsyslog.d/60-dns-text-transfer-splunk.conf.bak.$(date +%Y%m%d%H%M%S)
sudo sed -i '/module(load="imfile")/d' /etc/rsyslog.d/60-dns-text-transfer-splunk.conf
sudo rsyslogd -N1
sudo systemctl restart rsyslog
sudo systemctl status rsyslog --no-pagerIf nsupdate fails with REFUSED, verify:
- The sender is using the correct TSIG key file.
- The BIND zone contains the
update-policyblock. - The key name in the script matches the key name in BIND.
- BIND was reloaded after configuration.
If dig returns no TXT answer, verify:
dig @192.168.52.128 text.example.local SOA +short
dig @192.168.52.128 inbox.text.example.local TXT +shortIf BIND cannot write logs, check ownership and permissions:
sudo mkdir -p /var/log/named
sudo chown named:named /var/log/named
sudo chmod 0750 /var/log/named
sudo systemctl restart namedIf rsyslog does not forward events, validate the generated config:
sudo rsyslogd -N1
sudo systemctl restart rsyslogIf sender or receiver logs do not appear, create the directory with appropriate permissions:
sudo mkdir -p /var/log/dns-text-transfer
sudo chown splunk:splunk /var/log/dns-text-transfer
sudo chmod 750 /var/log/dns-text-transfer
Production Hardening
Use a dedicated DNS zone for this workflow:
text.example.internal
ops-msg.example.net
handoff.corp.example.comKeep the workflow constrained:
- Use TSIG for every update.
- Store the TSIG key with
chmod 600. - Limit who can run
dns_text_send.sh. - Use short, non-sensitive messages.
- Keep TTL low but reasonable, such as 60 seconds.
- Monitor failed updates and unexpected source hosts.
- Alert on abnormal message volume.
- Forward logs over TCP or a secured syslog relay.
- Keep BIND, rsyslog, and Splunk inputs patched and monitored.
Avoid using public resolvers for internal message retrieval. Query the authoritative DNS server or a trusted internal resolver so logs remain visible.
Rollback
The scripts create timestamped backups before replacing managed configuration files.
Restore BIND configuration:
sudo cp /etc/bind/named.conf.local.bak.YYYYMMDDHHMMSS /etc/bind/named.conf.local
sudo cp /etc/bind/named.conf.options.bak.YYYYMMDDHHMMSS /etc/bind/named.conf.options
sudo systemctl restart bind9On RHEL-family systems:
sudo cp /etc/named.conf.bak.YYYYMMDDHHMMSS /etc/named.conf
sudo cp /etc/named.rfc1912.zones.bak.YYYYMMDDHHMMSS /etc/named.rfc1912.zones
sudo systemctl restart namedRemove rsyslog forwarding:
sudo rm /etc/rsyslog.d/60-dns-text-transfer-splunk.conf
sudo systemctl restart rsyslogRemove the zone file only after confirming it is no longer needed:
sudo rm /var/named/dynamic/db.text.example.local.zone
sudo systemctl restart named
Complete Code Appendix
The following code blocks are generated from the current working scripts after the RHEL named path fix, the duplicate BIND logging cleanup, and the rsyslog imfile correction. Save each block with the shown file name in the same directory, then run the commands from the guide.
DNS Text Zone Configurator: configure_dns_text_zone.sh
#!/usr/bin/env bash
set -euo pipefail
ZONE=""
SERVER_IP=""
TSIG_NAME=""
TSIG_SECRET=""
DRY_RUN=0
APPLY=0
DNS_SERVICE=""
ZONES_DIR=""
CONF_LOCAL=""
CONF_MAIN=""
LOG_DIR="/var/log/named"
LOGGING_CONF=""
LOGROTATE_CONF="/etc/logrotate.d/dns-text-transfer"
usage() {
cat <<'USAGE'
Configure an authoritative BIND zone for small TXT messaging through DNS TXT records.
Example:
sudo ./configure_dns_text_zone.sh \
--zone text.example.local \
--server-ip 192.168.52.128 \
--tsig-name text-transfer-key \
--tsig-secret '<base64-secret>' \
--apply
Options:
--zone VALUE DNS zone used for messages, for example text.example.local.
--server-ip VALUE Authoritative DNS server IPv4 address.
--tsig-name VALUE TSIG key name allowed to update TXT records.
--tsig-secret VALUE Base64 TSIG secret.
--dry-run Print planned changes without writing files.
--apply Required to make system changes.
-h, --help Show this help.
This configures controlled TXT-record publishing for authorized systems. It does
not configure high-volume transfer or unmanaged external messaging.
USAGE
}
log() { printf '[%s] %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*"; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
run_cmd() {
if [[ "$DRY_RUN" -eq 1 ]]; then
printf 'DRY-RUN:'
printf ' %q' "$@"
printf '\n'
else
"$@"
fi
}
write_file() {
local path="$1"
local mode="$2"
local owner="$3"
local tmp
tmp="$(mktemp)"
cat > "$tmp"
if [[ "$DRY_RUN" -eq 1 ]]; then
log "Would write $path"
sed 's/^/ | /' "$tmp"
rm -f "$tmp"
return
fi
if [[ -f "$path" ]]; then
cp -a "$path" "${path}.bak.$(date -u +'%Y%m%d%H%M%S')"
fi
install -D -m "$mode" -o "${owner%%:*}" -g "${owner##*:}" "$tmp" "$path"
rm -f "$tmp"
}
valid_ipv4() {
local ip="$1"
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1
IFS='.' read -r a b c d <<< "$ip"
for octet in "$a" "$b" "$c" "$d"; do
[[ "$octet" -ge 0 && "$octet" -le 255 ]] || return 1
done
}
valid_zone() {
[[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.?$ ]]
}
detect_bind_user() {
if id bind >/dev/null 2>&1; then
printf 'bind:bind'
elif id named >/dev/null 2>&1; then
printf 'named:named'
else
printf 'root:root'
fi
}
detect_platform() {
if [[ -f /etc/named.conf ]]; then
DNS_SERVICE="named"
ZONES_DIR="/var/named/dynamic"
CONF_LOCAL="/etc/named.rfc1912.zones"
CONF_MAIN="/etc/named.conf"
LOGGING_CONF="/etc/named.dns-text-transfer-logging.conf"
elif [[ -f /etc/bind/named.conf ]]; then
DNS_SERVICE="bind9"
ZONES_DIR="/etc/bind/zones"
CONF_LOCAL="/etc/bind/named.conf.local"
if [[ -f /etc/bind/named.conf.options ]]; then
CONF_MAIN="/etc/bind/named.conf.options"
else
CONF_MAIN="/etc/bind/named.conf"
fi
LOGGING_CONF="/etc/bind/named.conf.dns-text-transfer-logging"
else
die "Could not find BIND config. Expected /etc/named.conf or /etc/bind/named.conf."
fi
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--zone) ZONE="${2:-}"; shift 2 ;;
--server-ip) SERVER_IP="${2:-}"; shift 2 ;;
--tsig-name) TSIG_NAME="${2:-}"; shift 2 ;;
--tsig-secret) TSIG_SECRET="${2:-}"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--apply) APPLY=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "Unknown option: $1" ;;
esac
done
}
validate_args() {
[[ -n "$ZONE" ]] || die "--zone is required."
[[ -n "$SERVER_IP" ]] || die "--server-ip is required."
[[ -n "$TSIG_NAME" ]] || die "--tsig-name is required."
[[ -n "$TSIG_SECRET" ]] || die "--tsig-secret is required."
valid_zone "$ZONE" || die "Invalid zone: $ZONE"
valid_ipv4 "$SERVER_IP" || die "Invalid --server-ip: $SERVER_IP"
[[ "$TSIG_NAME" =~ ^[A-Za-z0-9._-]+$ ]] || die "Invalid TSIG key name."
if [[ "$DRY_RUN" -eq 0 && "$APPLY" -ne 1 ]]; then
die "Refusing to change system files without --apply. Use --dry-run to preview."
fi
}
need_root() {
[[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Run as root, for example with sudo."
}
need_cmds() {
command -v named-checkconf >/dev/null 2>&1 || die "named-checkconf not found."
command -v named-checkzone >/dev/null 2>&1 || die "named-checkzone not found."
}
zone_file_path() {
if [[ "$DNS_SERVICE" == "named" ]]; then
printf '%s/db.%s.zone' "$ZONES_DIR" "${ZONE%.}"
else
printf '%s/db.%s' "$ZONES_DIR" "${ZONE%.}"
fi
}
create_zone_file() {
local owner serial zone_file
owner="$(detect_bind_user)"
serial="$(date -u +'%Y%m%d%H')"
zone_file="$(zone_file_path)"
run_cmd mkdir -p "$ZONES_DIR" "$LOG_DIR"
if [[ "$DRY_RUN" -eq 0 ]]; then
chown "$owner" "$ZONES_DIR" "$LOG_DIR" || true
chmod 0750 "$ZONES_DIR" "$LOG_DIR"
fi
cat <<EOF | write_file "$zone_file" 0640 "$owner"
\$TTL 60
@ IN SOA ns1.${ZONE%.}. admin.${ZONE%.}. (
${serial} ; serial
60 ; refresh
30 ; retry
604800 ; expire
60 ) ; negative cache ttl
@ IN NS ns1.${ZONE%.}.
ns1 IN A ${SERVER_IP}
EOF
if [[ "$DRY_RUN" -eq 0 ]]; then
named-checkzone "$ZONE" "$zone_file" >/dev/null
fi
}
update_managed_block() {
local path="$1"
local name="$2"
local content="$3"
local start="# BEGIN MANAGED ${name}"
local end="# END MANAGED ${name}"
local tmp
tmp="$(mktemp)"
if [[ -f "$path" ]]; then
awk -v start="$start" -v end="$end" '
$0 == start { skip=1; next }
$0 == end { skip=0; next }
skip != 1 { print }
' "$path" > "$tmp"
fi
{
cat "$tmp"
printf '\n%s\n%s\n%s\n' "$start" "$content" "$end"
} | write_file "$path" 0644 root:root
rm -f "$tmp"
}
create_named_local() {
local zone_file managed_block
zone_file="$(zone_file_path)"
managed_block="$(cat <<EOF
key "${TSIG_NAME}" {
algorithm hmac-sha256;
secret "${TSIG_SECRET}";
};
zone "${ZONE}" {
type master;
file "${zone_file}";
update-policy {
grant ${TSIG_NAME} zonesub TXT;
};
allow-query { any; };
};
EOF
)"
update_managed_block "$CONF_LOCAL" "dns-text-transfer-${ZONE%.}" "$managed_block"
}
create_logging_conf() {
if [[ "$DNS_SERVICE" == "named" ]]; then
return
fi
cat <<EOF | write_file "$LOGGING_CONF" 0644 root:root
logging {
channel dns_text_transfer_file {
file "${LOG_DIR}/dns-text-transfer.log" versions 10 size 50m;
severity info;
print-time yes;
print-severity yes;
print-category yes;
};
category update { dns_text_transfer_file; };
category update-security { dns_text_transfer_file; };
category queries { dns_text_transfer_file; };
category security { dns_text_transfer_file; };
};
EOF
}
rhel_logging_block() {
cat <<EOF
logging {
channel default_debug {
file "data/named.run";
severity dynamic;
};
channel dns_text_transfer_file {
file "${LOG_DIR}/dns-text-transfer.log" versions 10 size 50m;
severity info;
print-time yes;
print-severity yes;
print-category yes;
};
category update { dns_text_transfer_file; };
category update-security { dns_text_transfer_file; };
category queries { dns_text_transfer_file; };
category security { dns_text_transfer_file; };
};
EOF
}
strip_existing_logging_block() {
local path="$1"
local tmp
tmp="$(mktemp)"
awk '
/^[[:space:]]*logging[[:space:]]*\{/ {
skip=1
depth=0
}
skip {
depth += gsub(/\{/, "{")
depth -= gsub(/\}/, "}")
if (depth <= 0) {
skip=0
}
next
}
{ print }
' "$path" > "$tmp"
cat "$tmp" > "$path"
rm -f "$tmp"
}
remove_include() {
local include_file="$1"
local target_file="$2"
local tmp
tmp="$(mktemp)"
grep -vF "include \"${include_file}\";" "$target_file" > "$tmp" || true
cat "$tmp" > "$target_file"
rm -f "$tmp"
}
remove_logging_includes() {
local target_file="$1"
local tmp
tmp="$(mktemp)"
grep -vE '^[[:space:]]*include[[:space:]]+".*(splunk-logging|dns-text-transfer-logging).*";[[:space:]]*$' "$target_file" > "$tmp" || true
cat "$tmp" > "$target_file"
rm -f "$tmp"
}
ensure_include() {
local include_file="$1"
local target_file="$2"
if ! grep -qF "include \"${include_file}\";" "$target_file"; then
printf '\ninclude "%s";\n' "$include_file" >> "$target_file"
fi
}
include_bind_configs() {
if [[ "$DRY_RUN" -eq 1 ]]; then
log "Would ensure ${CONF_MAIN} includes ${CONF_LOCAL} and ${LOGGING_CONF}"
if [[ "$DNS_SERVICE" == "named" ]]; then
log "Would remove any existing logging block from ${CONF_MAIN} before adding dedicated logging include"
fi
return
fi
if [[ ! -f "$CONF_MAIN" ]]; then
die "BIND main config not found: ${CONF_MAIN}"
fi
cp -a "$CONF_MAIN" "${CONF_MAIN}.bak.$(date -u +'%Y%m%d%H%M%S')"
if [[ "$DNS_SERVICE" == "named" ]]; then
strip_existing_logging_block "$CONF_MAIN"
remove_include "$LOGGING_CONF" "$CONF_MAIN"
remove_logging_includes "$CONF_MAIN"
rhel_logging_block >> "$CONF_MAIN"
ensure_include "$CONF_LOCAL" "$CONF_MAIN"
else
ensure_include "$LOGGING_CONF" "$CONF_MAIN"
fi
}
create_logrotate() {
local owner_group
owner_group="$(detect_bind_user | tr ':' ' ')"
cat <<EOF | write_file "$LOGROTATE_CONF" 0644 root:root
${LOG_DIR}/dns-text-transfer.log {
daily
rotate 14
compress
missingok
notifempty
create 0640 ${owner_group}
postrotate
/usr/sbin/rndc reconfig >/dev/null 2>&1 || true
endscript
}
EOF
}
validate_and_reload() {
if [[ "$DRY_RUN" -eq 1 ]]; then
log "Would validate BIND configuration and reload service."
return
fi
named-checkconf
if [[ "$DNS_SERVICE" == "named" ]]; then
systemctl reload named || systemctl restart named
else
systemctl reload bind9 || systemctl restart bind9
fi
}
main() {
parse_args "$@"
validate_args
need_root
need_cmds
detect_platform
log "Configuring DNS TXT text-transfer zone ${ZONE}"
log "Detected BIND service ${DNS_SERVICE}; zone directory ${ZONES_DIR}"
create_zone_file
create_named_local
create_logging_conf
include_bind_configs
create_logrotate
validate_and_reload
log "Complete."
}
main "$@"
DNS Text Sender: dns_text_send.sh
#!/usr/bin/env bash
set -euo pipefail
ZONE=""
RECORD="message"
DNS_SERVER=""
KEY_FILE=""
MESSAGE=""
MESSAGE_FILE=""
TTL="60"
LOG_FILE="/var/log/dns-text-transfer/sender.log"
DRY_RUN=0
usage() {
cat <<'USAGE'
Send a small text message through DNS by publishing an authenticated TXT record.
Example:
./dns_text_send.sh --zone text.example.local --record inbox \
--dns-server 192.168.52.128 --key-file ./text-transfer.key \
--message "Hello remote device"
Options:
--zone VALUE DNS zone used for messages.
--record VALUE TXT record name under the zone. Default: message.
--dns-server VALUE Authoritative DNS server to update.
--key-file FILE nsupdate TSIG key file.
--message VALUE Text to publish.
--message-file FILE Read text from file instead of --message.
--ttl SECONDS TXT record TTL. Default: 60.
--log-file FILE Local JSONL audit log. Default: /var/log/dns-text-transfer/sender.log.
--dry-run Print nsupdate request without sending it.
-h, --help Show this help.
Limits:
This script intentionally supports one TXT value up to 200 characters. It is for
small operational messages, not high-volume transfer or general-purpose file movement.
USAGE
}
log_event() {
local status="$1"
local details="$2"
local dir
dir="$(dirname "$LOG_FILE")"
if [[ ! -d "$dir" ]]; then
mkdir -p "$dir" 2>/dev/null || return 0
fi
[[ -w "$dir" ]] || return 0
printf '{"time":"%s","component":"dns_text_send","zone":"%s","record":"%s","dns_server":"%s","status":"%s","details":"%s"}\n' \
"$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$ZONE" "$RECORD" "$DNS_SERVER" "$status" "$details" >> "$LOG_FILE"
}
die() {
log_event "failed" "$*"
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
trim_trailing_dot() {
printf '%s' "${1%.}"
}
fqdn() {
local z
z="$(trim_trailing_dot "$ZONE")"
if [[ "$RECORD" == "@" ]]; then
printf '%s.' "$z"
elif [[ "$RECORD" == *"." ]]; then
printf '%s' "$RECORD"
else
printf '%s.%s.' "$RECORD" "$z"
fi
}
escape_txt() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
printf '%s' "$value"
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--zone) ZONE="${2:-}"; shift 2 ;;
--record) RECORD="${2:-}"; shift 2 ;;
--dns-server) DNS_SERVER="${2:-}"; shift 2 ;;
--key-file) KEY_FILE="${2:-}"; shift 2 ;;
--message) MESSAGE="${2:-}"; shift 2 ;;
--message-file) MESSAGE_FILE="${2:-}"; shift 2 ;;
--ttl) TTL="${2:-}"; shift 2 ;;
--log-file) LOG_FILE="${2:-}"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "Unknown option: $1" ;;
esac
done
}
validate_args() {
[[ -n "$ZONE" ]] || die "--zone is required."
[[ -n "$DNS_SERVER" ]] || die "--dns-server is required."
[[ -n "$KEY_FILE" ]] || die "--key-file is required."
[[ -f "$KEY_FILE" ]] || die "Key file not found: $KEY_FILE"
[[ "$TTL" =~ ^[0-9]+$ && "$TTL" -ge 30 && "$TTL" -le 3600 ]] || die "--ttl must be 30-3600 seconds."
[[ "$RECORD" =~ ^(@|[A-Za-z0-9][A-Za-z0-9._-]{0,62})$ ]] || die "Invalid --record value."
if [[ -n "$MESSAGE_FILE" ]]; then
[[ -f "$MESSAGE_FILE" ]] || die "Message file not found: $MESSAGE_FILE"
MESSAGE="$(tr -d '\r' < "$MESSAGE_FILE" | head -c 201)"
fi
[[ -n "$MESSAGE" ]] || die "Provide --message or --message-file."
[[ "${#MESSAGE}" -le 200 ]] || die "Message is ${#MESSAGE} characters; maximum is 200."
command -v nsupdate >/dev/null 2>&1 || die "nsupdate not found. Install bind-utils or dnsutils."
}
main() {
parse_args "$@"
validate_args
local target escaped tmp
target="$(fqdn)"
escaped="$(escape_txt "$MESSAGE")"
tmp="$(mktemp)"
cat > "$tmp" <<EOF
server ${DNS_SERVER}
zone ${ZONE}
update delete ${target} TXT
update add ${target} ${TTL} TXT "${escaped}"
send
EOF
if [[ "$DRY_RUN" -eq 1 ]]; then
cat "$tmp"
rm -f "$tmp"
log_event "dry_run" "prepared update for ${target}"
exit 0
fi
nsupdate -k "$KEY_FILE" "$tmp"
rm -f "$tmp"
log_event "sent" "published ${#MESSAGE} characters to ${target}"
printf 'Published TXT message to %s via %s\n' "$target" "$DNS_SERVER"
}
main "$@"
DNS Text Receiver: dns_text_receive.sh
#!/usr/bin/env bash
set -euo pipefail
ZONE=""
RECORD="message"
DNS_SERVER=""
LOG_FILE="/var/log/dns-text-transfer/receiver.log"
RAW=0
usage() {
cat <<'USAGE'
Receive a small text message through DNS by reading a TXT record.
Example:
./dns_text_receive.sh --zone text.example.local --record inbox --dns-server 192.168.52.128
Options:
--zone VALUE DNS zone used for messages.
--record VALUE TXT record name under the zone. Default: message.
--dns-server VALUE DNS server to query.
--log-file FILE Local JSONL audit log. Default: /var/log/dns-text-transfer/receiver.log.
--raw Print raw dig TXT output.
-h, --help Show this help.
USAGE
}
log_event() {
local status="$1"
local details="$2"
local dir
dir="$(dirname "$LOG_FILE")"
if [[ ! -d "$dir" ]]; then
mkdir -p "$dir" 2>/dev/null || return 0
fi
[[ -w "$dir" ]] || return 0
printf '{"time":"%s","component":"dns_text_receive","zone":"%s","record":"%s","dns_server":"%s","status":"%s","details":"%s"}\n' \
"$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$ZONE" "$RECORD" "$DNS_SERVER" "$status" "$details" >> "$LOG_FILE"
}
die() {
log_event "failed" "$*"
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
trim_trailing_dot() {
printf '%s' "${1%.}"
}
fqdn() {
local z
z="$(trim_trailing_dot "$ZONE")"
if [[ "$RECORD" == "@" ]]; then
printf '%s.' "$z"
elif [[ "$RECORD" == *"." ]]; then
printf '%s' "$RECORD"
else
printf '%s.%s.' "$RECORD" "$z"
fi
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--zone) ZONE="${2:-}"; shift 2 ;;
--record) RECORD="${2:-}"; shift 2 ;;
--dns-server) DNS_SERVER="${2:-}"; shift 2 ;;
--log-file) LOG_FILE="${2:-}"; shift 2 ;;
--raw) RAW=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "Unknown option: $1" ;;
esac
done
}
validate_args() {
[[ -n "$ZONE" ]] || die "--zone is required."
[[ -n "$DNS_SERVER" ]] || die "--dns-server is required."
[[ "$RECORD" =~ ^(@|[A-Za-z0-9][A-Za-z0-9._-]{0,62})$ ]] || die "Invalid --record value."
command -v dig >/dev/null 2>&1 || die "dig not found. Install bind-utils or dnsutils."
}
main() {
parse_args "$@"
validate_args
local target output
target="$(fqdn)"
output="$(dig @"$DNS_SERVER" "$target" TXT +short)"
[[ -n "$output" ]] || die "No TXT message found at ${target}"
log_event "received" "read message from ${target}"
if [[ "$RAW" -eq 1 ]]; then
printf '%s\n' "$output"
else
printf '%s\n' "$output" | sed 's/^"//;s/"$//;s/"[[:space:]]*"//g'
fi
}
main "$@"
Splunk rsyslog Forwarder: configure_splunk_rsyslog.sh
#!/usr/bin/env bash
set -euo pipefail
SPLUNK_HOST=""
SPLUNK_PORT="514"
PROTOCOL="tcp"
FACILITY="local6"
APP_NAME="dns-text-transfer"
DRY_RUN=0
APPLY=0
RSYSLOG_CONF="/etc/rsyslog.d/60-dns-text-transfer-splunk.conf"
usage() {
cat <<'USAGE'
Forward DNS text-transfer logs to Splunk using rsyslog.
Example:
sudo ./configure_splunk_rsyslog.sh \
--splunk-host 10.10.10.50 --splunk-port 514 --protocol tcp --apply
Options:
--splunk-host VALUE Splunk indexer, heavy forwarder, or syslog receiver host.
--splunk-port VALUE Syslog listener port. Default: 514.
--protocol VALUE tcp or udp. Default: tcp.
--facility VALUE Syslog facility. Default: local6.
--app-name VALUE App name/tag in forwarded events. Default: dns-text-transfer.
--dry-run Print planned configuration.
--apply Required to make changes.
-h, --help Show this help.
USAGE
}
log() { printf '[%s] %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*"; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
need_root() {
[[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Run as root, for example with sudo."
}
valid_port() {
[[ "$1" =~ ^[0-9]+$ && "$1" -ge 1 && "$1" -le 65535 ]]
}
write_file() {
local path="$1"
local tmp
tmp="$(mktemp)"
cat > "$tmp"
if [[ "$DRY_RUN" -eq 1 ]]; then
log "Would write $path"
sed 's/^/ | /' "$tmp"
rm -f "$tmp"
return
fi
if [[ -f "$path" ]]; then
cp -a "$path" "${path}.bak.$(date -u +'%Y%m%d%H%M%S')"
fi
install -D -m 0644 -o root -g root "$tmp" "$path"
rm -f "$tmp"
}
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 ;;
--facility) FACILITY="${2:-}"; shift 2 ;;
--app-name) APP_NAME="${2:-}"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--apply) APPLY=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "Unknown option: $1" ;;
esac
done
}
validate_args() {
[[ -n "$SPLUNK_HOST" ]] || die "--splunk-host is required."
valid_port "$SPLUNK_PORT" || die "Invalid --splunk-port: $SPLUNK_PORT"
[[ "$PROTOCOL" == "tcp" || "$PROTOCOL" == "udp" ]] || die "--protocol must be tcp or udp."
[[ "$FACILITY" =~ ^local[0-7]$ ]] || die "--facility must be local0 through local7."
[[ "$APP_NAME" =~ ^[A-Za-z0-9._-]+$ ]] || die "--app-name may contain letters, numbers, dot, underscore, and hyphen."
if [[ "$DRY_RUN" -eq 0 && "$APPLY" -ne 1 ]]; then
die "Refusing to change system files without --apply. Use --dry-run to preview."
fi
}
create_rsyslog_conf() {
cat <<EOF | write_file "$RSYSLOG_CONF"
template(name="DnsTextTransferSplunkFormat" type="list") {
constant(value="<")
property(name="pri")
constant(value=">1 ")
property(name="timereported" dateFormat="rfc3339")
constant(value=" ")
property(name="hostname")
constant(value=" ${APP_NAME} - - - ")
property(name="msg")
constant(value="\\n")
}
input(type="imfile"
File="/var/log/named/dns-text-transfer.log"
Tag="${APP_NAME}-bind:"
Severity="info"
Facility="${FACILITY}"
PersistStateInterval="100")
input(type="imfile"
File="/var/log/dns-text-transfer/sender.log"
Tag="${APP_NAME}-sender:"
Severity="info"
Facility="${FACILITY}"
PersistStateInterval="100")
input(type="imfile"
File="/var/log/dns-text-transfer/receiver.log"
Tag="${APP_NAME}-receiver:"
Severity="info"
Facility="${FACILITY}"
PersistStateInterval="100")
${FACILITY}.* action(type="omfwd"
target="${SPLUNK_HOST}"
port="${SPLUNK_PORT}"
protocol="${PROTOCOL}"
template="DnsTextTransferSplunkFormat"
action.resumeRetryCount="-1"
queue.type="LinkedList"
queue.filename="dns_text_transfer_splunk"
queue.maxdiskspace="1g"
queue.saveonshutdown="on")
EOF
}
validate_and_reload() {
if [[ "$DRY_RUN" -eq 1 ]]; then
log "Would validate rsyslog configuration and restart rsyslog."
return
fi
rsyslogd -N1
systemctl restart rsyslog
}
main() {
parse_args "$@"
validate_args
need_root
command -v rsyslogd >/dev/null 2>&1 || die "rsyslogd not found. Install rsyslog first."
log "Configuring rsyslog forwarding to ${SPLUNK_HOST}:${SPLUNK_PORT}/${PROTOCOL}"
create_rsyslog_conf
validate_and_reload
log "Complete."
}
main "$@"
Summary
This workflow creates a controlled sender-to-DNS-to-remote-device path for small text messages. In the validated lab, the sender at 192.168.52.129 updated inbox.text.example.local, the DNS server at 192.168.52.128 logged the TXT update and query activity, and the receiver at 192.168.52.130 retrieved the message successfully.
The final Splunk validation proves the full path:
index=* "updating zone 'text.example.local/IN'" "adding an RR" "TXT"
| rex field=_raw "'(?<dns_record>[^']+)' TXT \"(?<message>[^\"]+)\""
| table _time host sourcetype dns_record messageThe result is a repeatable, SIEM-friendly DNS TXT transfer workflow with TSIG authentication, BIND visibility, rsyslog forwarding, and Splunk-searchable message evidence.