Emergency server help: get in touch

SIP TLS Certificate Check for Asterisk and FreePBX (TLS and WSS)

A read-only Bash script that checks every SIP TLS certificate an Asterisk or FreePBX server uses (PJSIP TLS transports and the WSS server WebRTC phones connect to): days left, host name, chain, private key, and whether Asterisk still serves an old certificate after a renewal.

Version
1.0.2
Last updated
October 17, 2026
Language
Bash
Tested on
1.0.2, 1.0.1 and 1.0.0 on Debian 12 with FreePBX 17.0.33 and Asterisk 22.11 (lab test, 16-17 Oct 2026): PJSIP TLS transport with a private-CA certificate, the FreePBX self-signed WSS default, 9 days left, renewed but not reloaded, wrong host name, mismatched key, expired, remote endpoint; 1.0.1 also with 16 automated fixtures (not yet valid, expired intermediate, missing intermediate, client-only certificate); 1.0.2 with 23 fixtures (combined PEM, encrypted key, client certificate required over TLS 1.3 and 1.2)
Testing level
Lab-tested
License
MIT
Pricing
Free

When the SIP TLS certificate on an Asterisk or FreePBX server expires, phones and trunks that use TLS stop registering and WebRTC phones lose their WSS connection, usually first thing in the morning and with nothing obvious in the PBX logs. This free script checks every SIP TLS certificate the server uses, before that happens.

It also catches a fault a normal expiry check misses: the certificate was renewed on disk, but Asterisk is still serving the old one because nothing reloaded it. A website checker looking at port 443 cannot see that, and the old certificate expires on schedule anyway. The script compares the file with the certificate Asterisk actually presents on its port.

What the SIP TLS certificate check looks at

  • Discovery. It asks Asterisk for its PJSIP transports (pjsip show transports) and picks every tls transport with its certificate, key and CA file. It reads the Asterisk HTTP TLS settings (http.conf and the FreePBX http_additional.conf and http_custom.conf) for the WSS server WebRTC phones use, normally port 8089.
  • The certificate file. Days left, whether it matches the host name (from external_signaling_address, --name or the server’s FQDN), whether the chain is trusted (system CAs, the transport’s ca_list_file and --ca), whether it is self-signed, whether every certificate in the chain is valid now (not expired and not valid yet) and allowed for TLS server use, and whether the private key belongs to it, also when certificate and key are in one PEM file. An encrypted key is reported instead of making the script wait for a password.
  • The served certificate. It opens a TLS connection to the port, compares that certificate with the file and checks the chain the server sends. It also checks that the TLS handshake completed: a valid certificate on a server that then refuses the connection (for example because it requires a client certificate) is reported, not counted as OK. A different fingerprint means Asterisk serves a different certificate than the file: usually a renewal that was never loaded, but a proxy in front of Asterisk, SNI or a transport that points at another file can also cause it.
  • Protocol. It warns when the server negotiates TLS 1.0 or 1.1.
  • Remote endpoints. --check sip.provider.example:5061 adds your SIP provider’s TLS port or a provisioning server, checked against the system CAs.

Why a renewed certificate can still break phones

Asterisk loads a certificate when the transport starts or reloads. If certbot or another tool replaces the file and nothing reloads Asterisk, the PBX keeps presenting the old certificate until it expires. On our test server the Asterisk log says exactly which settings a reload applies to an existing transport:

NOTICE res_pjsip/config_transport.c: Transport 'srvs-tls-test' is not fully reloadable, not reloading:
protocol, bind, TLS (everything but certificate and private key if filename is unchanged), TCP, ToS, or CoS options.

So on Asterisk 22 a reload is enough when the file name stays the same. In our test both fwconsole reload and asterisk -rx 'module reload res_pjsip.so' picked up the renewed SIP TLS certificate, and module reload http picked up a replaced WSS certificate. If the new certificate has a different file name, or on older Asterisk versions, the transport needs allow_reload=yes or an Asterisk restart (see the res_pjsip transport options).

Renewals are about to get much more frequent: public TLS certificates are limited to 200 days from March 2026 and will drop to 47 days by 2029 under the CA/Browser Forum ballot SC-081. Every renewal is another chance for the reload step to be missed; see our guide to the 47-day certificate lifetime.

Before you run it

  • The script is read-only. It reads config files and certificates, asks Asterisk for its transports and opens TLS connections. It changes nothing, so there is nothing to back up or revert.
  • Private keys are only used to compare public keys with the certificate; no key material is printed or saved.
  • Run it as root, or as the asterisk user, because FreePBX keys are readable by that user only. As another user the key check reports that the key is not readable.
  • It connects to 127.0.0.1 for transports bound to all addresses, so it does not test your firewall. Test from outside with --check pbx.example.com:5061 on another machine.

