26.1.7_2: issue with ACME client automation upload to TrueNAS websocket API

Started by Rene78, May 04, 2026, 07:31:11 PM

Previous topic - Next topic
In either case it looks like it expects TrueNAS as OS, not OPNsense.


Cheers,
Franco
"AI has absolutely reduced the cost of creating technical debt." -- ChatGPT

Quote from: ceel on May 14, 2026, 03:10:38 PM[Thu May 14 14:56:38 CEST 2026] Checking TrueNAS health...
/usr/local/sbin/acme.sh: midclt: not found

QuoteIt seems to be a part of this:
https://github.com/truenas/api_client

Thanks for finding this apparently missing API client. I reproduced this missing midclt also from a shell. Not sure why it does not show up in the system logs though. ;-)

Quote from: franco on May 18, 2026, 09:38:40 AMIn either case it looks like it expects TrueNAS as OS, not OPNsense.

Cheers,
Franco

What's in a name... the TrueNAS client apparently called midcli and the one ceel references (https://github.com/truenas/api_client) is midclt. To make it more confusing both the clients seem to be preinstalled on a TrueNAS box according to the GitHub documentation.

Anyway,
  • the midcli is the NAS cli interface —> that is preinstalled and can only be used (in my understanding) on the TrueNAS box.
  • The midclt is not installed by default, at least not on my TrueNAS scale box (25.10.3.1). It is compatible with TrueNAS SCALE (Debian based) according to the docs both also to run from non-TrueNAS clients. "TrueNAS comes with this client preinstalled, but it is also possible to use the TrueNAS websocket client from a non-TrueNAS host."

Hence, imho the midclt could be used (assumption here, if the code works on FreeBSD...) to complete the deployment task.

Shouldn't the automation "simply" use SSH to execute whatever is necessary on the TrueNAS system, including midclt?
Deciso DEC750
People who think they know everything are a great annoyance to those of us who do. (Isaac Asimov)

Quote from: Patrick M. Hausen on May 19, 2026, 09:11:40 AMShouldn't the automation "simply" use SSH to execute whatever is necessary on the TrueNAS system, including midclt?

I am not skilled enough to check what the acme client and plugin does on OPNSense (use local midctl OR remote calling through SSH or something else HTTP calls etc.). What I understand is that midctl is/was intended to make calling the TrueNAS API as easy as possible.

Sadly it's just mid.

(Sorry had to make that joke.)
Hardware:
DEC740


I "fixed" it by using the ACME plugin on the truenas scale box. I am using easydns as provider and decided to go for a script to set the DNS records. after some fiddling I was able to get that working.
#!/bin/sh

# ============================================================
# TrueNAS SCALE ACME Shell Authenticator for easyDNS
#
# Script:
#   /mnt/Pool/Scripts/ACME/acmedns.sh
#
#:
#
# TrueNAS-Format:
#   acmedns.sh set   scale.domain.com _acme-challenge.scale.domain.com TOKEN
#   acmedns.sh unset scale.domain.com _acme-challenge.scale.domain.com TOKEN
#
# Fallback-Format:
#   acmedns.sh set   _acme-challenge.scale.domain.com TOKEN
#   acmedns.sh unset _acme-challenge.scale.domain.com TOKEN
# ============================================================

set -eu

LOG_DIR="/mnt/Pool/Scripts/ACME"
LOG_FILE="${LOG_DIR}/acmedns.log"
MAX_LOG_BYTES=$((1024 * 1024))

API_BASE="https://rest.easydns.net"
EASYDNS_DOMAIN="domain.com"
EASYDNS_TTL=300

EASYDNS_TOKEN_FILE="/mnt/Pool/Scripts/ACME/easydns.token"
EASYDNS_KEY_FILE="/mnt/Pool/Scripts/ACME/easydns.key"

PROPAGATION_SLEEP=60

mkdir -p "$LOG_DIR"

rotate_log_if_needed() {
  if [ -f "$LOG_FILE" ]; then
    size="$(stat -c%s "$LOG_FILE" 2>/dev/null || echo 0)"
    if [ "$size" -gt "$MAX_LOG_BYTES" ]; then
      : > "$LOG_FILE"
    fi
  fi
}

log() {
  echo "[$(date '+%F %T')] $*" >> "$LOG_FILE"
}

fail() {
  log "ERROR: $*"
  exit 1
}

rotate_log_if_needed

[ -f "$EASYDNS_TOKEN_FILE" ] || fail "Missing EasyDNS token file: $EASYDNS_TOKEN_FILE"
[ -f "$EASYDNS_KEY_FILE" ] || fail "Missing EasyDNS key file: $EASYDNS_KEY_FILE"

EASYDNS_TOKEN="$(tr -d '\r\n' < "$EASYDNS_TOKEN_FILE")"
EASYDNS_KEY="$(tr -d '\r\n' < "$EASYDNS_KEY_FILE")"

