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

flowchart LR sender["Sender host"] --> dnsServer["Authoritative BIND DNS"] dnsServer --> receiver["Remote device"] sender --> senderScript["dns_text_send.sh"] receiver --> receiverScript["dns_text_receive.sh"] dnsServer --> zone["text.example.local inbox TXT"] sender --> senderLog["sender.log JSONL audit"] receiver --> receiverLog["receiver.log JSONL audit"] dnsServer --> bindLog["dns-text-transfer.log"] senderLog --> rsyslog["rsyslog imfile inputs"] receiverLog --> rsyslog bindLog --> rsyslog rsyslog --> splunk["Splunk syslog input"]

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.

sequenceDiagram participant Sender as Sender Host participant BIND as BIND DNS Server participant Receiver as Remote Device participant Rsyslog as rsyslog participant Splunk as Splunk Sender->>BIND: nsupdate TXT value with TSIG BIND-->>Sender: Update accepted or rejected Receiver->>BIND: dig TXT inbox.text.example.local BIND-->>Receiver: TXT response Sender->>Rsyslog: Write sender JSONL audit event Receiver->>Rsyslog: Write receiver JSONL audit event BIND->>Rsyslog: Write update and query log event Rsyslog->>Splunk: Forward normalized syslog events

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
ScriptPurpose
configure_dns_text_zone.shCreates a BIND zone for TXT-message publishing and enables BIND logging.
dns_text_send.shPublishes a short message into a TXT record through authenticated dynamic DNS update.
dns_text_receive.shRetrieves the message from the TXT record on the remote device.
configure_splunk_rsyslog.shForwards 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:

RoleHost/IP used in validation
DNS serverdnsserver, 192.168.52.128
Sender192.168.52.129
Receiver192.168.52.130
DNS zonetext.example.local
TXT recordinbox.text.example.local

The script detects the BIND configuration style from the host.

ComponentDebian/UbuntuRHEL/Alma/Rocky
BIND servicebind9named
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 rsyslog

On the sender:

# Debian/Ubuntu
sudo apt-get update
sudo apt-get install -y dnsutils

# RHEL/Alma/Rocky
sudo dnf install -y bind-utils

On the receiver:

# Debian/Ubuntu
sudo apt-get update
sudo apt-get install -y dnsutils

# RHEL/Alma/Rocky
sudo dnf install -y bind-utils

Make 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.key

Protect the key file:

chmod 600 text-transfer.key

The 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-run

Apply 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' \
  --apply

On the validated RHEL-family host, the script detected:

[2026-04-22T09:46:56Z] Detected BIND service named; zone directory /var/named/dynamic

The 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.128

The 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"
send

To 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-run

To 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.txt

The 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.128

Expected output:

Hello remote device

To view the raw TXT value returned by dig:

./dns_text_receive.sh \
  --zone text.example.local \
  --record inbox \
  --dns-server 192.168.52.128 \
  --raw

You 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-run

Apply:

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

The script tails these files:

FileSource
/var/log/named/dns-text-transfer.logBIND update, update-security, query, and security logs.
/var/log/dns-text-transfer/sender.logSender JSONL audit logs.
/var/log/dns-text-transfer/receiver.logReceiver 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:

SettingValue
Indexinfra_dns or network
Sourcetypedns:text-transfer
HostDNS server, sender, or receiver hostname
TransportTCP preferred for reliability

If forwarding through a syslog collector or Splunk Connect for Syslog, route the tags:

rsyslog tagSuggested 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 _raw

Show BIND TXT update events:

index=* "updating zone 'text.example.local/IN'" "adding an RR" "TXT"
| table _time host sourcetype _raw

Extract 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 message

Example extracted results from the validated lab:

hostsourcetypedns_recordmessage
dnsservernix:sysloginbox.text.example.localHello remote device
dnsservernix:sysloginbox.text.example.localmaintenance window complete
dnsservernix:sysloginbox.text.example.localSplunk validation test
dnsservernix:sysloginbox.text.example.localSplunk forwarding fixed

Show receiver query events:

index=* "query: inbox.text.example.local IN TXT"
| table _time host _raw

Detect failed sender or receiver operations:

index=* ("dns_text_send" OR "dns_text_receive") "\"status\":\"failed\""
| stats count by host component details

Monitor 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 named

Send 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.128

Check local DNS:

dig @192.168.52.128 inbox.text.example.local TXT +short

Expected 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.log

Validate rsyslog:

sudo rsyslogd -N1
sudo systemctl restart rsyslog
sudo systemctl status rsyslog

Confirm network reachability to Splunk:

nc -vz 10.10.10.50 514

For 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 named

Successful reload output should include:

reloading configuration succeeded
reloading zones succeeded
zone text.example.local/IN: loaded serial 2026042209
server reload successful

Fix rsyslog Duplicate imfile

If rsyslog validation fails with:

module 'imfile' already in this config, cannot be added

the 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-pager

If nsupdate fails with REFUSED, verify:

  • The sender is using the correct TSIG key file.
  • The BIND zone contains the update-policy block.
  • 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 +short

If 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 named

If rsyslog does not forward events, validate the generated config:

sudo rsyslogd -N1
sudo systemctl restart rsyslog

If 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.com

Keep 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 bind9

On 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 named

Remove rsyslog forwarding:

sudo rm /etc/rsyslog.d/60-dns-text-transfer-splunk.conf
sudo systemctl restart rsyslog

Remove 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 message

The result is a repeatable, SIEM-friendly DNS TXT transfer workflow with TSIG authentication, BIND visibility, rsyslog forwarding, and Splunk-searchable message evidence.