Install and first run

curl -fsSLO https://scr.srvscripts.com/sip-tls-certificate-check/sip-tls-cert-check.sh
sha256sum sip-tls-cert-check.sh       # compare with the SHA-256 on this page
bash sip-tls-cert-check.sh                                  # everything Asterisk uses
bash sip-tls-cert-check.sh --name pbx.example.com --warn-days 30
bash sip-tls-cert-check.sh --check sip.provider.example:5061 --json

Exit codes follow the Nagios convention: 0 OK, 1 WARNING (expiring within --warn-days, untrusted or self-signed, wrong host name, served certificate differs from the file, served chain incomplete, certificate not allowed for TLS server use, handshake not completed, encrypted key), 2 CRITICAL (expired or within --crit-days, not valid yet, an expired CA certificate in the chain, key does not match or missing from a combined PEM, no TLS handshake), 3 UNKNOWN (bad option, no openssl, nothing to check).

Tested on a real server

TestResult
FreePBX 17 as installed (WSS on 127.0.0.1:8089)WARNING, exit 1: the default certificate is self-signed
PJSIP TLS transport, private-CA certificate with 9 days leftWARNING, exit 1: expires in 9 days
Renewed certificate copied over the file, no reloadWARNING, exit 1: file 89 days, served 9 days, “renewed on disk but not loaded”
After fwconsole reloadThe transport is OK; only the self-signed WSS warning remains
--name sip.wrong-name.exampleWARNING: does not match host name, for both certificates
Key file replaced by a different key (not reloaded)CRITICAL, exit 2: the key does not belong to the certificate
Same, after a reloadCRITICAL, exit 2: no TLS handshake (connection reset); Asterisk logs “key values mismatch”
Expired certificate loadedCRITICAL, exit 2: expired 3 days ago
--check srvscripts.com:443 --jsonRemote certificate read (45 days left), valid JSON
--quiet from cronPrints only when there is a problem; exit code unchanged

We ran these tests on our FreePBX 17 lab server (Debian 12, Asterisk 22.11, 4 vCPU) on 16 and 17 October 2026 with OpenSSL 3.0. For the test we added a temporary PJSIP TLS transport on port 5062 with certificates from a private test CA, then removed it and restarted Asterisk; the FreePBX WSS certificate was put back exactly as it was. The same cases also run as automated tests against local OpenSSL servers on every change.

Version 1.0.1 (17 October 2026) adds checks an independent review found missing in 1.0.0: a certificate that is not valid yet, an expired intermediate CA certificate (1.0.0 reported OK), a certificate not allowed for TLS server use, and a server that does not send its intermediate certificate. The served chain is now always verified, and the “different certificate” message names the other possible causes. 16 automated fixtures cover these cases with certificates from a test CA; 1.0.0 fails 5 of them. On the rebuilt lab, 1.0.1 gives the same result as 1.0.0 for FreePBX as installed (self-signed WARNING). The screenshots are the real terminal output of 1.0.0; only IP addresses are masked, and 1.0.1 words the “different certificate” message more carefully.

A second review of 1.0.1 found two more gaps, fixed in 1.0.2: when the certificate and the private key were in one PEM file (FreePBX tlscertfile without tlsprivatekey), the key was not compared at all, and a server that sends a valid certificate but then ends the connection, for example because it requires a client certificate, was reported as OK. 1.0.2 compares the key in a combined file too, reports an encrypted key instead of waiting for its password, keeps the connection open for a second so an alert after the handshake is seen, and reports certificate_valid and handshake_complete separately in the JSON. 7 more fixtures (23 in all) cover this; 1.0.1 fails 6 of them, and with the encrypted key it waited until it was killed. On the lab, 1.0.2 gives the same result as before for FreePBX as installed.

SIP TLS certificate check output on FreePBX 17: the certificate file has 89 days left but Asterisk still serves the old certificate with 9 days left (renewed on disk but not reloaded)
The certificate file was renewed, but Asterisk still served the old one until fwconsole reload. Real output from our FreePBX 17 lab, 16 Oct 2026 (server time).
SIP TLS certificate check output: the private key does not belong to the certificate and, after a reload, the PJSIP TLS port completes no TLS handshake
A key that does not match the certificate: after the reload Asterisk could not complete any TLS handshake on the transport. CRITICAL, exit 2.
SIP TLS certificate check output: the PJSIP TLS transport serves a certificate that expired 3 days ago, CRITICAL
An expired certificate on the PJSIP TLS transport: CRITICAL, exit 2.
SIP TLS certificate check output on a fresh FreePBX 17: the WSS certificate on port 8089 is self-signed
FreePBX 17 as installed: the WSS server for WebRTC uses a self-signed certificate, reported as a warning.