[ -n "$EASYDNS_TOKEN" ] || fail "EasyDNS token file is empty"
[ -n "$EASYDNS_KEY" ] || fail "EasyDNS key file is empty"

record_to_host() {
  record="$1"

  # trailing dot entfernen und lowercase
  record="$(printf '%s' "$record" | sed 's/\.$//' | tr '[:upper:]' '[:lower:]')"

  case "$record" in
    *."$EASYDNS_DOMAIN")
      printf '%s\n' "${record%.$EASYDNS_DOMAIN}"
      ;;
    "$EASYDNS_DOMAIN")
      printf '%s\n' "@"
      ;;
    *)
      fail "Challenge record '$record' is not inside zone '$EASYDNS_DOMAIN'"
      ;;
  esac
}

json_body_for_add() {
  host="$1"
  token="$2"

  python3 - "$host" "$EASYDNS_DOMAIN" "$EASYDNS_TTL" "$token" <<'PY'
import sys, json

host = sys.argv[1]
domain = sys.argv[2]
ttl = int(sys.argv[3])
token = sys.argv[4]

body = {
    "host": host,
    "domain": domain,
    "ttl": ttl,
    "prio": 0,
    "type": "txt",
    "rdata": token,
}

print(json.dumps(body, separators=(",", ":")))
PY
}

api_get_records() {
  curl -fsS \
    -u "${EASYDNS_TOKEN}:${EASYDNS_KEY}" \
    --connect-timeout 15 \
    --max-time 60 \
    "${API_BASE}/zones/records/all/${EASYDNS_DOMAIN}?format=json"
}

api_add_txt() {
  body="$1"

  curl -fsS \
    -u "${EASYDNS_TOKEN}:${EASYDNS_KEY}" \
    -X PUT \
    --connect-timeout 15 \
    --max-time 60 \
    -H "Content-Type: application/json" \
    -d "$body" \
    "${API_BASE}/zones/records/add/${EASYDNS_DOMAIN}/txt?format=json"
}

api_delete_record() {
  id="$1"

  log "Trying DELETE with confirm body for id=$id"

  if curl -fsS \
    -u "${EASYDNS_TOKEN}:${EASYDNS_KEY}" \
    -X DELETE \
    --connect-timeout 15 \
    --max-time 60 \
    -H "Content-Type: application/json" \
    -d '{"confirm":"DELETE"}' \
    "${API_BASE}/zones/records/${EASYDNS_DOMAIN}/${id}?format=json" >> "$LOG_FILE" 2>&1; then
    log "DELETE with confirm body succeeded for id=$id"
    return 0
  fi

  log "DELETE with confirm body failed for id=$id, trying fallback DELETE without body"

  curl -fsS \
    -u "${EASYDNS_TOKEN}:${EASYDNS_KEY}" \
    -X DELETE \
    --connect-timeout 15 \
    --max-time 60 \
    "${API_BASE}/zones/records/${EASYDNS_DOMAIN}/${id}?format=json" >> "$LOG_FILE" 2>&1

  log "Fallback DELETE succeeded for id=$id"
}

find_txt_record_ids() {
  host="$1"
  token="${2:-}"
  records_json="$3"

  printf '%s' "$records_json" | python3 - "$host" "$token" <<'PY'
import sys, json

host = sys.argv[1].lower()
token = sys.argv[2]

try:
    data = json.load(sys.stdin)
except Exception as e:
    print(f"JSON_ERROR:{e}", file=sys.stderr)
    sys.exit(2)

for r in data.get("data", []):
    rtype = str(r.get("type", "")).lower()
    rhost = str(r.get("host", "")).lower()

    rrdata = r.get("rdata", r.get("rData", ""))
    rrdata = str(rrdata).strip()
    rrdata_unquoted = rrdata.strip("\"")

    if rtype == "txt" and rhost == host:
        if not token or rrdata == token or rrdata_unquoted == token:
            rid = r.get("id")
            if rid is not None:
                print(rid)
PY
}

delete_txt_record() {
  host="$1"
  token="${2:-}"

  log "Searching TXT records for host=$host token=${token:-none}"

  records_json="$(api_get_records)"
  log "easyDNS records fetched"

  ids="$(find_txt_record_ids "$host" "$token" "$records_json" || true)"

  if [ -z "$ids" ] && [ -n "$token" ]; then
    log "No TXT record found with exact token. Searching again by host only."
    ids="$(find_txt_record_ids "$host" "" "$records_json" || true)"
  fi

  if [ -z "$ids" ]; then
    log "No matching TXT record found for host=$host"
    return 0
  fi

  echo "$ids" | while IFS= read -r id; do
    [ -z "$id" ] && continue
    log "Deleting TXT record host=$host id=$id"
    api_delete_record "$id"
    log "Delete completed for TXT record id=$id"
    sleep 2
  done
}

