docker-mailserver/test/test_helper/tls.bash

134 lines
4.6 KiB
Bash
Raw Normal View History

tests(feat): Complete rewrite of letsencrypt tests (#2286) * chore: Normalize container setup Easier to grok what is different between configurations. - Container name usage replaced with variable - Volumes defined earlier and redeclared when relevant (only real difference is `VOLUME_LETSENCRYPT`) - Contextual comment about the `acme.json` copy. - Quoting `SSL_TYPE`, `SSL_DOMAIN` and `-h` values for syntax highlighting. - Moved `-t` and `${NAME}` to separate line. - Consistent indentation. * chore: DRY test logic Extracts out repeated test logic into methods * chore: Scope configs to individual test cases (1/3) - Preparation step for shifting out the container configs to their own scoped test cases. Split into multiple commits to ease reviewing by diffs for this change. - Re-arrange the hostname and domain configs to match the expected order of the new test cases. - Shuffle the hostname and domainname grouped tests into tests per container config scope. - Collapse the `acme.json` test cases into single test case. * chore: Scope configs to individual test cases (2/3) - Shifts the hostname and domainname container configs into their respective scoped test cases. - Moving the `acme.json` container config produces a less favorable diff, so is deferred to a follow-up commit. - Test cases updated to refer to their `${CONTAINER_NAME}` var instead of the hard-coded string name. * chore: Scope configs to individual test cases (3/3) Final commit to shift out the container configs. - Common vars are exported in `setup_file()` for the test cases to use without needing to repeat the declaration in each test case. - `teardown_file()` shifts container removal at end of scoped test case. * chore: Adapt to `common_container_setup` template - `CONTAINER_NAME` becomes `TEST_NAME` (`common.bash` helper via `init_with_defaults`). - `docker run ...` and related configuration is now outsourced to the `common.bash` helper, only extra args that the default template does not cover are defined in the test case. - `TARGET_DOMAIN`establishes the domain folder name for `/etc/letsencrypt/live`. - `_should*` methods no longer manage a `CONTAINER_NAME` arg, instead using the `TEST_NAME` global that should be valid as test is run as a sequence of test cases. - `PRIVATE_CONFIG` and the `private_config_path ...` are now using the global `TEST_TMP_CONFIG` initialized at the start of each test case, slightly different as not locally defined/scoped like `PRIVATE_CONFIG` would be within the test case, hence the explicit choice of a different name for context. * chore: Minor tweaks - Test case comment descriptions. - DRY: `docker rm -f` lines moved to `teardown()` - Use `wait_for_service` helper instead of checking the `changedetector` script itself is running. - There is a startup delay before the `changedetector` begins monitoring, wait until it ready event is logged. - Added a helper to query logs for a service (useful later). - `/bin/sh` commands reduced to `sh`. - Change the config check to match and compare output, not number of lines returned. Provides better failure output by bats to debug against. * chore: Add more test functions for `acme.json` This just extracts out existing logic from the test case to functions to make the test case itself more readable/terse. * chore: Housekeeping No changes, just moving logic around and grouping into inline functions, with some added comments. * chore: Switch to `example.test` certs This also required copying the source files to match the expected letsencrypt file structure expected in the test/container usage. * chore: Delete `test/config/letsencrypt/` No longer necessary, using the `example.test/` certs instead. These letsencrypt certs weren't for the domains they were used for, and of course long expired. * chore: Housekeeping Add more maintainer comments, rename some functions. * tests: Expand `acme.json` extraction coverage Finally able to add more test coverage! :) - Two new methods to validate expected success/failure of extraction for a given FQDN. - Added an RSA test prior to the wildcard to test a renewal simulation (just with different cert type). - Added extra method to make sure we're detecting multiple successful change events, not just a previous logged success (false positive). * tests: Refactor the negotiate_tls functionality Covers all ports (except POP) and correctly tests against expected verification status with new `example.test` certs. The `FQDN` var will be put to use in a follow-up commit. * tests: Verify the certs contain the expected FQDNs * chore: Extract TLS test methods into a separate helper script Can be useful for other TLS tests to utilize. * chore: Housekeeping * chore: Fix test typo There was a mismatch between the output and expected output between these two files "find key for" and "find key & cert for". Changed to "find key and/or cert for" to make the warning more clear that it's issued for either or both failure conditions. Co-authored-by: Georg Lauterbach <44545919+georglauterbach@users.noreply.github.com>
2021-11-16 16:00:16 +01:00
#! /bin/bash
load 'test_helper/common'
# Helper methods for testing TLS.
# `_should_*` methods are useful for common high-level functionality.
# ? --------------------------------------------- Negotiate TLS
# For certs actually provisioned from LetsEncrypt the Root CA cert should not need to be provided,
# as it would already be available by default in `/etc/ssl/certs`, requiring only the cert chain (fullchain.pem).
function _should_succesfully_negotiate_tls() {
local FQDN=${1}
local CONTAINER_NAME=${2:-${TEST_NAME}}
# shellcheck disable=SC2031
local CA_CERT=${3:-${TEST_CA_CERT}}
# Postfix and Dovecot are ready:
wait_for_smtp_port_in_container_to_respond "${CONTAINER_NAME}"
wait_for_tcp_port_in_container 993 "${CONTAINER_NAME}"
# Root CA cert should be present in the container:
assert docker exec "${CONTAINER_NAME}" [ -f "${CA_CERT}" ]
local PORTS=(25 587 465 143 993)
for PORT in "${PORTS[@]}"
do
_negotiate_tls "${FQDN}" "${PORT}"
done
}
# Basically runs commands like:
# docker exec "${TEST_NAME}" sh -c "timeout 1 openssl s_client -connect localhost:587 -starttls smtp -CAfile ${CA_CERT} 2>/dev/null | grep 'Verification'"
function _negotiate_tls() {
local FQDN=${1}
local PORT=${2}
local CONTAINER_NAME=${3:-${TEST_NAME}}
# shellcheck disable=SC2031
local CA_CERT=${4:-${TEST_CA_CERT}}
local CMD_OPENSSL_VERIFY
CMD_OPENSSL_VERIFY=$(_generate_openssl_cmd "${PORT}")
# Should fail as a chain of trust is required to verify successfully:
run docker exec "${CONTAINER_NAME}" sh -c "${CMD_OPENSSL_VERIFY}"
assert_output --partial 'Verification error: unable to verify the first certificate'
# Provide the Root CA cert for successful verification:
CMD_OPENSSL_VERIFY=$(_generate_openssl_cmd "${PORT}" "-CAfile ${CA_CERT}")
run docker exec "${CONTAINER_NAME}" sh -c "${CMD_OPENSSL_VERIFY}"
assert_output --partial 'Verification: OK'
_should_have_fqdn_in_cert "${FQDN}" "${PORT}"
}
function _generate_openssl_cmd() {
# Using a HOST of `localhost` will not have issues with `/etc/hosts` matching,
# since hostname may not be match correctly in `/etc/hosts` during tests when checking cert validity.
local HOST='localhost'
local PORT=${1}
local EXTRA_ARGS=${2}
# `echo '' | openssl ...` is a common approach for providing input to `openssl` command which waits on input to exit.
# While the command is still successful it does result with `500 5.5.2 Error: bad syntax` being included in the response.
# `timeout 1` instead of the empty echo pipe approach seems to work better instead.
local CMD_OPENSSL="timeout 1 openssl s_client -connect ${HOST}:${PORT}"
# STARTTLS ports need to add a hint:
if [[ ${PORT} =~ ^(25|587)$ ]]
then
CMD_OPENSSL="${CMD_OPENSSL} -starttls smtp"
elif [[ ${PORT} == 143 ]]
then
CMD_OPENSSL="${CMD_OPENSSL} -starttls imap"
elif [[ ${PORT} == 110 ]]
then
CMD_OPENSSL="${CMD_OPENSSL} -starttls pop3"
fi
# `2>/dev/null` prevents openssl interleaving output to stderr that shouldn't be captured:
echo "${CMD_OPENSSL} ${EXTRA_ARGS} 2>/dev/null"
}
# ? --------------------------------------------- Verify FQDN
function _should_have_fqdn_in_cert() {
local FQDN
FQDN=$(escape_fqdn "${1}")
_get_fqdns_for_cert "$@"
assert_output --regexp "Subject: CN = ${FQDN}|DNS:${FQDN}"
}
function _should_not_have_fqdn_in_cert() {
local FQDN
FQDN=$(escape_fqdn "${1}")
_get_fqdns_for_cert "$@"
refute_output --regexp "Subject: CN = ${FQDN}|DNS:${FQDN}"
}
# Escapes `*` and `.` so the FQDN literal can be used in regex queries
# `sed` will match those two chars and `\\&` says to prepend a `\` to the sed match (`&`)
function escape_fqdn() {
# shellcheck disable=SC2001
sed 's|[\*\.]|\\&|g' <<< "${1}"
}
function _get_fqdns_for_cert() {
local FQDN=${1}
local PORT=${2:-'25'}
local CONTAINER_NAME=${3:-${TEST_NAME}}
# shellcheck disable=SC2031
local CA_CERT=${4:-${TEST_CA_CERT}}
# `-servername` is for SNI, where the port may be for a service that serves multiple certs,
# and needs a specific FQDN to return the correct cert. Such as a reverse-proxy.
local EXTRA_ARGS="-servername ${FQDN} -CAfile ${CA_CERT}"
local CMD_OPENSSL_VERIFY
# eg: "timeout 1 openssl s_client -connect localhost:25 -starttls smtp ${EXTRA_ARGS} 2>/dev/null"
CMD_OPENSSL_VERIFY=$(_generate_openssl_cmd "${PORT}" "${EXTRA_ARGS}")
# Takes the result of the openssl output to return the x509 certificate,
# We then check that for any matching FQDN entries:
# main == `Subject CN = <FQDN>`, sans == `DNS:<FQDN>`
local CMD_FILTER_FQDN="openssl x509 -noout -text | grep -E 'Subject: CN = |DNS:'"
run docker exec "${CONTAINER_NAME}" sh -c "${CMD_OPENSSL_VERIFY} | ${CMD_FILTER_FQDN}"
}