#!/bin/bash
# Great Dane -- a multi-cert capable bulk downloading DANE-EE TLSA RR generator
#
# This software is copyright (C) 2024 by Veit Wahlich <veit(at)schoen-technisch(dot)de>.
#
# This is free software; you can redistribute it and/or modify it under
# the terms of the MIT License.

VERSION=0.3.1

SIGALGS_EC="ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512:ed25519:ed448:ECDSA+SHA224:ECDSA+SHA1"
SIGALGS_RSA="RSA+SHA256:RSA+SHA384:RSA+SHA512:RSA+SHA224:RSA+SHA1:rsa_pss_pss_sha256:rsa_pss_pss_sha384:rsa_pss_pss_sha512:rsa_pss_rsae_sha256:rsa_pss_rsae_sha384:rsa_pss_rsae_sha512"
SIGALGS_DSA="DSA+SHA256:DSA+SHA384:DSA+SHA512:DSA+SHA224:DSA+SHA1"

export LANG=C

function main(){
  hosts="${1,,}"
  hosts="${hosts//[^a-z0-9., -]/}"
  ports="${2//[^0-9, ]/}"
  starttls="${3//[^a-z-]/}"
  [ -z "$hosts" ] && show_syntax
  [ -z "$ports" ] && ports="443"
  hosts="${hosts//,/ }"
  ports="${ports//,/ }"
  types="EC RSA DSA"
  n_addrs=0
  for host in $hosts; do
    n_haddrs="$(words $(get_addr_by_hostname $host))"
    n_addrs="$((n_addrs+n_haddrs))"
  done
  n_ports="$(words $ports)"
  n_types="$(words $types)"
  n="$((n_addrs * n_ports * n_types))"
  c=0
  out_rrs=""
  for host in $hosts; do
    for addr in $(get_addr_by_hostname "$host"); do
      for port in $ports; do
        for type in $types; do
          prefix="$(printf "[% 3u%%]  " "$((c*100/n))")"
          rr="$(proc_cert "$host" "$addr" "$port" "$type" "$starttls" "$prefix")"
          if [ -n "$rr" ]; then
            out_rrs+="$rr"
            out_rrs+=$'\n'
          fi
          ((c++))
        done
      done
    done
  done
  if [ -n "$out_rrs" ]; then
    echo -e "\nThe following DANE-EE TLSA RRs have been generated:" >&2
    echo "$(awk '!x[$0]++' <<<"$out_rrs")"
    exit 0
  else
    echo -e "\nNo DANE-EE TLSA RRs could be generated." >&2
    exit 1
  fi
}

function words(){
  echo $#
}

function show_syntax(){
  echo "Great Dane $VERSION -- a multi-cert capable bulk downloading DANE-EE TLSA RR generator" >&2
  echo >&2
  echo "Great Dane generates DANE-EE TLSA resource records to be published in" >&2
  echo "DNSSEC-enabled DNS zones, from certificates directly downloaded via TLS" >&2
  echo "services." >&2
  echo "The software is multi-cert and multi-home capable, so it will not just download" >&2
  echo "the first certificate supplied, but will actively download EC as well as RSA" >&2
  echo "and DSA certificates from all IP addresses a hostname is associated with," >&2
  echo "extract the public keys and generate separate RRs for them." >&2
  echo "It can work in bulk scanning multiple hostnames and/or TCP ports at once." >&2
  echo "STARTTLS is available for any service supported by OpenSSL." >&2
  echo >&2
  echo "Syntax: $0 <hostname(s)> [<tcp port(s)> [<starttls mode>]]" >&2
  echo >&2
  echo "<hostname(s)> is a comma- or space-separated list of hostnames to process." >&2
  echo >&2
  echo "<tcp port(s)> is a comma- or space-separated list of numerical TCP ports to allow processing of multiple ports in a single run." >&2
  echo "If <tcp port(s)> is omitted, port 443 (HTTPS) will be used." >&2
  echo >&2
  echo "<starttls mode> is a STARTTLS mode to use when downloading certs instead of the default direct TLS mode." >&2
  echo "If <starttls mode> is omitted and <tcp port(s)> contains well-known plain text port with (optional) STARTTLS support, <starttls mode> will be auto-set accordingly for these ports." >&2
  echo "To disable STARTTLS for well-known plain text ports and force direct TLS mode, use 'direct' as <starttls mode>.">&2
  echo >&2
  echo "Valid values for <starttls mode> are:" >&2
  openssl s_client -starttls -help 2>&1 | grep -v '^s_client' >&2
  echo >&2
  echo "Example 1: Generate DANE-EE RRs for the SMTP, POP3, IMAP, SMTPS, Submission, IMAPS, POP3S and Sieve services on host mail.example.org:" >&2
  echo "  $0 mail.example.org 25,110,143,465,587,993,995,4190" >&2
  echo >&2
  echo "Example 2: Generate DANE-EE RRs for the HTTPS service on hosts example.org, www.example.org, example.com and www.example.com:">&2
  echo "  $0 \"example.org www.example.org example.com www.example.com\" 443">&2
  echo >&2
  echo "Example 3: Generate DANE-EE RRs for the services on ports 25 and 2525 on host mx.example.net using STARTTLS mode smtp:">&2
  echo "  $0 mx.example.net \"25 2525\" smtp">&2
  echo >&2
  echo "Bugs? Veit Wahlich <veit(at)schoen-technisch(dot)de>" >&2
  exit 10
}

