diff --git a/plugin.conf b/plugin.conf index 9e24192..c01c52a 100644 --- a/plugin.conf +++ b/plugin.conf @@ -2,7 +2,7 @@ name=mail-login id=mail-login type=user author=HITME.PL -version=1.0.8 +version=1.0.9 active=no installed=no user_run_as=root diff --git a/scripts/keygen.sh b/scripts/keygen.sh index 4224b46..b368d9a 100755 --- a/scripts/keygen.sh +++ b/scripts/keygen.sh @@ -5,8 +5,24 @@ KEY_DIR="${MAIL_LOGIN_KEY_DIR:-/usr/local/hitme_plugins/mail-login/secrets}" KEY_NAME="${MAIL_LOGIN_KEY_NAME:-mx1_ed25519}" KEY_PATH="$KEY_DIR/$KEY_NAME" MX_MODE="${MX_LOGIN_MODE:-1}" +SETTINGS_FILE="${MAIL_LOGIN_SETTINGS_FILE:-/usr/local/directadmin/plugins/mail-login/plugin-settings.conf}" HELPER_COMMAND="${MAIL_LOGIN_HELPER_COMMAND:-/usr/local/hitme_plugins/mail-login-helper/bin/create-login-url}" +config_value() { + key="$1" + if [ ! -f "$SETTINGS_FILE" ]; then + return 0 + fi + awk -F= -v key="$key" ' + $1 == key { + value = substr($0, index($0, "=") + 1) + gsub(/^[ \t]+|[ \t]+$/, "", value) + print value + exit + } + ' "$SETTINGS_FILE" +} + derive_servername() { if [ -n "${SERVERNAME:-}" ]; then printf '%s' "$SERVERNAME" @@ -45,12 +61,64 @@ detect_source_ip() { return 1 } +pin_mx_host_key() { + if [ -z "$MX_HOST" ]; then + HOST_KEY_STATUS="skipped: MAIL_LOGIN_SSH_HOST is not configured" + return + fi + if ! command -v ssh-keyscan >/dev/null 2>&1; then + HOST_KEY_STATUS="skipped: ssh-keyscan is not available" + return + fi + + known_dir="$(dirname "$KNOWN_HOSTS")" + mkdir -p "$known_dir" + chmod 700 "$known_dir" + touch "$KNOWN_HOSTS" + chmod 600 "$KNOWN_HOSTS" + + lookup="$MX_HOST" + if [ "$MX_PORT" != "22" ]; then + lookup="[$MX_HOST]:$MX_PORT" + fi + if ssh-keygen -F "$lookup" -f "$KNOWN_HOSTS" >/dev/null 2>&1; then + HOST_KEY_STATUS="already present for $lookup" + return + fi + + tmp="$KNOWN_HOSTS.$$" + if ssh-keyscan -T "$SCAN_TIMEOUT" -p "$MX_PORT" -t ed25519 "$MX_HOST" > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then + cat "$tmp" >> "$KNOWN_HOSTS" + chmod 600 "$KNOWN_HOSTS" + HOST_KEY_FINGERPRINT="$(ssh-keygen -lf "$tmp" 2>/dev/null | head -n 1 || true)" + HOST_KEY_STATUS="pinned $lookup" + rm -f "$tmp" + return + fi + + rm -f "$tmp" + HOST_KEY_STATUS="failed: cannot fetch ED25519 host key for $MX_HOST:$MX_PORT" +} + SERVER_LABEL="$(clean_label "$(derive_servername)")" if [ -z "$SERVER_LABEL" ]; then echo "Cannot determine SERVERNAME" >&2 exit 1 fi +CONFIG_SSH_HOST="$(config_value MAIL_LOGIN_SSH_HOST || true)" +CONFIG_SSH_PORT="$(config_value MAIL_LOGIN_SSH_PORT || true)" +CONFIG_KNOWN_HOSTS="$(config_value MAIL_LOGIN_SSH_KNOWN_HOSTS || true)" +CONFIG_SCAN_TIMEOUT="$(config_value MAIL_LOGIN_CONNECT_TIMEOUT_SECONDS || true)" + +MX_HOST="${MAIL_LOGIN_SSH_HOST:-$CONFIG_SSH_HOST}" +MX_PORT="${MAIL_LOGIN_SSH_PORT:-$CONFIG_SSH_PORT}" +MX_PORT="${MX_PORT:-22}" +KNOWN_HOSTS="${MAIL_LOGIN_SSH_KNOWN_HOSTS:-$CONFIG_KNOWN_HOSTS}" +KNOWN_HOSTS="${KNOWN_HOSTS:-$KEY_DIR/known_hosts}" +SCAN_TIMEOUT="${MAIL_LOGIN_CONNECT_TIMEOUT_SECONDS:-$CONFIG_SCAN_TIMEOUT}" +SCAN_TIMEOUT="${SCAN_TIMEOUT:-5}" + case "$MX_MODE" in 1|2) ;; *) @@ -59,6 +127,20 @@ case "$MX_MODE" in ;; esac +case "$MX_PORT" in + *[!0-9]*|'') + echo "MAIL_LOGIN_SSH_PORT must be numeric" >&2 + exit 1 + ;; +esac + +case "$SCAN_TIMEOUT" in + *[!0-9]*|'') + echo "MAIL_LOGIN_CONNECT_TIMEOUT_SECONDS must be numeric" >&2 + exit 1 + ;; +esac + SOURCE_IP="$(detect_source_ip || true)" case "$SOURCE_IP" in *[!A-Za-z0-9:./_-]*|'') @@ -82,6 +164,10 @@ fi chmod 600 "$KEY_PATH" chmod 644 "$KEY_PATH.pub" +HOST_KEY_STATUS="" +HOST_KEY_FINGERPRINT="" +pin_mx_host_key + PUB_KEY="$(cat "$KEY_PATH.pub")" BASE_OPTIONS="from=\"$SOURCE_IP\",restrict,no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty" @@ -102,7 +188,12 @@ $KEY_PATH.pub Set this in plugin-settings.conf: MAIL_LOGIN_SSH_IDENTITY=$KEY_PATH -MAIL_LOGIN_SSH_KNOWN_HOSTS=/root/.ssh/known_hosts +MAIL_LOGIN_SSH_KNOWN_HOSTS=$KNOWN_HOSTS + +MX known_hosts: +$KNOWN_HOSTS +Pinned MX host key: $HOST_KEY_STATUS +${HOST_KEY_FINGERPRINT} Add this line to /root/.ssh/authorized_keys on mx1: $AUTH_OPTIONS $PUB_KEY $COMMENT diff --git a/tests/keygen_script_test.php b/tests/keygen_script_test.php index 71388dd..d65c741 100644 --- a/tests/keygen_script_test.php +++ b/tests/keygen_script_test.php @@ -47,6 +47,51 @@ test('keygen script detects source ip without parameters', function (): void { assert_not_contains('SOURCE_SERVER_PUBLIC_IP', $output); }); +test('keygen script pins mx host key from plugin settings', function (): void { + $keyDir = TEST_TMP . '/keys-known-hosts'; + $knownHosts = TEST_TMP . '/known-hosts/known_hosts'; + $settings = TEST_TMP . '/plugin-settings.conf'; + $binDir = TEST_TMP . '/fake-scan-bin'; + $hostKey = TEST_TMP . '/mx-host-ed25519'; + mkdir($binDir, 0700, true); + + exec(sprintf('ssh-keygen -q -t ed25519 -N "" -f %s -C %s', escapeshellarg($hostKey), escapeshellarg('mx1.test')), $keygenOut, $keygenCode); + assert_same(0, $keygenCode, implode("\n", $keygenOut)); + $pub = trim((string)file_get_contents($hostKey . '.pub')); + $parts = explode(' ', $pub); + $knownHostLine = '[mx1.test]:10022 ' . $parts[0] . ' ' . $parts[1]; + + test_write($settings, implode("\n", [ + 'MAIL_LOGIN_SSH_HOST=mx1.test', + 'MAIL_LOGIN_SSH_PORT=10022', + 'MAIL_LOGIN_SSH_KNOWN_HOSTS=' . $knownHosts, + 'MAIL_LOGIN_CONNECT_TIMEOUT_SECONDS=5', + '', + ])); + test_write($binDir . '/ssh-keyscan', "#!/bin/sh\nif [ \"$1 $2 $3 $4 $5 $6 $7\" = '-T 5 -p 10022 -t ed25519 mx1.test' ]; then cat <<'EOF'\n$knownHostLine\nEOF\nexit 0\nfi\nexit 1\n"); + chmod($binDir . '/ssh-keyscan', 0755); + + $script = PLUGIN_ROOT . '/scripts/keygen.sh'; + $cmd = sprintf( + 'PATH=%s:$PATH MAIL_LOGIN_SETTINGS_FILE=%s MAIL_LOGIN_KEY_DIR=%s SERVERNAME=%s SOURCE_IP=%s %s', + escapeshellarg($binDir), + escapeshellarg($settings), + escapeshellarg($keyDir), + escapeshellarg('h4'), + escapeshellarg('203.0.113.10'), + escapeshellarg($script) + ); + + exec($cmd, $out, $code); + + assert_same(0, $code, implode("\n", $out)); + assert_true(is_file($knownHosts)); + assert_contains('[mx1.test]:10022', file_get_contents($knownHosts) ?: ''); + $output = implode("\n", $out); + assert_contains('MAIL_LOGIN_SSH_KNOWN_HOSTS=' . $knownHosts, $output); + assert_contains('Pinned MX host key:', $output); +}); + test('keygen script prints helper forced command line when mx login mode is helper', function (): void { $keyDir = TEST_TMP . '/keys-helper'; $script = PLUGIN_ROOT . '/scripts/keygen.sh'; diff --git a/tests/package_contents_test.php b/tests/package_contents_test.php index 77daf14..ccd2b7b 100644 --- a/tests/package_contents_test.php +++ b/tests/package_contents_test.php @@ -8,7 +8,7 @@ test('plugin metadata and packaging docs match project rules', function (): void assert_contains('name=mail-login', $conf); assert_contains('id=mail-login', $conf); assert_contains('type=user', $conf); - assert_contains('version=1.0.8', $conf); + assert_contains('version=1.0.9', $conf); assert_contains('user_run_as=root', $conf); assert_contains('/Users/marek/GPT/ChatGPT/da_plugins/mail-login/src', $packing); assert_contains('/Users/marek/GPT/ChatGPT/da_plugins/mail-login/archives/X.Y.Z/mail-login.tar.gz', $packing);