Automating DKIM DNS Records with HE.net and Friends

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.privateprivate key used by OpenDKIM to sign mail.
  • mail.txt – DNS TXT record containing the public key. The p= 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.