Options

OptionMeaning
--name HOSTHost name the certificates must match (default: the transport’s external_signaling_address, then the server’s FQDN)
--check HOST:PORTAlso check a remote TLS endpoint; repeat for several
--ca FILEExtra CA certificates to trust, for a private CA
--warn-days NWarn when a certificate expires within N days (default 21)
--crit-days NCritical within N days (default 7)
--conf DIRAsterisk config folder (default /etc/asterisk)
--jsonJSON output for monitoring systems
-q, --quietPrint nothing when everything is OK

Run it daily from cron

install -m 755 sip-tls-cert-check.sh /usr/local/bin/
cat > /etc/cron.d/sip-tls-cert-check <<'EOF'
15 7 * * * root /usr/local/bin/sip-tls-cert-check.sh --quiet --warn-days 21 | mail -E -s "PBX certificate" ops@example.com
EOF

With --quiet the script prints only when something needs attention, so mail -E sends nothing on good days. In Nagios, Icinga or Zabbix, use the exit code directly or parse the --json output (file_days_left, served_days_left, served_matches_file, certificate_valid, handshake_complete). Run it right after every certificate renewal too, as a check that the reload happened.

Notes and limits

  • The script does not check DTLS certificates set on WebRTC endpoints (dtls_cert_file). Browsers check those by fingerprint rather than by CA, so expiry matters less there.
  • A WSS transport in PJSIP has no certificate of its own: it uses the Asterisk HTTP TLS server, which the script checks.
  • Phones that verify the server need the full chain. If the check reports an untrusted chain for a public certificate, install the full chain file, not the leaf certificate alone.
  • A self-signed certificate works only for clients configured to trust it. Browsers reject it for WebRTC; see our Asterisk WebRTC setup guide and the Asterisk WebRTC documentation.

Related: certificate and key matcher, SSL certificate checker, certificate decoder, automate SSL certificate renewal, PJSIP health check, Asterisk security audit.

The script