function download_cert(){
  c_host="$1"
  c_addr="$2"
  c_port="$3"
  c_sigalgs="$4"
  c_starttls="$5"
  [ -n "$c_starttls" ] && c_starttls="-starttls $c_starttls"
  echo \
  | openssl s_client $c_starttls -connect "[$c_addr]":"$c_port" -servername "$c_host" -sigalgs "$c_sigalgs" -prexit 2>/dev/null \
  | openssl x509 -outform PEM 2>/dev/null
}

function proc_cert(){
  c_host="$1"
  c_addr="$2"
  c_port="$3"
  c_type="$4"
  c_starttls="$5"
  c_prefix="$6"
  if [ -z "$c_starttls" ]; then
    c_starttls="$(get_starttls_by_port "$c_port")"
  elif [ "$c_starttls" = 'direct' ]; then
    c_starttls=""
  fi
  case "$c_type" in
    EC)
      c_sigalgs="$SIGALGS_EC"
      ;;
    RSA)
      c_sigalgs="$SIGALGS_RSA"
      ;;
    DSA)
      c_sigalgs="$SIGALGS_DSA"
      ;;
    *)
      return
  esac
  cert_file="$(mktemp)" || fatal "Unable to create temp file."
  out="${prefix}Scanning ${c_host}[$c_addr]:$c_port/tcp"
  if [ -n "$c_starttls" ]; then
    out+=" (STARTTLS mode '$c_starttls')..."
  else
    out+=" (direct TLS mode)..."
  fi
  echo -n "$out" >&2
  download_cert "$c_host" "$c_addr" "$c_port" "$c_sigalgs" "$c_starttls" >"$cert_file"
  echo -en "\r$(sed -e 's/./ /g' <<<"$out")\r" >&2
  if [ -s "$cert_file" ]; then
    echo "${c_host}[$c_addr]:$c_port/tcp : $(gen_cert_summary "$c_type" "$cert_file")" >&2
    echo "$(gen_tlsa_rr "$c_host" "$c_port" "$cert_file") $(gen_pubkey_comment "$c_type" "$cert_file")"
  fi
  rm -f "$cert_file"
}

function get_addr_by_hostname(){
  getent ahosts "$@" | cut -d' ' -f1 | awk '!x[$0]++' | tr '\n' ' '
}

function get_starttls_by_port(){
  c_port="$1"
  case "$c_port" in
    25|587) echo "smtp";;
    110)    echo "pop3";;
    143)    echo "imap";;
    21)     echo "ftp";;
    5222)   echo "xmpp";;
    5269)   echo "xmpp-server";;
    23)     echo "telnet";;
    6667)   echo "irc";;
    3306)   echo "mysql";;
    5432)   echo "postgres";;
    24)     echo "lmtp";;
    119)    echo "nntp";;
    4190)   echo "sieve";;
    389)    echo "ldap";;
  esac
}

function get_hash_pubkey_sha256(){
  c_cert="$1"
  openssl x509 -in "$c_cert" -pubkey -noout \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -hex -r \
  | cut -d' ' -f 1
}

function get_cert_subject(){
  c_cert="$1"
  openssl x509 -in "$c_cert" -noout -subject -nameopt esc_2253,esc_ctrl,esc_msb,utf8,dump_nostr,dump_der,use_quote,sep_comma_plus_space,space_eq,sname \
  | sed -e 's/^ *subject=//'
}

function get_cert_san(){
  c_cert="$1"
  openssl x509 -in "$c_cert" -noout -ext subjectAltName \
  | grep '^ *DNS:' \
  | sed -e 's/^ *//; s/DNS://g'
}

function get_pubkey_type(){
  c_type="$1"
  c_cert="$2"
  echo -n "$c_type"
  openssl x509 -in "$c_cert" -pubkey -noout \
  | openssl pkey -pubin -noout -text_pub \
  | sed -n 's/^Public-Key: (\([0-9]\+\) *bit)/-\1/p; s/^ASN1 OID: \(.*\)$/(\1)/p' \
  | tr '\n' ' '
}

function gen_tlsa_rr(){
  c_host="$1"
  c_port="$2"
  c_cert="$3"
  hash="$(get_hash_pubkey_sha256 "$c_cert")"
  if [ -n "$hash" ]; then
    echo "_${c_port}._tcp.${c_host}. IN TLSA 3 1 1 $hash"
  fi
}

function gen_cert_summary(){
  c_type="$1"
  c_cert="$2"
  echo "$(get_pubkey_type "$c_type" "$c_cert")certificate with subject '$(get_cert_subject "$c_cert")' and SANs '$(get_cert_san "$c_cert")'"
}

function gen_pubkey_comment(){
  c_type="$1"
  c_cert="$2"
  echo "; DANE-EE $(get_pubkey_type "$c_type" "$c_cert")pubkey SHA2-256 hash"
}

function fatal(){
  msg="$1"
  exitcode="$2"
  [ -z "$exitcode" ] && exitcode=1
  echo "FATAL: $msg" >&2
  exit "$exitcode"
}

main "$@"