set_record() {
  challenge_fqdn="$1"
  token="$2"

  host="$(record_to_host "$challenge_fqdn")"

  log "SET requested"
  log "Challenge FQDN=$challenge_fqdn"
  log "Zone=$EASYDNS_DOMAIN"
  log "Host=$host"

  delete_txt_record "$host" "$token" || true

  body="$(json_body_for_add "$host" "$token")"

  log "Adding TXT record host=$host ttl=$EASYDNS_TTL"
  api_add_txt "$body" >> "$LOG_FILE" 2>&1
  log "TXT record added for host=$host"

  log "Waiting ${PROPAGATION_SLEEP}s for DNS propagation"
  sleep "$PROPAGATION_SLEEP"

  log "SET completed"
}

unset_record() {
  challenge_fqdn="$1"
  token="${2:-}"

  host="$(record_to_host "$challenge_fqdn")"

  log "UNSET requested"
  log "Challenge FQDN=$challenge_fqdn"
  log "Zone=$EASYDNS_DOMAIN"
  log "Host=$host"
  log "Token=${token:-none}"

  delete_txt_record "$host" "$token"

  log "UNSET completed"
}

# ------------------------------------------------------------
# Parameter Parsing
# ------------------------------------------------------------
mode="${1:-}"

arg2="${2:-}"
arg3="${3:-}"
arg4="${4:-}"

domain_fqdn=""
challenge_fqdn=""
txt_token=""

log "============================================================"
log "Raw args: mode='${mode:-}' arg2='${arg2:-}' arg3='${arg3:-}' arg4='${arg4:-}'"

case "$mode" in
  set)
    case "$arg2" in
      _acme-challenge*)
        # Fallback-Format:
        # set challenge token
        challenge_fqdn="$arg2"
        txt_token="$arg3"
        ;;
      *)
        # TrueNAS-Format:
        # set domain challenge token
        domain_fqdn="$arg2"
        challenge_fqdn="$arg3"
        txt_token="$arg4"
        ;;
    esac

    [ -n "$challenge_fqdn" ] || fail "Missing challenge FQDN argument"
    [ -n "$txt_token" ] || fail "Missing TXT token argument"

    set_record "$challenge_fqdn" "$txt_token"
    ;;

  unset)
    case "$arg2" in
      _acme-challenge*)
        # Fallback-Format:
        # unset challenge token
        challenge_fqdn="$arg2"
        txt_token="$arg3"
        ;;
      *)
        # TrueNAS-Format:
        # unset domain challenge token
        domain_fqdn="$arg2"
        challenge_fqdn="$arg3"
        txt_token="$arg4"
        ;;
    esac

    [ -n "$challenge_fqdn" ] || fail "Missing challenge FQDN argument"

    unset_record "$challenge_fqdn" "$txt_token"
    ;;

  *)
    fail "Unknown mode '$mode'. Expected 'set' or 'unset'."
    ;;
esac

exit 0

and then use this script when requesting the certificate. The only thing that is not working is the automatic cleanup of the DNS recored, therefore I created this script:'
#!/bin/sh

set -eu

LOG_DIR="/mnt/Pool/Scripts/ACME"
LOG_FILE="${LOG_DIR}/cleanup-acme-txt.log"

API_BASE="https://rest.easydns.net"
EASYDNS_DOMAIN="domain.com"

EASYDNS_TOKEN_FILE="/mnt/Pool/Scripts/ACME/easydns.token"
EASYDNS_KEY_FILE="/mnt/Pool/Scripts/ACME/easydns.key"

DRY_RUN=0

if [ "${1:-}" = "--dry-run" ]; then
  DRY_RUN=1
fi

mkdir -p "$LOG_DIR"

log() {
  echo "[$(date '+%F %T')] $*" | tee -a "$LOG_FILE"
}

fail() {
  log "ERROR: $*"
  exit 1
}

[ -f "$EASYDNS_TOKEN_FILE" ] || fail "Token file missing: $EASYDNS_TOKEN_FILE"
[ -f "$EASYDNS_KEY_FILE" ] || fail "Key file missing: $EASYDNS_KEY_FILE"

EASYDNS_TOKEN="$(tr -d '\r\n' < "$EASYDNS_TOKEN_FILE")"
EASYDNS_KEY="$(tr -d '\r\n' < "$EASYDNS_KEY_FILE")"

[ -n "$EASYDNS_TOKEN" ] || fail "Token file is empty"
[ -n "$EASYDNS_KEY" ] || fail "Key file is empty"

TMP_RECORDS="$(mktemp)"
TMP_IDS="$(mktemp)"

cleanup_tmp() {
  rm -f "$TMP_RECORDS" "$TMP_IDS"
}

trap cleanup_tmp EXIT INT TERM

log "============================================================"
log "Starting ACME TXT cleanup for zone: $EASYDNS_DOMAIN"

if [ "$DRY_RUN" -eq 1 ]; then
  log "Mode: DRY-RUN - nothing will be deleted"
else
  log "Mode: DELETE - matching records will be deleted"
fi

to run this once a week to clean up the old records. It is not as smart as centralizing the task from opnsense but it does the job.