You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
606 lines
15 KiB
606 lines
15 KiB
#!/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 |
|
|
|
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 " -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 autodiscover |
|
# -------------------------------------------------------------------- # |
|
|
|
# TODO |
|
|
|
# -------------------------------------------------------------------- # |
|
# Check autoconfig |
|
# -------------------------------------------------------------------- # |
|
|
|
# TODO |
|
|
|
# -------------------------------------------------------------------- # |
|
|
|
# main |
|
|
|
PARSED_ARGS=$(getopt -n ${SCRIPT_NAME} -o hxsmk --longoptions help,mx,spf,dmarc,dkim -- "$@") |
|
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 |
|
;; |
|
--) # 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 ] |
|
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 |
|
|
|
exit ${status_code}
|
|
|