sip-tls-cert-check.shDownload
#!/usr/bin/env bash
# SIP TLS and WSS Certificate Check for Asterisk and FreePBX: Expiry, Hostname, Chain, Reload (v1.0.2) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/sip-tls-certificate-check/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
#
# Read-only check of the certificates an Asterisk/FreePBX server uses for encrypted signalling: PJSIP TLS and WSS
# transports (SIP over TLS, WebRTC) and the Asterisk HTTP TLS server that WebRTC phones connect to. For each one it
# checks the certificate FILE (days left, host name, chain, private key match) and the certificate Asterisk actually
# SERVES on its port. A served certificate that differs from the file means the certificate was renewed on disk but
# Asterisk was never reloaded, the classic cause of "phones stopped registering when the old certificate expired".
# Remote endpoints (your SIP provider's TLS port, a provisioning server) can be added with --check.
#
#   bash sip-tls-cert-check.sh                         # discover and check everything on this PBX
#   bash sip-tls-cert-check.sh --name pbx.example.com --warn-days 30
#   bash sip-tls-cert-check.sh --check sip.provider.example:5061 --json
#   0 7 * * * root /usr/local/bin/sip-tls-cert-check.sh --quiet || mail -s "PBX certificate" ops@example.com < /dev/null
#
# Nothing is changed: it reads config files and certificates and opens TLS connections. Private keys are only used
# to compare public keys; nothing secret is printed.
# Exit codes (Nagios style): 0 OK, 1 WARNING, 2 CRITICAL, 3 UNKNOWN (bad option, openssl missing, nothing to check).
# Version 1.0.2 (2026-10-17): second review: the private key is also compared when it sits in the same PEM file as
#   the certificate (1.0.1 skipped that); an encrypted or missing key is reported; a server certificate that verifies
#   is no longer taken as a working connection: an unfinished handshake (for example a server that requires a client
#   certificate) is a WARNING, and the JSON has certificate_valid and handshake_complete.
# Version 1.0.1 (2026-10-17): fixes from an independent review: a certificate that is not valid yet, an expired or
#   not-yet-valid CA in the chain, a certificate not allowed for TLS servers, and a server that does not send its
#   intermediate certificate are now reported (1.0.0 said OK); the served chain is always verified, with the transport's
#   ca_list_file and --ca; the "different certificate" message names other possible causes.
# Version 1.0.0 (2026-10-17): first release. Tested on FreePBX 17.0.33 / Asterisk 22.11 (Debian 12, OpenSSL 3.0) with a
#   PJSIP TLS transport and the FreePBX WSS server: private-CA, self-signed default, 9 days left, expired, mismatched
#   key, renewed on disk but not reloaded. Wrong host name and "nothing listening" are tested against openssl s_server.
set -uo pipefail
export LC_ALL=C
WARN_DAYS=21; CRIT_DAYS=7; NAME=""; CAFILE=""; JSON=0; QUIET=0; CHECKS=(); AST=${ASTERISK_BIN:-asterisk}; CONF=/etc/asterisk; TIMEOUT=8
usage() {
  cat <<'EOF'
Usage: sip-tls-cert-check.sh [options]
  --name HOST          host name the certificates must match (default: the transport's external address,
                       then the machine's FQDN)
  --check HOST:PORT    also check a remote TLS endpoint (repeatable), e.g. your provider's SIP TLS port
  --ca FILE            extra CA certificates to trust (a private CA); system CAs are always trusted
  --warn-days N        warn when a certificate expires within N days (default 21)
  --crit-days N        critical within N days (default 7)
  --conf DIR           Asterisk config folder (default /etc/asterisk)
  --json               JSON output
  -q, --quiet          print nothing when everything is OK
  -h, --help           this help
EOF
}
die() { echo "UNKNOWN: $*" >&2; exit 3; }
HPRE='^[][A-Za-z0-9.:-]+:[0-9]{1,5}$'
while [ $# -gt 0 ]; do
  case "$1" in
    --name) NAME="${2:-}"; [[ "$NAME" =~ ^[A-Za-z0-9.*:-]+$ ]] || die "--name: invalid host name"; shift 2 ;;
    --check) [[ "${2:-}" =~ $HPRE ]] || die "--check needs HOST:PORT"; CHECKS+=("$2"); shift 2 ;;
    --ca) CAFILE="${2:-}"; [ -r "$CAFILE" ] || die "--ca: cannot read $CAFILE"; shift 2 ;;
    --warn-days) [[ "${2:-}" =~ ^[0-9]+$ ]] || die "--warn-days needs a number"; WARN_DAYS=$2; shift 2 ;;
    --crit-days) [[ "${2:-}" =~ ^[0-9]+$ ]] || die "--crit-days needs a number"; CRIT_DAYS=$2; shift 2 ;;
    --conf) CONF="${2:-}"; [ -d "$CONF" ] || die "--conf: no such folder"; shift 2 ;;
    --json) JSON=1; shift ;;
    -q|--quiet) QUIET=1; shift ;;
    -h|--help) usage; exit 0 ;;
    *) usage >&2; die "unknown option: $1" ;;
  esac
done
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
[ "$CRIT_DAYS" -le "$WARN_DAYS" ] || die "--crit-days must not be larger than --warn-days"
TMP=$(mktemp -d) || die "mktemp failed"; trap 'rm -rf "$TMP"' EXIT
STATUS=0; LINES=(); JS=(); NOW=$(date +%s)
bump() { case "$1" in CRIT) [ "$STATUS" -lt 2 ] && STATUS=2 ;; WARN) [ "$STATUS" -lt 1 ] && STATUS=1 ;; UNKNOWN) [ "$STATUS" -lt 2 ] && STATUS=3 ;; esac; }
say() { bump "$1"; LINES+=("  $1 $2"); }
jstr() { local s=${1//\\/\\\\}; s=${s//\"/\\\"}; printf '"%s"' "${s//$'\n'/ }"; }

# ---- discovery: list of "label|host:port|certfile|keyfile|cafile|name" ----
ITEMS=()
default_name() { [ -n "$NAME" ] && { echo "$NAME"; return; }; [ -n "${1:-}" ] && { echo "$1"; return; }; hostname -f 2>/dev/null || hostname; }
connect_host() { case "$1" in 0.0.0.0|::|'['::']'|'') echo 127.0.0.1 ;; *) echo "$1" ;; esac; }
if command -v "$AST" >/dev/null 2>&1 && "$AST" -rx "core show version" >/dev/null 2>&1; then
  while read -r tid; do
    det=$("$AST" -rx "pjsip show transport $tid" 2>/dev/null)
    proto=$(awk '$1=="protocol"{print $3}' <<<"$det"); [[ "$proto" =~ ^(tls|wss)$ ]] || continue
    bind=$(awk '$1=="bind"{print $3}' <<<"$det"); cert=$(awk '$1=="cert_file"{print $3}' <<<"$det"); key=$(awk '$1=="priv_key_file"{print $3}' <<<"$det")
    ca=$(awk '$1=="ca_list_file"{print $3}' <<<"$det"); ext=$(awk '$1=="external_signaling_address"{print $3}' <<<"$det")
    h=${bind%:*}; p=${bind##*:}; [ "$proto" = wss ] && p=""   # WSS rides on the HTTP TLS server, checked below
    if [ -z "$cert" ]; then [ "$proto" = tls ] && say WARN "PJSIP transport $tid (tls) has no cert_file"; continue; fi
    ITEMS+=("PJSIP $proto transport $tid|$( [ -n "$p" ] && echo "$(connect_host "$h"):$p")|$cert|$key|$ca|$(default_name "$ext")")
  done < <("$AST" -rx "pjsip show transports" 2>/dev/null | awk '$1=="Transport:" && $2 !~ /^</ {print $2}')
fi
# Asterisk HTTP TLS (WSS for WebRTC phones). The last value wins, as in Asterisk's own include order.
hcert=""; hkey=""; hbind=""; hen=""
for f in "$CONF/http.conf" "$CONF/http_additional.conf" "$CONF/http_custom.conf"; do
  [ -r "$f" ] || continue
  while IFS='=' read -r k v; do
    k=${k//[[:space:]]/}; v=${v%%;*}; v=${v//[[:space:]]/}
    case "$k" in tlsenable) hen=$v ;; tlsbindaddr) hbind=$v ;; tlscertfile) hcert=$v ;; tlsprivatekey) hkey=$v ;; esac
  done < <(grep -E '^[[:space:]]*(tlsenable|tlsbindaddr|tlscertfile|tlsprivatekey)[[:space:]]*=' "$f")
