Automating DKIM DNS Records with HE.net and Friends

Tech‑Scrolls 117
Proverb: “A sealed letter proves the hand that sent it; a broken seal proves the hand that betrayed it.”
In other words: when the message arrives, the seal must match the sender. DKIM is the seal.
Introduction
Email without DomainKeys Identified Mail (DKIM) is like a letter without a wax seal: anyone could have written it. DKIM gives every message a cryptographic signature that the recipient can verify against a public key in your DNS zone. If the signature matches the DNS record, the seal is intact and the message is trusted.
That public key, however, lives in DNS. Whenever you rotate keys—or rebuild a mail server—you must update DNS quickly and accurately. Manual edits are easy to forget or mistype.
This Tech‑Scroll shows how to automate DKIM record updates using dns‑lexicon and a short shell script. It is tested on Arch Linux but works on any modern Linux distribution. We use it ourselves to keep the DKIM record for breathtechnology.co.uk correct without touching the DNS panel.
We assume:
- you already run a Postfix/OpenDKIM mail server;
- your DKIM selector keys have been generated;
- you have a HE.net DNS account (other providers supported by Lexicon work the same way).
If you haven’t created the keys yet, see Generating your first DKIM key below.
1. Generate (or Locate) Your DKIM Keys
If OpenDKIM is already signing mail you can skip this. Otherwise:
# Alpine: apk add opendkim
# Arch: pacman -S opendkim
mkdir -p /etc/opendkim/keys/example.com
cd /etc/opendkim/keys/example.com
opendkim-genkey -b 2048 -d example.com -s mail
This creates:
mail.private
– private key used by OpenDKIM to sign mail.mail.txt
– DNS TXT record containing the public key. Thep=
tag is the key you must publish.
Configure OpenDKIM:
Domain example.com
Selector mail
KeyFile /etc/opendkim/keys/example.com/mail.private
Restart the service and send a test email to ensure DKIM signatures appear.
2. Install dns‑lexicon
dns‑lexicon is a universal DNS API client.
On Arch Linux:
pacman -S python python-pip dns-lexicon
On Alpine Linux:
apk add python3 py3-pip
pip3 install dns-lexicon
Lexicon supports dozens of providers; for HE.net the provider name is henet.
Create credentials file /etc/lexicon/henet.env
:
export LEXICON_HENET_USERNAME='your-he.net-username'
export LEXICON_HENET_PASSWORD='your-he.net-password'
chmod 600 /etc/lexicon/henet.env
to protect it.
3. The Automation Script
Save the following as /usr/local/sbin/update-dkim-he.sh
and make it executable.
#!/bin/sh
# update-dkim-he.sh — DKIM TXT normalizer & HE.net publisher (no awk)
# Fixes: ignores trailing ; comments from opendkim .txt, avoids
# duplicate/broken TXT.
# Arch/Alpine friendly. Idempotent. Reconciles against public DNS.
#
# MIT License
#
# Copyright (c) 2025 Akadata Limited - by BreathTechnology.co.uk
# Created by the Director - free to use, free to enhance, all good
# things in life are free, it's something we live by and if you need
# support then we are here yet that sadly does cost. We still need to
# pay them things called 'bills'
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the "Software"), to deal in the Software without
# restriction, including without limitation the rights to use, copy,
# modify, merge, publish, distribute, sublicense, and/or sell copies
# of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
set -eu
# ---- Config ---------------------------------------------------------------
DOMAIN="example.com"
SELECTOR="${SELECTOR:-mail}"
# override: SELECTOR=s1 …
DKIM_TXT_FILE="${DKIM_TXT_FILE:-/etc/opendkim/keys/${DOMAIN}/${SELECTOR}.txt}"
STATE_DIR="/var/lib/dkim-sync"
NAME="${SELECTOR}._domainkey"
FQDN="${NAME}.${DOMAIN}"
PATH="$PATH:/usr/local/bin:/usr/bin"
TTL="${TTL:-900}"
# ---- Provider env (HE.net via Lexicon provider 'henet') -------------------
. /etc/lexicon/henet.env
# ---- Helpers --------------------------------------------------------------
canon_spaces() {
# collapse multiple spaces, trim edges
sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//; s/[[:space:]]+/ /g'
}
normalize_file_record() {
# Use only the quoted payloads from opendkim .txt to avoid trailing ';' comments
# 1) extract quoted chunks, 2) drop quotes, 3) join, 4) collapse whitespace
grep -o '"[^"]*"' "$DKIM_TXT_FILE" \
| sed 's/^"//;s/"$//' \
| tr -d '\n' \
| tr -d '\t' \
| canon_spaces
}
normalize_dig_lines() {
# Convert dig +short TXT output to one canonical record per line
# Remove quotes, collapse whitespace
canon_spaces
}
# ---- Ensure state dir -----------------------------------------------------
install -d -m 0750 "$STATE_DIR"
# ---- Build desired TXT content from file ---------------------------------
if [ ! -f "$DKIM_TXT_FILE" ]; then
echo "DKIM file not found: $DKIM_TXT_FILE" >&2
exit 1
fi
CONTENT="$(normalize_file_record || true)"
if [ -z "$CONTENT" ] || ! printf "%s" "$CONTENT" | grep -q 'v=DKIM1;'; then
# Fallback: extract p=… and reconstruct
P=$(grep -o 'p=[A-Za-z0-9+/]\{20,\}[A-Za-z0-9+/=]*' "$DKIM_TXT_FILE" | head -1 | sed 's/^p=//')
if [ -n "$P" ]; then
CONTENT="v=DKIM1; k=rsa; p=${P}"
fi
fi
# Validate we have a proper DKIM record before proceeding
if [ -z "$CONTENT" ] || ! printf "%s" "$CONTENT" | grep -q '^v=DKIM1; k=rsa; p='; then
echo "Error: Could not extract valid DKIM record from $DKIM_TXT_FILE" >&2
exit 1
fi
# ensure content has no trailing semicolon-comments (defensive)
CONTENT=$(printf "%s" "$CONTENT" | sed 's/[[:space:]]*;[[:space:]]*DKIM.*$//')
# ---- Determine DNS state --------------------------------------------------
DNS_RAW=$(dig +short "$FQDN" TXT || true)
CURRENT_RECORDS=$(printf "%s\n" "$DNS_RAW" \
| sed 's/"//g' \
| normalize_dig_lines \
| sort -u || true)
MATCH_COUNT=0
if [ -n "$CURRENT_RECORDS" ]; then
MATCH_COUNT=$(printf "%s\n" "$CURRENT_RECORDS" | grep -Fx -- "$CONTENT" | wc -l | tr -d ' ')
fi
HASH_FILE="${STATE_DIR}/${FQDN}.sha256"
NEW_HASH=$(printf "%s" "$CONTENT" | sha256sum | awk '{print $1}')
OLD_HASH=""; [ -f "$HASH_FILE" ] && OLD_HASH=$(cat "$HASH_FILE")
DNS_OK=0
if [ "$MATCH_COUNT" -ge "1" ]; then
TOTAL_COUNT=$(printf "%s\n" "$CURRENT_RECORDS" | wc -l | tr -d ' ')
# Check if all records match our content (no duplicates with different content)
if [ "$MATCH_COUNT" = "$TOTAL_COUNT" ]; then
DNS_OK=1
fi
fi
if [ "$DNS_OK" = "1" ] && [ "$NEW_HASH" = "$OLD_HASH" ]; then
echo "No change for ${FQDN}"
exit 0
fi
echo "Updating ${FQDN} TXT in HE.net…"
# Debug output (commented out for production)
# echo "Current records: $(printf "%s\n" "$CURRENT_RECORDS" | wc -l)" >&2
# echo "Matching records: $MATCH_COUNT" >&2
# echo "New content: $CONTENT" >&2
# Validate content before creating record
if [ -z "$CONTENT" ] || ! printf "%s" "$CONTENT" | grep -q '^v=DKIM1; k=rsa; p='; then
echo "Error: Invalid DKIM content, not creating record" >&2
exit 1
fi
# Delete ALL current TXT variants visible in public DNS
if [ -n "$CURRENT_RECORDS" ]; then
printf "%s\n" "$CURRENT_RECORDS" | while IFS= read -r REC; do
[ -z "$REC" ] && continue
# Add extra validation to prevent deleting with incomplete records
if [ -n "$REC" ] && printf "%s" "$REC" | grep -q '^v=DKIM1; k=rsa; p='; then
lexicon henet delete "$DOMAIN" TXT --name "$NAME" --content "$REC" || true
elif [ -n "$REC" ] && printf "%s" "$REC" | grep -q '^v=DKIM1$'; then
# Specifically delete incomplete v=DKIM1 records
lexicon henet delete "$DOMAIN" TXT --name "$NAME" --content "$REC" || true
fi
done
fi
# Create canonical TXT
lexicon henet create "$DOMAIN" TXT --name "$NAME" --content "$CONTENT" --ttl "$TTL"
printf "%s" "$NEW_HASH" > "$HASH_FILE"
echo "Updated ${FQDN}"
How it Works
- Parse – reads the
mail.txt
file and extracts the DKIM public key. - Detect change – compares SHA256 hash of the TXT to the last deployed value.
- Update – if the key changed, deletes the old DNS record and creates a new one using Lexicon’s
henet
provider. - Store hash – remembers the hash so subsequent runs are no-op until the key changes again.
You can override the selector:
SELECTOR=s1 /usr/local/sbin/update-dkim-he.sh
4. Automate with systemd
Create a service unit /etc/systemd/system/dkim-sync.service
:
[Unit]
Description=Sync DKIM TXT record with HE.net
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/update-dkim-he.sh
Create a timer /etc/systemd/system/dkim-sync.timer
:
[Unit]
Description=Run DKIM sync every 6 hours
[Timer]
OnBootSec=2min
OnUnitActiveSec=6h
RandomizedDelaySec=10m
Unit=dkim-sync.service
[Install]
WantedBy=timers.target
Enable and start:
systemctl daemon-reload
systemctl enable --now dkim-sync.timer
The script now checks and updates the DKIM record every six hours.
5. Test and Validate
Query the DNS record:
dig +short mail._domainkey.example.com TXT
Ensure the p=
value matches the key in mail.txt
.
Send a test email to mail‑tester.com or a Gmail account and view the headers; you should see:
DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=mail; …
with dkim=pass.
Rotate your DKIM key (generate a new selector) and watch the timer update DNS automatically.
6. Other Providers
Lexicon supports many DNS providers: Cloudflare, Route53, Google Cloud DNS, and more. The script is identical—only the provider name and environment variables change. Example for Cloudflare:
lexicon cloudflare create example.com TXT --name mail._domainkey --content "v=DKIM1; …"
Check Lexicon’s provider list for the required environment variables.
Why Automate?
- Security – rotate keys regularly without manual DNS edits.
- Reliability – avoid typos that break mail delivery.
- Scalability – manage multiple domains and selectors.
- Auditability – systemd logs every update.
The proverb at the start reminds us: a message’s trust is proven when its seal is unbroken. Automating DKIM ensures that every email you send arrives with a seal that matches the hand that sent it.
This process is current and tested at the time of writing. We use it in production to keep our own DKIM records accurate without touching the DNS control panel.