You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

664 lines
16 KiB
Bash

#!/bin/bash
SCRIPT_NAME="mailserver-moulinette"
MASTER=42l.fr
TTL=86400 # 1 day
CHECK_ALL=1
CHECK_MX=0
CHECK_SPF=0
CHECK_DMARC=0
CHECK_DKIM=0
CHECK_AUTOCONFIG=0
status_code=0
# example domain: courrier.dev
usage()
{
echo "Usage: ${SCRIPT_NAME} [options] yourdomain.com"
echo
echo "Check if the provided domain name has all the requirements to send"
echo "and receive email from 42l's mailserver. If used without options"
echo "all the checks are run in turn. Otherwise, only the specified"
echo "checks are run."
echo
echo "Options:"
echo " -x, --mx check MX configuration"
echo " -s, --spf check SPF configuration"
echo " -m, --dmarc check DMARC configuration"
echo " -k, --dkim check DKIM configuration"
echo " -o, --autoconfig check email autoconfiguration"
echo " -h, --help display this help"
exit 2
}
compute_status_code()
{
local last_cmd=$1
if [ ${last_cmd} -ne 0 ]
then
status_code=${last_cmd}
fi
}
client_fqdn()
{
local client=$1
if [ "${client: -1}" = "." ]
then
echo "${client}"
else
echo "${client}."
fi
}
is_valid_rcode()
{
local rcode=$1
local client=$2
local query=$3
if [ "$rcode" != "NOERROR" ]
then
if [ "$rcode" = "NXDOMAIN" ]
then
echo "Error: $client does not exist"
else
echo "Error: received $rcode to query '$query'"
fi
return 1
fi
return 0
}
dig_rcode()
{
local args=$@
local header=$(dig +noall +comment $args | grep HEADER)
local rcode=$(echo $header | cut -d ',' -f 2 | cut -d ':' -f 2 | tr -d ' ')
echo $rcode
}
dig_answer()
{
local args=$@
dig +noall +answer $args
}
dig_short()
{
local args=$@
dig +short $args
}
dig_noerror()
{
local rcode=$1
if [ "$rcode" != "NOERROR" ]
then
echo $rcode
return 1
fi
return 0
}
# -------------------------------------------------------------------- #
# Check MX
# -------------------------------------------------------------------- #
# CHECK 1: dig MX +noall +answer courrier.dev
# --> At least one entry must be "mail.42l.fr." and have a weight of "2"
# --> It must be the only entry.
check_mx()
{
echo "Check the MX record ... "
local client=$1
DNS_RECORD_MX="2 mail.42l.fr."
DNS_RECORD_MX_HOST="mail.42l.fr."
DNS_RECORD_MX_REGEX="[0-9]+[[:space:]]mail\.42l\.fr\."
DNS_RECORD_MX_PRIORITY_REGEX="[0-9]+"
CLIENT_DNS_RECORD_MX="$(client_fqdn $client) $TTL IN MX ${DNS_RECORD_MX}"
local query="$client MX"
local rcode=$(dig_rcode $query)
# received DNS error
if ! is_valid_rcode "$rcode" "$client" "$query"
then
return 1
else
local answer_short=$(dig_short $query)
# empty MX record
if [ -z "${answer_short}" ]
then
echo "Error: empty MX record"
echo "You need to add the following line to your DNS zone :"
echo "${CLIENT_DNS_RECORD_MX}"
return 1
# MX record does not contain the full value
elif [[ ! "${answer_short}" =~ ${DNS_RECORD_MX_REGEX} ]]
then
# MX record does not contain the right host
if [[ ! "${answer_short}" =~ ${DNS_RECORD_MX_HOST} ]]
then
echo "Error: wrong MX record, should point on ${DNS_RECORD_MX_HOST}"
echo "Your MX record should be :"
echo "${CLIENT_DNS_RECORD_MX}"
return 1
fi
# MX record does not contain an integer priority
if [[ ! "${answer_short}" =~ ${DNS_RECORD_MX_PRIORITY_REGEX} ]]
then
echo "Error: MX weight should be an integer"
echo "Your MX record should be something like :"
echo "${CLIENT_DNS_RECORD_MX}"
return 1
fi
# both matched - shouldn't happen here
echo "OK"
else
# more than one MX record
if [ $(echo "${answer_short}" | wc -l) -gt 1 ]
then
echo "Error: too many MX records"
echo "There should be only one MX record"
return 1
else
echo "OK"
fi
fi
fi
return 0
}
# -------------------------------------------------------------------- #
# Check SPF
# -------------------------------------------------------------------- #
# CHECK 1: dig TXT +noall +answer courrier.dev
# --> At least one entry must be "v=spf1 mx a:mail.42l.fr a:42l.fr ~all"
# --> It must be the only entry containing "v=spf1"
check_spf()
{
echo "Check SPF ... "
local client=$1
DNS_RECORD_TXT_SPF="\"v=spf1 mx a:mail.42l.fr a:42l.fr ~all\""
DNS_RECORD_TXT_SPF_ENTRY="v=spf1"
CLIENT_DNS_RECORD_TXT_SPF="$(client_fqdn $client) $TTL IN TXT ${DNS_RECORD_TXT_SPF}"
local query="$client TXT"
local rcode=$(dig_rcode $query)
# received DNS error
if ! is_valid_rcode "$rcode" "$client" "$query"
then
return 1
else
local answer_short=$(dig_short $query)
# empty TXT record
if [ -z "${answer_short}" ]
then
echo "Error: empty TXT record"
echo "You need to add the following line to your DNS zone :"
echo "${CLIENT_DNS_RECORD_TXT_SPF}"
return 1
# TXT record does not contain the full value
elif [[ ! "${answer_short}" =~ ${DNS_RECORD_TXT_SPF} ]]
then
# TXT record does not contain SPF entry
if [[ ! "${answer_short}" =~ ${DNS_RECORD_TXT_SPF_ENTRY} ]]
then
echo "Error: missing TXT record with SPF"
echo "You need to add the following TXT record to your DNS zone :"
echo "${CLIENT_DNS_RECORD_TXT_SPF}"
return 1
# TXT record with SPF not exactly equal to the required value
else
echo "Error: wrong TXT record configuration for SPF"
echo "Found:" $(echo "${answer_short}" | grep "${DNS_RECORD_TXT_SPF_ENTRY}")
echo "You need to replace this TXT record with the following :"
echo "${CLIENT_DNS_RECORD_TXT_SPF}"
return 1
fi
else
# more than one TXT record with SPF
if [ $(echo "${answer_short}" | grep ${DNS_RECORD_TXT_SPF_ENTRY} | wc -l) -gt 1 ]
then
echo "Error: too many TXT records defining ${DNS_RECORD_TXT_SPF_ENTRY}"
echo "There should be only one TXT record with ${DNS_RECORD_TXT_SPF_ENTRY}"
return 1
else
echo "OK"
fi
fi
fi
return 0
}
# CHECK 2: dig SPF +noall +answer courrier.dev
# --> This query must return nothing
check_spf_empty_record()
{
local client=$1
local query="$client SPF"
local rcode=$(dig_rcode $query)
# received DNS error
if ! is_valid_rcode "$rcode" "$client" "$query"
then
return 1
else
local answer_short=$(dig_short $query)
# not empty SPF record
if [ ! -z "${answer_short}" ]
then
echo "Error: SPF record should be empty"
echo "Please remove the following record from your DNS configuration :"
echo "$(dig_answer $query)"
return 1
else
echo "OK"
fi
fi
return 0
}
# -------------------------------------------------------------------- #
# Check DMARC
# -------------------------------------------------------------------- #
# CHECK 1: dig TXT +noall +answer _dmarc.courrier.dev
# --> At least one entry must be "v=DMARC1;p=quarantine;rua=mailto:dmarc@courrier.dev;pct=100;aspf=r;adkim=r;"
# --> It must be the only entry.
# Informations on DMARC (RFC 7489)
# adkim: strict (s) or relaxed (r) DKIM Identifier Alignment mode
# aspf: strict (s) or relaxed (r) SPF Identifier Alignment mode
# p: policy to be enacted by the receiver at the request of the domain owner
# none: owner request no specific action to be taken
# quarantine: owner wishes for receiver to mark failing mails as suspicious
# reject: owner wishes for receiver to discard all failing mails
# pct: percentage of messages from the owner mails to which the policy is to be applied
# rua: addresses to which aggregate feedback is to be sent
check_dmarc()
{
echo "Check DMARC ... "
local _client=$1
local client="_dmarc.${_client}"
# all those tags are required and the values are fixed by 42l policy
# see <https://git.42l.fr/neil/mailserver-moulinette/issues/2>
DMARC_TAG_VERSION="v=DMARC1;" # required
DMARC_TAG_POLICY="p=quarantine;" # required and fixed by 42l
DMARC_TAG_RUA="rua=mailto:dmarc@${_client};" # required and fixed by 42l
DMARC_TAG_PERCENTAGE="pct=100;" # required and fixed by 42l
DMARC_TAG_ASPF="aspf=r;" # required and fixed by 42l
DMARC_TAG_ADKIM="adkim=r;" # required and fixed by 42l
DNS_RECORD_TXT_DMARC="\"${DMARC_TAG_VERSION}${DMARC_TAG_POLICY}${DMARC_TAG_RUA}${DMARC_TAG_PERCENTAGE}${DMARC_TAG_ASPF}${DMARC_TAG_ADKIM}\""
#DNS_RECORD_TXT_DMARC="v=DMARC1;p=quarantine;rua=mailto:dmarc@${_client};pct=100;aspf=r;adkim=r;"
CLIENT_DNS_RECORD_TXT_DMARC="$(client_fqdn $client) $TTL IN TXT ${DNS_RECORD_TXT_DMARC}"
local query="$client TXT"
local rcode=$(dig_rcode $query)
if ! is_valid_rcode "$rcode" "$client" "$query"
then
echo "Missing DMARC configuration"
echo "Add the following entry to your DNS configuration :"
echo "${CLIENT_DNS_RECORD_TXT_DMARC}"
return 1
else
local answer_short=$(dig_short $query)
# empty TXT record
if [ -z "${answer_short}" ]
then
echo "Error: empty TXT record"
echo "You need to add the following line to your DNS zone :"
echo "${CLIENT_DNS_RECORD_TXT_DMARC}"
return 1
# TXT record does not contain DMARC TXT RECORD
elif [[ ! "${answer_short}" =~ "${DMARC_TAG_VERSION}" ]]
then
echo "Error: missing DMARC in TXT record"
echo "You need to add the following line to your DNS zone :"
echo "${CLIENT_DNS_RECORD_TXT_DMARC}"
return 1
else
# DMARC TXT record does not match
if [[ ! "${answer_short}" =~ "${DNS_RECORD_TXT_DMARC}" ]]
then
for tag in "${DMARC_TAG_POLICY}" "${DMARC_TAG_RUA}" "${DMARC_TAG_PERCENTAGE}" "${DMARC_TAG_ASPF}" "${DMARC_TAG_ADKIM}"
do
if [[ ! "${answer_short}" =~ "$tag" ]]
then
echo "Error: wrong or missing value for tag '$(echo $tag | cut -d \= -f 1)='"
echo "Should be '$tag'"
echo "Found: $(echo ${answer_short})"
return 1
fi
done
else
# there should be only one DMARC TXT record
if [ $(echo ${answer_short} | grep ${DMARC_TAG_VERSION} | wc -l) -gt 1 ]
then
echo "Error: too many TXT records defining DMARC"
echo "There should be only one TXT record with ${DMARC_TAG_VERSION}"
return 1
else
echo "OK"
fi
fi
fi
fi
return 0
}
# -------------------------------------------------------------------- #
# Check DKIM
# -------------------------------------------------------------------- #
# CHECK 1: dig TXT +noall +answer mail._domainkey.courrier.dev
# --> At least one entry must start with "v=DKIM1; h=sha256; k=rsa;".
# --> The entry must be multipart.
# --> It must be the only entry.
# Informations on DKIM (RFC 6376 section 3.6.1)
# v= DKIM version
# h= hash algorithms
# k= key type
# p= public key base64 encoded
check_dkim()
{
echo "Check DKIM ..."
local _client=$1
local client="mail._domainkey.${_client}"
BASE64_REGEX="[0-9a-zA-Z+/]+"
# all those tags are required and the values are fixed by 42l policy
# see <https://git.42l.fr/neil/mailserver-moulinette/issues/3>
DKIM_TAG_VERSION="v=DKIM1;" # required
DKIM_TAG_HASH="h=sha256;" # required and fixed by 42l
DKIM_TAG_KEY="k=rsa;" # required and fixed by 42l
DKIM_TAG_PUBLIC_KEY_REGEX="p=${BASE64_REGEX}\"([[:space:]]\"${BASE64_REGEX}+\")+"
DKIM_TAG_PUBLIC_KEY_SPLIT_REGEX="p=${BASE64_REGEX}\"[[:space:]]\"${BASE64_REGEX}+\""
DNS_RECORD_TXT_DKIM="v=DKIM1; h=sha256; k=rsa;"
CLIENT_DNS_RECORD_TXT_DKIM="$(client_fqdn $client) $TTL IN TXT \"${DNS_RECORD_TXT_DKIM} p=the-public-key-base64\""
local query="$client TXT"
local rcode=$(dig_rcode $query)
if ! is_valid_rcode "$rcode" "$client" "$query"
then
echo "Missing DKIM configuration"
echo "Add the following entry to your DNS configuration :"
echo "${CLIENT_DNS_RECORD_TXT_DKIM}"
return 1
else
local answer_short=$(dig_short $query)
# empty TXT record
if [ -z "${answer_short}" ]
then
echo "Error: empty TXT record"
echo "You need to add the following line to your DNS zone :"
echo "${CLIENT_DNS_RECORD_TXT_DKIM}"
echo "The public key can be retrieved from the mail server administrator."
return 1
# TXT record does not contain DKIM TXT RECORD
elif [[ ! "${answer_short}" =~ "${DKIM_TAG_VERSION}" ]]
then
echo "Error: missing DKIM in TXT record"
echo "You need to add the following line to your DNS zone :"
echo "${CLIENT_DNS_RECORD_TXT_DKIM}"
return 1
else
# lookig for missing tag in DKIM TXT record
for tag in "${DKIM_TAG_VERSION}" "${DKIM_TAG_HASH}" "${DKIM_TAG_KEY}"
do
if [[ ! "${answer_short}" =~ "$tag" ]]
then
echo "Error: wrong or missing value for tag '$(echo $tag | cut -d \= -f 1)='"
echo "Should be '$tag'"
echo "Found: $(echo ${answer_short})"
return 1
fi
done
# checking that DKIM public key is base64 only
if [[ ! "${answer_short}" =~ ${DKIM_TAG_PUBLIC_KEY_REGEX} ]]
then
echo "Error: DKIM public key contains unauthorized characters, should be base64 only"
echo "Found: $(echo ${answer_short})"
return 1
fi
# checking that the public key is split at least in two
if [[ ! "${answer_short}" =~ ${DKIM_TAG_PUBLIC_KEY_SPLIT_REGEX} ]]
then
echo "Error: DKIM public key should be split in half at least"
echo ${answer_short}
echo "Found: $(echo ${answer_short})"
return 1
fi
# there should be only one DKIM TXT record
if [ $(echo ${answer_short} | grep ${DKIM_TAG_VERSION} | wc -l) -gt 1 ]
then
echo "Error: too many TXT records defining DKIM"
echo "There should be only one TXT record with ${DKIM_TAG_VERSION}"
return 1
fi
# if we reached here, then everything is fine
echo "OK"
fi
fi
return 0
}
# -------------------------------------------------------------------- #
# Check email autoconfiguration
# -------------------------------------------------------------------- #
# 2 CHECKS: query CNAME for autodiscover.<domain> and autoconfig.<domain>
# --> both should return autodiscover.42l.fr
check_autoconfig()
{
echo "Check email autoconfiguration ..."
local _client=$1
local query_result=0
for label in autoconfig autodiscover
do
echo "Checking entry $label.$_client ..."
local client="$label.${_client}"
DNS_RECORD_CNAME_HOST="autodiscover.42l.fr"
DNS_RECORD_CNAME_REGEX="autodiscover\.42l\.fr.?$"
CLIENT_DNS_RECORD_AUTOCONFIG="$(client_fqdn $client) $TTL IN CNAME ${DNS_RECORD_CNAME_HOST}"
local query="$client CNAME"
local rcode=$(dig_rcode $query)
# received DNS error
if ! is_valid_rcode "$rcode" "$client" "$query"
then
echo "You may need to add the following line to your DNS zone :"
echo "${CLIENT_DNS_RECORD_AUTOCONFIG}"
query_result=1
else
local answer_short=$(dig_short $query)
# empty CNAME record
if [ -z "${answer_short}" ]
then
echo "Error: empty CNAME record"
echo "You need to add the following line to your DNS zone :"
echo "${CLIENT_DNS_RECORD_AUTOCONFIG}"
query_result=1
# CNAME record does not contain the full value
elif [[ ! "${answer_short}" =~ ${DNS_RECORD_CNAME_REGEX} ]]
then
echo "Error: wrong CNAME record, should point on ${DNS_RECORD_CNAME_HOST}"
echo "Your CNAME record should be :"
echo "${CLIENT_DNS_RECORD_AUTOCONFIG}"
query_result=1
else
echo "OK"
fi
fi
done
return $query_result
}
# main
PARSED_ARGS=$(getopt -n ${SCRIPT_NAME} -o hxsmko --longoptions help,mx,spf,dmarc,dkim,autoconfig -- "$@")
VALID_ARGS=$?
if [ ${VALID_ARGS} -ne 0 ]
then
usage
fi
# reset script args
eval set -- "${PARSED_ARGS}"
while :
do
case "$1" in
-h | --help)
usage
;;
-x | --mx)
CHECK_MX=1
shift
;;
-s | --spf)
CHECK_SPF=1;
shift
;;
-m | --dmarc)
CHECK_DMARC=1;
shift
;;
-k | --dkim)
CHECK_DKIM=1;
shift
;;
-o | --autoconfig)
CHECK_AUTOCONFIG=1;
shift
;;
--) # reached the end of the arguments, break out of the loop
shift
break
;;
*)
echo "Unexpected option: $1 - this should not happen."
usage
;;
esac
done
if [ $# -ne 1 ]
then
echo "${SCRIPT_NAME}: missing domain"
usage
fi
CLIENT=$1
if [ ${CHECK_MX} -eq 1 -o ${CHECK_SPF} -eq 1 -o ${CHECK_DMARC} -eq 1 -o ${CHECK_DKIM} -eq 1 -o ${CHECK_AUTOCONFIG} -eq 1 ]
then
CHECK_ALL=0
fi
# check if the required binaries are installed
for bin in dig
do
if ! command -v $bin > /dev/null
then
echo "Missing binary: please install \`$bin' to use this utility."
exit 1
fi
done
if [ ${CHECK_MX} -eq 1 -o ${CHECK_ALL} -eq 1 ]
then
check_mx $CLIENT
compute_status_code $?
fi
if [ ${CHECK_SPF} -eq 1 -o ${CHECK_ALL} -eq 1 ]
then
check_spf $CLIENT
compute_status_code $?
check_spf_empty_record $CLIENT
compute_status_code $?
fi
if [ ${CHECK_DMARC} -eq 1 -o ${CHECK_ALL} -eq 1 ]
then
check_dmarc $CLIENT
compute_status_code $?
fi
if [ ${CHECK_DKIM} -eq 1 -o ${CHECK_ALL} -eq 1 ]
then
check_dkim $CLIENT
compute_status_code $?
fi
if [ ${CHECK_AUTOCONFIG} -eq 1 -o ${CHECK_ALL} -eq 1 ]
then
check_autoconfig $CLIENT
compute_status_code $?
fi
exit ${status_code}