done
if [[ "$hen" =~ ^(yes|true|1)$ ]] && [ -n "$hcert" ]; then
  hb=${hbind:-0.0.0.0:8089}; hh=${hb%:*}; hp=${hb##*:}; [ "$hh" = "$hb" ] && hp=8089
  ITEMS+=("Asterisk HTTP TLS (WSS/WebRTC)|$(connect_host "$hh"):$hp|$hcert|${hkey:-$hcert}||$(default_name "")")
fi
for c in "${CHECKS[@]}"; do rn=${c%:*}; rn=${rn#[}; rn=${rn%]}; ITEMS+=("Remote $c|$c||||${NAME:-$rn}"); done
[ "${#ITEMS[@]}" -gt 0 ] || die "nothing to check: no TLS transport or HTTP TLS found in Asterisk and no --check given"

days_left() { local e; e=$(openssl x509 -enddate -noout -in "$1" 2>/dev/null | cut -d= -f2); [ -n "$e" ] || return 1; echo $(( ($(date -d "$e" +%s) - NOW) / 86400 )); }
cn_of() { openssl x509 -noout -subject -nameopt multiline -in "$1" 2>/dev/null | awk -F'= ' '/commonName/{print $2; exit}'; }
fp_of() { openssl x509 -noout -fingerprint -sha256 -in "$1" 2>/dev/null | cut -d= -f2; }
host_ok() { if [[ "$2" =~ ^[0-9.]+$|: ]]; then openssl x509 -noout -checkip "$2" -in "$1" 2>/dev/null | grep -q 'does match'; else openssl x509 -noout -checkhost "$2" -in "$1" 2>/dev/null | grep -q 'does match'; fi; }
cabundle() { # transport ca_list_file + --ca -> path of a combined CA file, or nothing (system CAs only)
  [ -z "$1" ] && [ -z "$CAFILE" ] && return 0
  cat ${1:+"$1"} ${CAFILE:+"$CAFILE"} > "$TMP/ca.pem" 2>/dev/null; echo "$TMP/ca.pem"
}
verify_chain() { # cert cafile -> one "code depth message" line per problem; dates checked on every certificate in the chain
  local ca=() out; [ -n "$2" ] && ca=(-CAfile "$2")
  out=$(openssl verify "${ca[@]}" -purpose sslserver -untrusted "$1" "$1" 2>&1) && return 0
  sed -nE 's/^error ([0-9]+) at ([0-9]+) depth lookup: (.*)$/\1 \2 \3/p' <<<"$out" | sort -u; return 1
}
not_before() { local b; b=$(openssl x509 -startdate -noout -in "$1" 2>/dev/null | cut -d= -f2); [ -n "$b" ] && [ "$(date -d "$b" +%s)" -gt "$NOW" ] && echo "$b"; }
left() { if [ "$1" -lt 0 ]; then echo "expired $(( -$1 )) day(s) ago"; else echo "$1 day(s) left"; fi; }
check_days() { # label days
  if [ "$2" -lt 0 ]; then say CRIT "$1 EXPIRED $(( -$2 )) day(s) ago"
  elif [ "$2" -le "$CRIT_DAYS" ]; then say CRIT "$1 expires in $2 day(s)"
  elif [ "$2" -le "$WARN_DAYS" ]; then say WARN "$1 expires in $2 day(s)"; fi
}

for it in "${ITEMS[@]}"; do
  IFS='|' read -r label hp cert key ca name <<<"$it"
  hs=""; LINES+=("$label${hp:+ ($hp)}"); fdays=""; ffp=""; sdays=""; sfp=""; proto=""; selfsigned=0; vcode=""; vtext=""
  if [ -n "$cert" ]; then
    if [ ! -r "$cert" ] || ! openssl x509 -noout -in "$cert" 2>/dev/null; then say CRIT "certificate file $cert is missing or unreadable"
    else
      fdays=$(days_left "$cert"); ffp=$(fp_of "$cert")
      LINES+=("    file $cert: CN=$(cn_of "$cert"), $(left "$fdays")")
      check_days "file $cert" "$fdays"
      nb=$(not_before "$cert") && say CRIT "file certificate is not valid yet (valid from $nb): clients reject it until then"
      host_ok "$cert" "$name" || say WARN "file certificate does not match host name $name"
      selfsigned=0
      [ "$(openssl x509 -noout -subject -nameopt RFC2253 -in "$cert" | cut -d= -f2-)" = "$(openssl x509 -noout -issuer -nameopt RFC2253 -in "$cert" | cut -d= -f2-)" ] && selfsigned=1
      cab=$(cabundle "$ca")
      if ! why=$(verify_chain "$cert" "$cab"); then
        while read -r code depth msg; do
          case "$code" in
            10) [ "$depth" -gt 0 ] && say CRIT "a CA certificate in the file's chain (depth $depth) has expired" ;;   # the leaf itself is reported above
            9) [ "$depth" -gt 0 ] && say CRIT "a CA certificate in the file's chain (depth $depth) is not valid yet" ;;
            18|19) [ "$selfsigned" = 1 ] && say WARN "file certificate is self-signed: only clients told to trust this exact certificate accept it; browsers (WebRTC) and most SIP providers reject it" \
                     || say WARN "file certificate chain ends in a CA that is not trusted here: $msg (add the CA with --ca)" ;;
            26) say WARN "file certificate (depth $depth) is not allowed for TLS server use (extended key usage): $msg" ;;
            "") ;;
            *) say WARN "file certificate chain is not trusted: $msg (add the CA with --ca, or install the full chain)" ;;
          esac
        done <<<"${why:-0 0 unknown error}"
      fi
      if [ -n "$key" ]; then   # also when the key sits in the same PEM file as the certificate
        kfile="private key $key"; [ "$key" = "$cert" ] && kfile="the private key in $key"
        if [ ! -r "$key" ]; then say WARN "private key $key is not readable by this user"
        elif ! timeout 10 openssl pkey -pubout -in "$key" -passin pass: -out "$TMP/kpub.pem" </dev/null >/dev/null 2>&1; then
          if grep -q 'ENCRYPTED' "$key" 2>/dev/null; then say WARN "$kfile is encrypted, so it could not be compared with the certificate"
          elif [ "$key" = "$cert" ]; then say CRIT "no private key found in $key (certificate and key in one file): Asterisk cannot load it"
          else say CRIT "$kfile could not be read as a private key"; fi
        elif ! cmp -s <(openssl x509 -noout -pubkey -in "$cert" 2>/dev/null) "$TMP/kpub.pem"; then
          say CRIT "$kfile does not belong to the certificate: after the next reload TLS handshakes fail (Asterisk logs 'key values mismatch')"
        fi
      fi
    fi
  fi
  if [ -n "$hp" ]; then
    if [[ "$name" =~ ^[0-9.]+$|: ]]; then vopt=(-verify_ip "$name"); else vopt=(-servername "$name" -verify_hostname "$name"); fi
    cab=$(cabundle "$ca")
    # keep the connection open for a second, so an alert the server sends after the handshake (TLS 1.3 client
    # certificate check) is seen; with an immediately closed input openssl would exit 0 before it arrives
    out=$(sleep 1 | timeout "$TIMEOUT" openssl s_client -connect "$hp" "${vopt[@]}" ${cab:+-CAfile "$cab"} -showcerts 2>&1); rc=$?
    awk '/-----BEGIN CERTIFICATE-----/{f=1} f{print} /-----END CERTIFICATE-----/{exit}' <<<"$out" > "$TMP/served.pem"
    if ! openssl x509 -noout -in "$TMP/served.pem" 2>/dev/null; then
      say CRIT "no TLS handshake on $hp: nothing listening, port blocked, or the server cannot load its certificate ($(grep -m1 -iE 'connect|errno|refused|timeout|alert' <<<"$out" | cut -c1-80 || echo "openssl exit $rc"))"
    else
      sdays=$(days_left "$TMP/served.pem"); sfp=$(fp_of "$TMP/served.pem"); proto=$(grep -m1 -oE 'TLSv1(\.[0-3])?' <<<"$out")
      LINES+=("    served on $hp: CN=$(cn_of "$TMP/served.pem"), $(left "$sdays"), ${proto:-TLS}")
      [ "$ffp" = "$sfp" ] || { check_days "served certificate" "$sdays"; nb=$(not_before "$TMP/served.pem") && say CRIT "served certificate is not valid yet (valid from $nb)"; }   # same certificate: already reported above
      if [ -n "$ffp" ] && [ "$ffp" != "$sfp" ]; then
        if [[ "$label" == PJSIP* ]]; then fix="Reload PJSIP: fwconsole reload, or asterisk -rx 'module reload res_pjsip.so' (older Asterisk versions may need allow_reload=yes on the transport or a restart)"
        else fix="Reload the HTTP server: asterisk -rx 'module reload http'"; fi
        say WARN "Asterisk serves a different certificate than $cert. Usually the file was renewed but not reloaded ($fix); a proxy in front, SNI or another configuration can also cause this."
      fi
      # a valid certificate is not a working connection: the handshake must also have completed
      hs=true; alert=$(grep -m1 -oiE 'alert [a-z][a-z ]*|alert number [0-9]+' <<<"$out")
      if [ "$rc" -ne 0 ] || grep -qiE 'certificate required|alert number 116' <<<"$out"; then
        hs=false
        if grep -qiE 'certificate required|alert number 116|Acceptable client certificate CA names|Client Certificate Types' <<<"$out"; then
          say WARN "$hp asks for a client certificate, so the TLS connection was not completed: only the server certificate was checked"
        else say WARN "TLS handshake on $hp did not complete (${alert:-openssl exit $rc}): only the server certificate was checked"; fi
      fi
      vr=$(grep -m1 -oE 'Verify return code: [0-9]+ \([^)]*\)' <<<"$out"); vcode=$(grep -oE '[0-9]+' <<<"$vr" | head -1); vtext=${vr#*(}; vtext=${vtext%)}
      [ -z "$cert" ] && { host_ok "$TMP/served.pem" "$name" || say WARN "served certificate does not match host name $name"; }
      # what a client sees: always judge the served chain, even when the leaf equals the file (the server may omit the intermediate)
      case "${vcode:-x}" in
        0) ;;
        62) { [ -z "$cert" ] || [ "$ffp" = "$sfp" ]; } || say WARN "served certificate does not match host name $name" ;;   # otherwise reported above
        9) not_before "$TMP/served.pem" >/dev/null || say CRIT "served chain: a CA certificate is not valid yet" ;;   # leaf: reported above
        10) [ "${sdays:-1}" -lt 0 ] || say CRIT "served chain: a CA certificate has expired" ;;
        18|19) { [ "$ffp" = "$sfp" ] && [ "${selfsigned:-0}" = 1 ]; } || say WARN "served certificate is self-signed or its CA is not trusted: $vtext" ;;
        20|21) say WARN "served chain is incomplete or its CA is unknown ($vtext): the server probably does not send the intermediate certificate. Point the transport at the full chain file (certificate + intermediates), or add a private CA with --ca" ;;
        x) [ "$hs" = false ] || say WARN "TLS handshake did not complete, so the served chain could not be verified (client certificate required, or the connection was closed)" ;;
        *) say WARN "served certificate fails verification: $vtext" ;;
      esac
      [[ "$proto" =~ ^TLSv1(\.[01])?$ ]] && say WARN "server negotiated $proto: TLS 1.0 and 1.1 are obsolete; set method=tlsv1_2 (or newer)"
    fi
  fi
  JS+=("{\"item\":$(jstr "$label"),\"endpoint\":$(jstr "$hp"),\"file\":$(jstr "$cert"),\"name\":$(jstr "$name"),\"file_days_left\":${fdays:-null},\"served_days_left\":${sdays:-null},\"served_matches_file\":$( [ -n "$ffp" ] && [ -n "$sfp" ] && { [ "$ffp" = "$sfp" ] && echo true || echo false; } || echo null),\"tls\":$(jstr "$proto"),\"served_verify\":$(jstr "${vtext:-}"),\"certificate_valid\":$(case "${vcode:-}" in 0) echo true;; "") echo null;; *) echo false;; esac),\"handshake_complete\":${hs:-null}}")
