feat: remove mx helper mode

This commit is contained in:
Marek Miklewicz
2026-06-30 20:49:30 +02:00
parent 8861611a67
commit cec0b9ef9d
14 changed files with 61 additions and 572 deletions
+4 -19
View File
@@ -61,7 +61,6 @@ final class RemoteLoginUrlClient
$settings->sshUser() . '@' . $settings->sshHost(), $settings->sshUser() . '@' . $settings->sshHost(),
]; ];
if ($settings->mxLoginMode() === 1) {
$command = array_merge($base, [ $command = array_merge($base, [
'/usr/bin/da', '/usr/bin/da',
'login-url', 'login-url',
@@ -77,19 +76,6 @@ final class RemoteLoginUrlClient
return $command; return $command;
} }
return array_merge($base, [
'mail-login-create-url',
$request->username,
$request->sourceHost,
$request->serverName,
(string)$request->ttl,
$request->userIpBound ? '1' : '0',
$request->clientIp,
$request->redirectPath,
(string)$request->createdAt,
]);
}
public function create(Settings $settings, LoginUrlRequest $request): string public function create(Settings $settings, LoginUrlRequest $request): string
{ {
$command = self::buildCommand($settings, $request); $command = self::buildCommand($settings, $request);
@@ -101,7 +87,7 @@ final class RemoteLoginUrlClient
throw new RuntimeException(self::failureMessage($settings, $result)); throw new RuntimeException(self::failureMessage($settings, $result));
} }
if (!preg_match('/https:\/\/\S+/', $result->stdout, $match)) { if (!preg_match('/https:\/\/\S+/', $result->stdout, $match)) {
throw new RuntimeException('MX helper did not return a login URL'); throw new RuntimeException('MX direct SSH command did not return a login URL');
} }
try { try {
return UrlValidator::assertGeneratedLoginUrl(trim($match[0]), $settings); return UrlValidator::assertGeneratedLoginUrl(trim($match[0]), $settings);
@@ -112,8 +98,7 @@ final class RemoteLoginUrlClient
private static function failureMessage(Settings $settings, ProcessResult $result): string private static function failureMessage(Settings $settings, ProcessResult $result): string
{ {
$message = $settings->mxLoginMode() === 1 ? 'MX direct SSH command failed' : 'MX helper failed'; $message = 'MX direct SSH command failed (exit ' . $result->exitCode . ')';
$message .= ' (exit ' . $result->exitCode . ')';
$detail = self::safeDiagnostic($result->stderr); $detail = self::safeDiagnostic($result->stderr);
if ($detail !== '') { if ($detail !== '') {
$message .= ': ' . $detail; $message .= ': ' . $detail;
@@ -140,7 +125,7 @@ final class RemoteLoginUrlClient
]; ];
$process = proc_open($command, $descriptors, $pipes); $process = proc_open($command, $descriptors, $pipes);
if (!is_resource($process)) { if (!is_resource($process)) {
throw new RuntimeException('Cannot start MX helper'); throw new RuntimeException('Cannot start MX direct SSH command');
} }
fclose($pipes[0]); fclose($pipes[0]);
stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[1], false);
@@ -161,7 +146,7 @@ final class RemoteLoginUrlClient
fclose($pipe); fclose($pipe);
} }
proc_close($process); proc_close($process);
throw new RuntimeException('MX helper timed out'); throw new RuntimeException('MX direct SSH command timed out');
} }
usleep(100000); usleep(100000);
} while (true); } while (true);
-9
View File
@@ -58,7 +58,6 @@ final class Settings
return [ return [
'DEFAULT_ENABLE' => 'true', 'DEFAULT_ENABLE' => 'true',
'MAIL_LOGIN_METHOD' => 'login_url', 'MAIL_LOGIN_METHOD' => 'login_url',
'MX_LOGIN_MODE' => '1',
'MAIL_LOGIN_TARGET_NAME' => 'mx1', 'MAIL_LOGIN_TARGET_NAME' => 'mx1',
'MAIL_LOGIN_TARGET_BASE_URL' => 'https://mx1.mojserwer.pl:2222', 'MAIL_LOGIN_TARGET_BASE_URL' => 'https://mx1.mojserwer.pl:2222',
'MAIL_LOGIN_REDIRECT_URL' => '/', 'MAIL_LOGIN_REDIRECT_URL' => '/',
@@ -116,11 +115,6 @@ final class Settings
return $this->getString('MAIL_LOGIN_METHOD', 'login_url'); return $this->getString('MAIL_LOGIN_METHOD', 'login_url');
} }
public function mxLoginMode(): int
{
return (int)$this->getString('MX_LOGIN_MODE', '1');
}
public function redirectUrl(): string public function redirectUrl(): string
{ {
return $this->getString('MAIL_LOGIN_REDIRECT_URL', '/'); return $this->getString('MAIL_LOGIN_REDIRECT_URL', '/');
@@ -267,9 +261,6 @@ final class Settings
if ($this->method() !== 'login_url') { if ($this->method() !== 'login_url') {
throw new InvalidArgumentException('MAIL_LOGIN_METHOD must be login_url'); throw new InvalidArgumentException('MAIL_LOGIN_METHOD must be login_url');
} }
if (!in_array($this->getString('MX_LOGIN_MODE', '1'), ['1', '2'], true)) {
throw new InvalidArgumentException('MX_LOGIN_MODE must be 1 or 2');
}
if (!in_array($this->clientIpMode(), ['direct', 'trusted_proxy'], true)) { if (!in_array($this->clientIpMode(), ['direct', 'trusted_proxy'], true)) {
throw new InvalidArgumentException('MAIL_LOGIN_CLIENT_IP_MODE must be direct or trusted_proxy'); throw new InvalidArgumentException('MAIL_LOGIN_CLIENT_IP_MODE must be direct or trusted_proxy');
} }
-160
View File
@@ -1,160 +0,0 @@
#!/usr/local/bin/php
<?php
declare(strict_types=1);
if (realpath((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
mailLoginCleanupMain();
}
function mailLoginCleanupMain(): void
{
$config = [
'base_url' => getenv('DA_BASE_URL') ?: 'https://127.0.0.1:2222',
'user' => getenv('DA_API_USER') ?: '',
'key' => getenv('DA_API_LOGIN_KEY') ?: '',
'audit_log' => getenv('MAIL_LOGIN_AUDIT_LOG') ?: '/usr/local/hitme_plugins/mail-login-helper/logs/audit.log',
'state_dir' => getenv('MAIL_LOGIN_STATE_DIR') ?: '/usr/local/hitme_plugins/mail-login-helper/state',
];
if ($config['user'] === '' || $config['key'] === '') {
fwrite(STDERR, "DirectAdmin API credentials are required for cleanup\n");
exit(2);
}
$base = mailLoginCleanupValidateBaseUrl($config['base_url']);
$now = time();
$states = mailLoginCleanupLoadStates($config['state_dir']);
$entries = mailLoginCleanupApiRequest('GET', $base . '/api/login-keys/urls', $config);
if (!is_array($entries)) {
fwrite(STDERR, "DirectAdmin returned invalid login URL list\n");
exit(1);
}
$deleted = 0;
foreach ($entries as $entry) {
if (is_array($entry) && mailLoginCleanupShouldDelete($entry, $states, $now)) {
mailLoginCleanupApiRequest('DELETE', $base . '/api/login-keys/urls/' . rawurlencode((string)$entry['id']), $config);
$deleted++;
}
}
mailLoginCleanupAudit($config['audit_log'], [
'event' => 'cleanup_expired_login_urls',
'result' => 'success',
'deleted' => $deleted,
]);
echo "Deleted expired login URLs: {$deleted}\n";
}
/**
* @return list<array<string,mixed>>
*/
function mailLoginCleanupLoadStates(string $stateDir): array
{
$states = [];
foreach (glob(rtrim($stateDir, '/') . '/autologin-*.json') ?: [] as $file) {
$decoded = json_decode((string)file_get_contents($file), true);
if (is_array($decoded)) {
$states[] = $decoded;
}
}
return $states;
}
/**
* @param array<string,mixed> $entry
* @param list<array<string,mixed>> $states
*/
function mailLoginCleanupShouldDelete(array $entry, array $states, int $now): bool
{
if (empty($entry['id']) || empty($entry['expires'])) {
return false;
}
$entryExpires = strtotime((string)$entry['expires']);
if ($entryExpires === false || $entryExpires >= $now) {
return false;
}
foreach ($states as $state) {
if ((string)($state['id'] ?? '') === '' || (string)$entry['id'] !== (string)$state['id']) {
continue;
}
if (($state['status'] ?? '') !== 'created') {
continue;
}
$stateExpires = (int)($state['expires'] ?? 0);
if ($stateExpires <= 0 || $stateExpires > $now) {
continue;
}
if ((string)($entry['redirectURL'] ?? '/') !== (string)($state['redirect_path'] ?? '/')) {
continue;
}
$entryNetworks = $entry['allowNetworks'] ?? [];
if (!is_array($entryNetworks)) {
$entryNetworks = [];
}
$stateIp = (string)($state['client_ip'] ?? '');
$ipBound = (int)($state['ip_bound'] ?? 0) === 1;
if ($ipBound && !in_array($stateIp . (str_contains($stateIp, ':') ? '/128' : '/32'), $entryNetworks, true)) {
continue;
}
return true;
}
return false;
}
function mailLoginCleanupValidateBaseUrl(string $baseUrl): string
{
$baseUrl = rtrim($baseUrl, '/');
$parts = parse_url($baseUrl);
if (!is_array($parts) || strtolower((string)($parts['scheme'] ?? '')) !== 'https' || empty($parts['host'])) {
throw new InvalidArgumentException('DA_BASE_URL must be an https URL');
}
return $baseUrl;
}
/**
* @param array<string,string> $config
* @return mixed
*/
function mailLoginCleanupApiRequest(string $method, string $url, array $config): mixed
{
$ch = curl_init($url);
if ($ch === false) {
throw new RuntimeException('Cannot initialize curl');
}
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_USERPWD => $config['user'] . ':' . $config['key'],
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($body === false || $code >= 400) {
throw new RuntimeException($error !== '' ? $error : 'DirectAdmin API request failed');
}
if ($method === 'DELETE') {
return true;
}
return json_decode((string)$body, true, 512, JSON_THROW_ON_ERROR);
}
/**
* @param array<string,mixed> $fields
*/
function mailLoginCleanupAudit(string $path, array $fields): void
{
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0700, true);
}
$fields['timestamp'] = gmdate('c');
file_put_contents($path, json_encode($fields, JSON_UNESCAPED_SLASHES) . "\n", FILE_APPEND | LOCK_EX);
chmod($path, 0600);
}
-29
View File
@@ -1,29 +0,0 @@
#!/bin/sh
set -eu
BASE_DIR="/usr/local/hitme_plugins/mail-login-helper"
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
mkdir -p "$BASE_DIR/bin" "$BASE_DIR/config" "$BASE_DIR/logs" "$BASE_DIR/state"
chmod 700 "$BASE_DIR" "$BASE_DIR/bin" "$BASE_DIR/config" "$BASE_DIR/logs" "$BASE_DIR/state"
cp "$SCRIPT_DIR/mail-login-authorized-command.sh" "$BASE_DIR/bin/create-login-url"
cp "$SCRIPT_DIR/cleanup-expired-login-urls.php" "$BASE_DIR/bin/cleanup-expired-login-urls"
chmod 755 "$BASE_DIR/bin/create-login-url" "$BASE_DIR/bin/cleanup-expired-login-urls"
touch "$BASE_DIR/logs/audit.log"
chmod 600 "$BASE_DIR/logs/audit.log"
if [ ! -f "$BASE_DIR/config/helper-settings.conf" ]; then
cat > "$BASE_DIR/config/helper-settings.conf" <<'EOF'
MAIL_LOGIN_ALLOWED_SOURCE_HOSTS=h4.domena.pl
MAIL_LOGIN_DA_BIN=/usr/bin/da
EOF
chmod 600 "$BASE_DIR/config/helper-settings.conf"
fi
cat > "$BASE_DIR/authorized_keys.example" <<'EOF'
from="SOURCE_SERVER_PUBLIC_IP",restrict,no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty,command="/usr/local/hitme_plugins/mail-login-helper/bin/create-login-url" ssh-ed25519 SOURCE_SERVER_PUBLIC_KEY mail-login-source
EOF
chmod 600 "$BASE_DIR/authorized_keys.example"
echo "mail-login MX helper installed at $BASE_DIR"
-127
View File
@@ -1,127 +0,0 @@
#!/bin/sh
set -eu
set -f
DA_BIN="${MAIL_LOGIN_DA_BIN:-/usr/bin/da}"
CONFIG_FILE="${MAIL_LOGIN_HELPER_CONFIG:-/usr/local/hitme_plugins/mail-login-helper/config/helper-settings.conf}"
AUDIT_LOG="${MAIL_LOGIN_AUDIT_LOG:-/usr/local/hitme_plugins/mail-login-helper/logs/audit.log}"
STATE_DIR="${MAIL_LOGIN_STATE_DIR:-/usr/local/hitme_plugins/mail-login-helper/state}"
if [ -f "$CONFIG_FILE" ]; then
# shellcheck disable=SC1090
. "$CONFIG_FILE"
fi
audit() {
dir=$(dirname "$AUDIT_LOG")
mkdir -p "$dir"
chmod 700 "$dir"
php -r '$fields=["timestamp"=>gmdate("c"),"event"=>$argv[1],"result"=>$argv[2],"correlation"=>$argv[3],"username"=>$argv[4],"source_host"=>$argv[5],"client_ip"=>$argv[6]]; echo json_encode($fields, JSON_UNESCAPED_SLASHES)."\n";' \
"$1" "$2" "$3" "$4" "$5" "$6" >> "$AUDIT_LOG"
chmod 600 "$AUDIT_LOG"
}
fail() {
audit "create_login_url" "failure" "${CORRELATION:-unknown}" "${USERNAME_ARG:-unknown}" "${SOURCE_HOST:-unknown}" "${CLIENT_IP:-unknown}"
echo "mail-login helper failed" >&2
exit 1
}
clean_label() {
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g; s/^-*//; s/-*$//'
}
first_label() {
printf '%s' "$1" | sed 's/:.*$//' | awk -F. '{print $1}'
}
host_allowed() {
allowed="${MAIL_LOGIN_ALLOWED_SOURCE_HOSTS:-}"
[ -n "$allowed" ] || return 1
old_ifs=$IFS
IFS=,
for host in $allowed; do
normalized=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]' | sed 's#^https\?://##; s#/.*$##; s/:[0-9][0-9]*$//; s/^ *//; s/ *$//')
[ "$normalized" = "$SOURCE_HOST" ] && IFS=$old_ifs && return 0
done
IFS=$old_ifs
return 1
}
valid_ip() {
php -r 'exit(filter_var($argv[1], FILTER_VALIDATE_IP) ? 0 : 1);' "$1"
}
if [ "$#" -eq 0 ] && [ -n "${SSH_ORIGINAL_COMMAND:-}" ]; then
# The authorized_keys forced command receives the requested command here.
# All accepted fields are validated below before use.
set -- $SSH_ORIGINAL_COMMAND
fi
COMMAND="${1:-}"
USERNAME_ARG="${2:-}"
SOURCE_HOST="${3:-}"
SERVER_NAME="${4:-}"
TTL="${5:-}"
USER_IP_BOUND="${6:-}"
CLIENT_IP="${7:-}"
REDIRECT_PATH="${8:-}"
TIMESTAMP_ARG="${9:-}"
[ "$COMMAND" = "mail-login-create-url" ] || fail
printf '%s' "$USERNAME_ARG" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$' || fail
printf '%s' "$SOURCE_HOST" | grep -Eq '^[A-Za-z0-9.-]+$' || fail
SOURCE_HOST=$(printf '%s' "$SOURCE_HOST" | tr '[:upper:]' '[:lower:]')
host_allowed || fail
SERVER_NAME=$(first_label "$SOURCE_HOST")
SERVER_NAME=$(clean_label "$SERVER_NAME")
printf '%s' "$SERVER_NAME" | grep -Eq '^[a-z0-9-]{1,32}$' || fail
printf '%s' "$TTL" | grep -Eq '^[0-9]+$' || fail
[ "$TTL" -ge 5 ] && [ "$TTL" -le 300 ] || fail
[ "$USER_IP_BOUND" = "0" ] || [ "$USER_IP_BOUND" = "1" ] || fail
valid_ip "$CLIENT_IP" || fail
case "$REDIRECT_PATH" in
/*) ;;
*) fail ;;
esac
case "$REDIRECT_PATH" in
//*|*\\*) fail ;;
esac
printf '%s' "$REDIRECT_PATH" | grep -Eq '^/[A-Za-z0-9._~%/?=+-]*$' || fail
printf '%s' "$TIMESTAMP_ARG" | grep -Eq '^[0-9]+$' || fail
SERVER_CLEAN=$(clean_label "$SERVER_NAME")
USER_CLEAN=$(clean_label "$USERNAME_ARG")
TIMESTAMP="$TIMESTAMP_ARG"
CORRELATION="autologin-$SERVER_CLEAN-$USER_CLEAN-$TIMESTAMP"
if [ ${#CORRELATION} -gt 96 ]; then
HASH=$(printf '%s' "$CORRELATION" | openssl dgst -sha256 -r 2>/dev/null | awk '{print substr($1,1,10)}')
SUFFIX="-$HASH-$TIMESTAMP"
BODY=$(printf '%s' "autologin-$SERVER_CLEAN-$USER_CLEAN" | cut -c 1-$((96 - ${#SUFFIX})) | sed 's/-*$//')
CORRELATION="$BODY$SUFFIX"
fi
mkdir -p "$STATE_DIR"
chmod 700 "$STATE_DIR"
if [ "$USER_IP_BOUND" = "1" ]; then
OUTPUT=$("$DA_BIN" login-url "--user=$USERNAME_ARG" "--redirect-url=$REDIRECT_PATH" "--expiry=${TTL}s" "--ip=$CLIENT_IP") || fail
else
OUTPUT=$("$DA_BIN" login-url "--user=$USERNAME_ARG" "--redirect-url=$REDIRECT_PATH" "--expiry=${TTL}s") || fail
audit "ip_bound_disabled" "warning" "$CORRELATION" "$USERNAME_ARG" "$SOURCE_HOST" "$CLIENT_IP"
fi
URL=$(printf '%s\n' "$OUTPUT" | awk '/https:\/\// {for (i=1;i<=NF;i++) if ($i ~ /^https:\/\//) {print $i; exit}}')
[ -n "$URL" ] || fail
case "$URL" in
https://*/api/login/url?key=*) ;;
*) fail ;;
esac
LOGIN_URL_ID=$(printf '%s\n' "$OUTPUT" | awk 'match($0,/HASHURL[A-Z0-9]+/) {print substr($0,RSTART,RLENGTH); exit}')
php -r '$fields=["timestamp"=>(int)$argv[1],"expires"=>(int)$argv[2],"correlation"=>$argv[3],"id"=>$argv[4],"username"=>$argv[5],"source_host"=>$argv[6],"client_ip"=>$argv[7],"ttl"=>(int)$argv[8],"ip_bound"=>(int)$argv[9],"redirect_path"=>$argv[10],"status"=>"created"]; echo json_encode($fields, JSON_UNESCAPED_SLASHES)."\n";' \
"$TIMESTAMP" "$((TIMESTAMP + TTL))" "$CORRELATION" "$LOGIN_URL_ID" "$USERNAME_ARG" "$SOURCE_HOST" "$CLIENT_IP" "$TTL" "$USER_IP_BOUND" "$REDIRECT_PATH" > "$STATE_DIR/$CORRELATION.json"
chmod 600 "$STATE_DIR/$CORRELATION.json"
audit "create_login_url" "success" "$CORRELATION" "$USERNAME_ARG" "$SOURCE_HOST" "$CLIENT_IP"
printf 'URL: %s\n' "$URL"
+2 -4
View File
@@ -8,10 +8,8 @@ DEFAULT_ENABLE=true
# Metoda produkcyjna. Wersja 1 używa jednorazowego DirectAdmin login URL generowanego na MX. # Metoda produkcyjna. Wersja 1 używa jednorazowego DirectAdmin login URL generowanego na MX.
MAIL_LOGIN_METHOD=login_url MAIL_LOGIN_METHOD=login_url
# Tryb tworzenia login URL na MX: # Login URL jest tworzony wyłącznie przez direct SSH command:
# 1 = direct SSH command: serwer źródłowy łączy się po SSH do MX i uruchamia bezpośrednio /usr/bin/da login-url; nie wymaga instalowania helpera na MX. # serwer źródłowy łączy się po SSH do MX i uruchamia bezpośrednio /usr/bin/da login-url.
# 2 = helper: serwer źródłowy łączy się po SSH do ograniczonego helpera na MX; lepsza walidacja/audyt/cleanup, ale wymaga instalacji helpera.
MX_LOGIN_MODE=1
# Nazwa i adres docelowego panelu poczty. # Nazwa i adres docelowego panelu poczty.
MAIL_LOGIN_TARGET_NAME=mx1 MAIL_LOGIN_TARGET_NAME=mx1
+1 -1
View File
@@ -2,7 +2,7 @@ name=MX-Autologin
id=mxautologin id=mxautologin
type=user type=user
author=HITME.PL author=HITME.PL
version=1.0.14 version=1.1.1
active=no active=no
installed=no installed=no
user_run_as=root user_run_as=root
-15
View File
@@ -4,9 +4,7 @@ set -eu
KEY_DIR="${MAIL_LOGIN_KEY_DIR:-/usr/local/hitme_plugins/mxautologin/secrets}" KEY_DIR="${MAIL_LOGIN_KEY_DIR:-/usr/local/hitme_plugins/mxautologin/secrets}"
KEY_NAME="${MAIL_LOGIN_KEY_NAME:-mx1_ed25519}" KEY_NAME="${MAIL_LOGIN_KEY_NAME:-mx1_ed25519}"
KEY_PATH="$KEY_DIR/$KEY_NAME" KEY_PATH="$KEY_DIR/$KEY_NAME"
MX_MODE="${MX_LOGIN_MODE:-1}"
SETTINGS_FILE="${MAIL_LOGIN_SETTINGS_FILE:-/usr/local/directadmin/plugins/mxautologin/plugin-settings.conf}" SETTINGS_FILE="${MAIL_LOGIN_SETTINGS_FILE:-/usr/local/directadmin/plugins/mxautologin/plugin-settings.conf}"
HELPER_COMMAND="${MAIL_LOGIN_HELPER_COMMAND:-/usr/local/hitme_plugins/mail-login-helper/bin/create-login-url}"
config_value() { config_value() {
key="$1" key="$1"
@@ -119,14 +117,6 @@ KNOWN_HOSTS="${KNOWN_HOSTS:-$KEY_DIR/known_hosts}"
SCAN_TIMEOUT="${MAIL_LOGIN_CONNECT_TIMEOUT_SECONDS:-$CONFIG_SCAN_TIMEOUT}" SCAN_TIMEOUT="${MAIL_LOGIN_CONNECT_TIMEOUT_SECONDS:-$CONFIG_SCAN_TIMEOUT}"
SCAN_TIMEOUT="${SCAN_TIMEOUT:-5}" SCAN_TIMEOUT="${SCAN_TIMEOUT:-5}"
case "$MX_MODE" in
1|2) ;;
*)
echo "MX_LOGIN_MODE must be 1 or 2" >&2
exit 1
;;
esac
case "$MX_PORT" in case "$MX_PORT" in
*[!0-9]*|'') *[!0-9]*|'')
echo "MAIL_LOGIN_SSH_PORT must be numeric" >&2 echo "MAIL_LOGIN_SSH_PORT must be numeric" >&2
@@ -170,12 +160,7 @@ pin_mx_host_key
PUB_KEY="$(cat "$KEY_PATH.pub")" PUB_KEY="$(cat "$KEY_PATH.pub")"
BASE_OPTIONS="from=\"$SOURCE_IP\",restrict,no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty" BASE_OPTIONS="from=\"$SOURCE_IP\",restrict,no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty"
if [ "$MX_MODE" = "2" ]; then
AUTH_OPTIONS="$BASE_OPTIONS,command=\"$HELPER_COMMAND\""
else
AUTH_OPTIONS="$BASE_OPTIONS" AUTH_OPTIONS="$BASE_OPTIONS"
fi
COMMENT="KLUCZ PLUGINU MX-Autologin DLA SERWERA $SERVER_LABEL" COMMENT="KLUCZ PLUGINU MX-Autologin DLA SERWERA $SERVER_LABEL"
-48
View File
@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
require_once PLUGIN_ROOT . '/mx-helper/cleanup-expired-login-urls.php';
test('cleanup deletes only expired login urls matching local mail-login state', function (): void {
$state = [
'timestamp' => 1782810000,
'expires' => 1782810030,
'correlation' => 'autologin-h4-mxtest-1782810000',
'id' => 'HASHURLMATCH',
'redirect_path' => '/',
'client_ip' => '198.51.100.10',
'ip_bound' => 1,
'status' => 'created',
];
$matching = [
'id' => 'HASHURLMATCH',
'created' => '2026-06-30T10:00:00Z',
'expires' => '2026-06-30T10:00:30Z',
'redirectURL' => '/',
'allowNetworks' => ['198.51.100.10/32'],
];
$wrongRedirect = $matching;
$wrongRedirect['id'] = 'HASHURLWRONG';
$wrongRedirect['redirectURL'] = '/admin';
$notExpired = $matching;
$notExpired['id'] = 'HASHURLFRESH';
$notExpired['expires'] = '2099-06-30T10:00:30Z';
assert_true(mailLoginCleanupShouldDelete($matching, [$state], strtotime('2026-06-30T10:01:00Z')));
assert_false(mailLoginCleanupShouldDelete($wrongRedirect, [$state], strtotime('2026-06-30T10:01:00Z')));
assert_false(mailLoginCleanupShouldDelete($notExpired, [$state], strtotime('2026-06-30T10:01:00Z')));
$stateWithoutId = $state;
unset($stateWithoutId['id']);
assert_false(mailLoginCleanupShouldDelete($matching, [$stateWithoutId], strtotime('2026-06-30T10:01:00Z')));
});
test('cleanup rejects non-https directadmin api base url', function (): void {
try {
mailLoginCleanupValidateBaseUrl('http://127.0.0.1:2222');
throw new RuntimeException('Expected http base URL to fail');
} catch (InvalidArgumentException $e) {
assert_contains('https', strtolower($e->getMessage()));
}
});
+4 -3
View File
@@ -92,8 +92,8 @@ test('keygen script pins mx host key from plugin settings', function (): void {
assert_contains('Pinned MX host key:', $output); assert_contains('Pinned MX host key:', $output);
}); });
test('keygen script prints helper forced command line when mx login mode is helper', function (): void { test('keygen script ignores helper mode variables and prints direct ssh line only', function (): void {
$keyDir = TEST_TMP . '/keys-helper'; $keyDir = TEST_TMP . '/keys-direct-only';
$script = PLUGIN_ROOT . '/scripts/keygen.sh'; $script = PLUGIN_ROOT . '/scripts/keygen.sh';
$cmd = sprintf( $cmd = sprintf(
'MAIL_LOGIN_KEY_DIR=%s MX_LOGIN_MODE=2 SERVERNAME=%s SOURCE_IP=%s %s', 'MAIL_LOGIN_KEY_DIR=%s MX_LOGIN_MODE=2 SERVERNAME=%s SOURCE_IP=%s %s',
@@ -107,6 +107,7 @@ test('keygen script prints helper forced command line when mx login mode is help
assert_same(0, $code, implode("\n", $out)); assert_same(0, $code, implode("\n", $out));
$output = implode("\n", $out); $output = implode("\n", $out);
assert_contains('command="/usr/local/hitme_plugins/mail-login-helper/bin/create-login-url"', $output); assert_not_contains('command=', $output);
assert_not_contains('mail-login-helper', $output);
assert_contains('KLUCZ PLUGINU MX-Autologin DLA SERWERA h4', $output); assert_contains('KLUCZ PLUGINU MX-Autologin DLA SERWERA h4', $output);
}); });
-103
View File
@@ -1,103 +0,0 @@
<?php
declare(strict_types=1);
test('mx helper conditionally passes user ip binding to da login-url', function (): void {
$bin = TEST_TMP . '/fake-da';
$log = TEST_TMP . '/fake-da.log';
$audit = TEST_TMP . '/audit.log';
$state = TEST_TMP . '/state';
test_write($bin, "#!/bin/sh\nprintf '%s\\n' \"$@\" >> " . escapeshellarg($log) . "\necho 'URL: https://mx1.domena.pl:2222/api/login/url?key=SECRET'\n");
chmod($bin, 0755);
$script = PLUGIN_ROOT . '/mx-helper/mail-login-authorized-command.sh';
$cmd = sprintf(
'MAIL_LOGIN_DA_BIN=%s MAIL_LOGIN_AUDIT_LOG=%s MAIL_LOGIN_STATE_DIR=%s MAIL_LOGIN_ALLOWED_SOURCE_HOSTS=%s %s %s',
escapeshellarg($bin),
escapeshellarg($audit),
escapeshellarg($state),
escapeshellarg('h4.domena.pl'),
escapeshellarg($script),
'mail-login-create-url mxtest h4.domena.pl h4 30 1 198.51.100.10 / 1782810000'
);
exec($cmd, $out, $code);
assert_same(0, $code, implode("\n", $out));
assert_contains('--ip=198.51.100.10', file_get_contents($log) ?: '');
assert_contains('autologin-h4-mxtest-', file_get_contents($audit) ?: '');
file_put_contents($log, '');
$cmdNoIp = sprintf(
'MAIL_LOGIN_DA_BIN=%s MAIL_LOGIN_AUDIT_LOG=%s MAIL_LOGIN_STATE_DIR=%s MAIL_LOGIN_ALLOWED_SOURCE_HOSTS=%s %s %s',
escapeshellarg($bin),
escapeshellarg($audit),
escapeshellarg($state),
escapeshellarg('h4.domena.pl'),
escapeshellarg($script),
'mail-login-create-url mxtest h4.domena.pl h4 30 0 198.51.100.10 / 1782810001'
);
exec($cmdNoIp, $outNoIp, $codeNoIp);
assert_same(0, $codeNoIp, implode("\n", $outNoIp));
assert_not_contains('--ip=', file_get_contents($log) ?: '');
assert_contains('ip_bound_disabled', file_get_contents($audit) ?: '');
});
test('mx helper accepts forced command arguments from ssh original command', function (): void {
$bin = TEST_TMP . '/fake-da-original';
$log = TEST_TMP . '/fake-da-original.log';
$audit = TEST_TMP . '/audit-original.log';
$state = TEST_TMP . '/state-original';
test_write($bin, "#!/bin/sh\nprintf '%s\\n' \"$@\" >> " . escapeshellarg($log) . "\necho 'URL: https://mx1.domena.pl:2222/api/login/url?key=SECRET'\n");
chmod($bin, 0755);
$script = PLUGIN_ROOT . '/mx-helper/mail-login-authorized-command.sh';
$cmd = sprintf(
'MAIL_LOGIN_DA_BIN=%s MAIL_LOGIN_AUDIT_LOG=%s MAIL_LOGIN_STATE_DIR=%s MAIL_LOGIN_ALLOWED_SOURCE_HOSTS=%s SSH_ORIGINAL_COMMAND=%s %s',
escapeshellarg($bin),
escapeshellarg($audit),
escapeshellarg($state),
escapeshellarg('h4.domena.pl'),
escapeshellarg('mail-login-create-url mxtest h4.domena.pl evil 30 1 198.51.100.10 / 1782810002'),
escapeshellarg($script)
);
exec($cmd, $out, $code);
assert_same(0, $code, implode("\n", $out));
assert_contains('--user=mxtest', file_get_contents($log) ?: '');
assert_contains('autologin-h4-mxtest-', file_get_contents($audit) ?: '');
assert_not_contains('autologin-evil-mxtest-', file_get_contents($audit) ?: '');
});
test('mx helper rejects disallowed source hosts and invalid client ips', function (): void {
$bin = TEST_TMP . '/fake-da-reject';
$audit = TEST_TMP . '/audit-reject.log';
$state = TEST_TMP . '/state-reject';
test_write($bin, "#!/bin/sh\necho 'URL: https://mx1.domena.pl:2222/api/login/url?key=SECRET'\n");
chmod($bin, 0755);
$script = PLUGIN_ROOT . '/mx-helper/mail-login-authorized-command.sh';
$badHost = sprintf(
'MAIL_LOGIN_DA_BIN=%s MAIL_LOGIN_AUDIT_LOG=%s MAIL_LOGIN_STATE_DIR=%s MAIL_LOGIN_ALLOWED_SOURCE_HOSTS=%s %s %s 2>/dev/null',
escapeshellarg($bin),
escapeshellarg($audit),
escapeshellarg($state),
escapeshellarg('h4.domena.pl'),
escapeshellarg($script),
'mail-login-create-url mxtest evil.domena.pl evil 30 1 198.51.100.10 / 1782810003'
);
exec($badHost, $outHost, $codeHost);
assert_same(1, $codeHost);
$badIp = sprintf(
'MAIL_LOGIN_DA_BIN=%s MAIL_LOGIN_AUDIT_LOG=%s MAIL_LOGIN_STATE_DIR=%s MAIL_LOGIN_ALLOWED_SOURCE_HOSTS=%s %s %s 2>/dev/null',
escapeshellarg($bin),
escapeshellarg($audit),
escapeshellarg($state),
escapeshellarg('h4.domena.pl'),
escapeshellarg($script),
'mail-login-create-url mxtest h4.domena.pl h4 30 1 999.999.999.999 / 1782810004'
);
exec($badIp, $outIp, $codeIp);
assert_same(1, $codeIp);
assert_not_contains("\nBROKEN", file_get_contents($audit) ?: '');
});
+26 -1
View File
@@ -8,7 +8,7 @@ test('plugin metadata and packaging docs match project rules', function (): void
assert_contains('name=MX-Autologin', $conf); assert_contains('name=MX-Autologin', $conf);
assert_contains('id=mxautologin', $conf); assert_contains('id=mxautologin', $conf);
assert_contains('type=user', $conf); assert_contains('type=user', $conf);
assert_contains('version=1.0.14', $conf); assert_contains('version=1.1.1', $conf);
assert_contains('user_run_as=root', $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/src', $packing);
assert_contains('/Users/marek/GPT/ChatGPT/da_plugins/mail-login/archives/X.Y.Z/mxautologin.tar.gz', $packing); assert_contains('/Users/marek/GPT/ChatGPT/da_plugins/mail-login/archives/X.Y.Z/mxautologin.tar.gz', $packing);
@@ -39,6 +39,31 @@ test('package source excludes local-only files and dangerous placeholders', func
assert_same([], $bad); assert_same([], $bad);
}); });
test('package source contains no mx helper implementation or mode switch', function (): void {
assert_false(is_dir(PLUGIN_ROOT . '/mx-helper'), 'mx-helper directory must not be shipped');
$forbidden = [];
$needles = ['MX_LOGIN_MODE', 'mail-login-helper', 'mail-login-create-url', '/mx-helper/'];
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(PLUGIN_ROOT, FilesystemIterator::SKIP_DOTS));
foreach ($it as $file) {
if (!$file->isFile()) {
continue;
}
$relative = substr($file->getPathname(), strlen(PLUGIN_ROOT) + 1);
if (str_starts_with($relative, '.git/') || str_starts_with($relative, 'tests/')) {
continue;
}
$content = file_get_contents($file->getPathname()) ?: '';
foreach ($needles as $needle) {
if (str_contains($relative, $needle) || str_contains($content, $needle)) {
$forbidden[] = $relative . ':' . $needle;
}
}
}
assert_same([], $forbidden);
});
test('directadmin entry points use mx autologin directory and display label', function (): void { test('directadmin entry points use mx autologin directory and display label', function (): void {
$txt = file_get_contents(PLUGIN_ROOT . '/hooks/user_txt.html') ?: ''; $txt = file_get_contents(PLUGIN_ROOT . '/hooks/user_txt.html') ?: '';
$img = file_get_contents(PLUGIN_ROOT . '/hooks/user_img.html') ?: ''; $img = file_get_contents(PLUGIN_ROOT . '/hooks/user_img.html') ?: '';
+3 -26
View File
@@ -22,35 +22,13 @@ test('remote login url client builds direct ssh command by default', function ()
assert_contains('UserKnownHostsFile=/secure/known_hosts', implode(' ', $command)); assert_contains('UserKnownHostsFile=/secure/known_hosts', implode(' ', $command));
assert_contains('root@mx1.domena.pl', implode(' ', $command)); assert_contains('root@mx1.domena.pl', implode(' ', $command));
assert_not_contains('mail-login-create-url', implode(' ', $command)); assert_not_contains('mail-login-create-url', implode(' ', $command));
assert_not_contains('MX_LOGIN_MODE', implode(' ', $command));
assert_contains('/usr/bin/da login-url', implode(' ', $command)); assert_contains('/usr/bin/da login-url', implode(' ', $command));
assert_contains('--user=mxtest', implode(' ', $command)); assert_contains('--user=mxtest', implode(' ', $command));
assert_contains('--expiry=30s', implode(' ', $command)); assert_contains('--expiry=30s', implode(' ', $command));
assert_contains('--ip=198.51.100.10', implode(' ', $command)); assert_contains('--ip=198.51.100.10', implode(' ', $command));
}); });
test('remote login url client builds helper ssh command when mode is helper', function (): void {
$settings = Settings::fromArray([
'MAIL_LOGIN_TARGET_BASE_URL' => 'https://mx1.domena.pl:2222',
'MAIL_LOGIN_SSH_HOST' => 'mx1.domena.pl',
'MAIL_LOGIN_SSH_PORT' => '222',
'MAIL_LOGIN_SSH_USER' => 'root',
'MAIL_LOGIN_SSH_IDENTITY' => '/secure/key',
'MAIL_LOGIN_SSH_KNOWN_HOSTS' => '/secure/known_hosts',
'LOGIN_KEY_TTL' => '30',
'USER_IP_BOUND' => '1',
'MX_LOGIN_MODE' => '2',
]);
$request = new LoginUrlRequest('mxtest', 'h4.domena.pl', 'h4', 30, true, '198.51.100.10', '/', 1782810000);
$command = RemoteLoginUrlClient::buildCommand($settings, $request);
assert_same('ssh', $command[0]);
$pos = array_search('mail-login-create-url', $command, true);
assert_true(is_int($pos), 'Command marker missing');
assert_same('mxtest', $command[$pos + 1]);
assert_same('1', $command[$pos + 5]);
assert_same('1782810000', $command[$pos + 8]);
});
test('remote login url client omits expiry when ttl is zero', function (): void { test('remote login url client omits expiry when ttl is zero', function (): void {
$settings = Settings::fromArray([ $settings = Settings::fromArray([
'MAIL_LOGIN_TARGET_BASE_URL' => 'https://mx1.domena.pl:2222', 'MAIL_LOGIN_TARGET_BASE_URL' => 'https://mx1.domena.pl:2222',
@@ -65,7 +43,7 @@ test('remote login url client omits expiry when ttl is zero', function (): void
assert_not_contains('--expiry=', implode(' ', $command)); assert_not_contains('--expiry=', implode(' ', $command));
}); });
test('remote login url client validates helper output before returning it', function (): void { test('remote login url client validates direct ssh output before returning it', function (): void {
$settings = Settings::fromArray(['MAIL_LOGIN_TARGET_BASE_URL' => 'https://mx1.domena.pl:2222']); $settings = Settings::fromArray(['MAIL_LOGIN_TARGET_BASE_URL' => 'https://mx1.domena.pl:2222']);
$request = new LoginUrlRequest('mxtest', 'h4.domena.pl', 'h4', 30, true, '198.51.100.10', '/', 1782810000); $request = new LoginUrlRequest('mxtest', 'h4.domena.pl', 'h4', 30, true, '198.51.100.10', '/', 1782810000);
@@ -81,7 +59,7 @@ test('remote login url client validates helper output before returning it', func
try { try {
$badClient->create($settings, $request); $badClient->create($settings, $request);
throw new RuntimeException('Expected invalid helper URL to fail'); throw new RuntimeException('Expected invalid direct SSH URL to fail');
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
assert_contains('login url', strtolower($e->getMessage())); assert_contains('login url', strtolower($e->getMessage()));
} }
@@ -90,7 +68,6 @@ test('remote login url client validates helper output before returning it', func
test('remote login url client reports direct ssh failure diagnostics', function (): void { test('remote login url client reports direct ssh failure diagnostics', function (): void {
$settings = Settings::fromArray([ $settings = Settings::fromArray([
'MAIL_LOGIN_TARGET_BASE_URL' => 'https://mx1.domena.pl:2222', 'MAIL_LOGIN_TARGET_BASE_URL' => 'https://mx1.domena.pl:2222',
'MX_LOGIN_MODE' => '1',
]); ]);
$request = new LoginUrlRequest('mxtest', 'h4.domena.pl', 'h4', 30, true, '198.51.100.10', '/', 1782810000); $request = new LoginUrlRequest('mxtest', 'h4.domena.pl', 'h4', 30, true, '198.51.100.10', '/', 1782810000);
$client = new RemoteLoginUrlClient(function (array $command, int $timeout): ProcessResult { $client = new RemoteLoginUrlClient(function (array $command, int $timeout): ProcessResult {
+8 -14
View File
@@ -12,7 +12,6 @@ test('settings expose secure defaults from plugin-settings.conf', function (): v
assert_same('MASKED', $settings->auditClientIp('198.51.100.10')); assert_same('MASKED', $settings->auditClientIp('198.51.100.10'));
assert_true($settings->defaultEnable()); assert_true($settings->defaultEnable());
assert_same('login_url', $settings->method()); assert_same('login_url', $settings->method());
assert_same(1, $settings->mxLoginMode());
assert_same('root', $settings->sshUser()); assert_same('root', $settings->sshUser());
assert_same('https://mx1.mojserwer.pl:2222', $settings->targetBaseUrl()); assert_same('https://mx1.mojserwer.pl:2222', $settings->targetBaseUrl());
}); });
@@ -37,14 +36,12 @@ test('settings accept explicit ttl and user ip binding toggle', function (): voi
'LOGIN_KEY_TTL' => '5', 'LOGIN_KEY_TTL' => '5',
'USER_IP_BOUND' => '0', 'USER_IP_BOUND' => '0',
'IP_MASK' => '0', 'IP_MASK' => '0',
'MX_LOGIN_MODE' => '2',
]); ]);
assert_same(5, $settings->loginKeyTtl()); assert_same(5, $settings->loginKeyTtl());
assert_false($settings->userIpBound()); assert_false($settings->userIpBound());
assert_false($settings->ipMask()); assert_false($settings->ipMask());
assert_same('198.51.100.10', $settings->auditClientIp('198.51.100.10')); assert_same('198.51.100.10', $settings->auditClientIp('198.51.100.10'));
assert_same(2, $settings->mxLoginMode());
}); });
test('settings accept zero login key ttl as directadmin default expiry', function (): void { test('settings accept zero login key ttl as directadmin default expiry', function (): void {
@@ -64,17 +61,6 @@ test('settings reject unsupported ip mask values', function (): void {
} }
}); });
test('settings reject unsupported mx login mode values', function (): void {
foreach (['0', '3', 'helper', ''] as $mode) {
try {
Settings::fromArray(['MX_LOGIN_MODE' => $mode]);
throw new RuntimeException('Expected invalid MX_LOGIN_MODE to fail: ' . $mode);
} catch (InvalidArgumentException $e) {
assert_contains('MX_LOGIN_MODE', $e->getMessage());
}
}
});
test('source plugin settings do not expose local source host allowlist', function (): void { test('source plugin settings do not expose local source host allowlist', function (): void {
$defaults = Settings::defaults(); $defaults = Settings::defaults();
$config = file_get_contents(PLUGIN_ROOT . '/plugin-settings.conf') ?: ''; $config = file_get_contents(PLUGIN_ROOT . '/plugin-settings.conf') ?: '';
@@ -83,6 +69,14 @@ test('source plugin settings do not expose local source host allowlist', functio
assert_not_contains('MAIL_LOGIN_ALLOWED_SOURCE_HOSTS', $config); assert_not_contains('MAIL_LOGIN_ALLOWED_SOURCE_HOSTS', $config);
}); });
test('settings do not expose mx helper mode', function (): void {
$defaults = Settings::defaults();
$config = file_get_contents(PLUGIN_ROOT . '/plugin-settings.conf') ?: '';
assert_false(array_key_exists('MX_LOGIN_MODE', $defaults));
assert_not_contains('MX_LOGIN_MODE', $config);
});
test('settings reject disabled plugin and unsupported methods through access guard', function (): void { test('settings reject disabled plugin and unsupported methods through access guard', function (): void {
try { try {
Settings::fromArray(['MAIL_LOGIN_METHOD' => 'password']); Settings::fromArray(['MAIL_LOGIN_METHOD' => 'password']);