done

LVL=(OK WARNING CRITICAL UNKNOWN); host=$(hostname -f 2>/dev/null || hostname)
probs=(); for l in "${LINES[@]}"; do [[ "$l" =~ ^\ \ (WARN|CRIT|UNKNOWN)\  ]] && probs+=("${l#  }"); done
summary="${LVL[$STATUS]}: ${#ITEMS[@]} TLS endpoint(s) checked, ${#probs[@]} problem(s)"
if [ "$JSON" = 1 ]; then
  printf '{"host":%s,"status":%s,"warn_days":%s,"crit_days":%s,"items":[%s],"problems":[' "$(jstr "$host")" "$(jstr "${LVL[$STATUS]}")" "$WARN_DAYS" "$CRIT_DAYS" "$(IFS=,; echo "${JS[*]}")"
  for i in "${!probs[@]}"; do [ "$i" -gt 0 ] && printf ','; jstr "${probs[$i]}"; done; printf '],"text":%s}\n' "$(jstr "$host $summary")"
elif [ "$QUIET" = 0 ] || [ "$STATUS" -gt 0 ]; then
  echo "SIP TLS certificate check on $host"; printf '%s\n' "${LINES[@]}"; echo "$summary"
fi
exit "$STATUS"
Version 1.0.2 · SHA-256 200410d6d2b550bd8f742c8e08b0ee8779dd0507f6bc9aef2edbeeaa8d5ff858
Download and verify on Linux or macOS
curl -fsSL -o sip-tls-cert-check.sh https://scr.srvscripts.com/sip-tls-certificate-check/sip-tls-cert-check.sh && curl -fsSL https://scr.srvscripts.com/sip-tls-certificate-check/sip-tls-cert-check.sh.sha256 | sha256sum -c
Download and verify in Windows PowerShell
Invoke-WebRequest -Uri 'https://scr.srvscripts.com/sip-tls-certificate-check/sip-tls-cert-check.sh' -OutFile 'sip-tls-cert-check.sh'; if ((Get-FileHash 'sip-tls-cert-check.sh' -Algorithm SHA256).Hash -eq '200410D6D2B550BD8F742C8E08B0EE8779DD0507F6BC9AEF2EDBEEAA8D5FF858') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }
Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.
Also on GitHub: github.com/srvscripts/scripts

Frequently asked questions

How do I check the SIP TLS certificate on Asterisk?

Run openssl s_client -connect 127.0.0.1:5061 -showcerts on the PBX to see the certificate Asterisk serves, and openssl x509 -enddate -noout -in on the file named by cert_file in the transport. This script does both for every TLS transport and the WSS server and compares them.

I renewed the certificate but phones still see the old one. Why?

Asterisk loads the certificate when the transport starts or reloads. Run fwconsole reload (FreePBX) or asterisk -rx "module reload res_pjsip.so". On Asterisk 22 that reloads the certificate if the file name is unchanged; otherwise set allow_reload=yes on the transport or restart Asterisk.

Which port does WebRTC use on FreePBX?

WebRTC phones connect over secure WebSocket (WSS) to the Asterisk HTTP TLS server, port 8089 by default. Its certificate is set by tlscertfile in the HTTP configuration, not in the PJSIP transport.

Can I use a self-signed certificate for SIP TLS?

Only with clients you configure to trust it, such as desk phones you provision with the certificate. Browsers and most SIP providers reject self-signed certificates, so use a certificate from a public CA for WebRTC and provider trunks.

Does the script change anything on the server?

No. It reads configuration and certificates, asks Asterisk for its transports and opens TLS connections. Private keys are only compared with the certificate and never printed.

Share this script

Changelog

  • 1.0.2 — The private key is also compared when it is in the same PEM file as the certificate; an encrypted or missing key is reported; an unfinished TLS handshake (for example a server that requires a client certificate) is a WARNING, and the JSON has certificate_valid and handshake_complete. Found in a second independent review.
  • 1.0.1 — Checks a certificate that is not valid yet, an expired CA certificate in the chain, a certificate not allowed for TLS server use and a served chain without its intermediate; the served chain is always verified; the different-certificate message names the other possible causes (proxy, SNI, another file). Found in an independent review.
  • 1.0.0 — Initial release

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.