fix: harden mx helper and cleanup flow
This commit is contained in:
Executable → Regular
+91
-33
@@ -2,51 +2,109 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$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',
|
||||
];
|
||||
|
||||
if ($config['user'] === '' || $config['key'] === '') {
|
||||
fwrite(STDERR, "DirectAdmin API credentials are required for cleanup\n");
|
||||
exit(2);
|
||||
if (realpath((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||
mailLoginCleanupMain();
|
||||
}
|
||||
|
||||
$base = rtrim($config['base_url'], '/');
|
||||
$now = time();
|
||||
$entries = apiRequest('GET', $base . '/api/login-keys/urls', $config);
|
||||
if (!is_array($entries)) {
|
||||
fwrite(STDERR, "DirectAdmin returned invalid login URL list\n");
|
||||
exit(1);
|
||||
}
|
||||
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',
|
||||
];
|
||||
|
||||
$deleted = 0;
|
||||
foreach ($entries as $entry) {
|
||||
if (!is_array($entry) || empty($entry['id']) || empty($entry['expires'])) {
|
||||
continue;
|
||||
if ($config['user'] === '' || $config['key'] === '') {
|
||||
fwrite(STDERR, "DirectAdmin API credentials are required for cleanup\n");
|
||||
exit(2);
|
||||
}
|
||||
$expires = strtotime((string)$entry['expires']);
|
||||
if ($expires !== false && $expires < $now) {
|
||||
apiRequest('DELETE', $base . '/api/login-keys/urls/' . rawurlencode((string)$entry['id']), $config);
|
||||
$deleted++;
|
||||
|
||||
$base = rtrim($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";
|
||||
}
|
||||
|
||||
audit($config['audit_log'], [
|
||||
'event' => 'cleanup_expired_login_urls',
|
||||
'result' => 'success',
|
||||
'deleted' => $deleted,
|
||||
]);
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
echo "Deleted expired login URLs: {$deleted}\n";
|
||||
/**
|
||||
* @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 (($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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $config
|
||||
* @return mixed
|
||||
*/
|
||||
function apiRequest(string $method, string $url, array $config): mixed
|
||||
function mailLoginCleanupApiRequest(string $method, string $url, array $config): mixed
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
@@ -77,7 +135,7 @@ function apiRequest(string $method, string $url, array $config): mixed
|
||||
/**
|
||||
* @param array<string,mixed> $fields
|
||||
*/
|
||||
function audit(string $path, array $fields): void
|
||||
function mailLoginCleanupAudit(string $path, array $fields): void
|
||||
{
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
|
||||
@@ -4,8 +4,8 @@ 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/logs" "$BASE_DIR/state"
|
||||
chmod 700 "$BASE_DIR" "$BASE_DIR/bin" "$BASE_DIR/logs" "$BASE_DIR/state"
|
||||
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"
|
||||
@@ -13,4 +13,17 @@ chmod 755 "$BASE_DIR/bin/create-login-url" "$BASE_DIR/bin/cleanup-expired-login-
|
||||
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"
|
||||
|
||||
@@ -3,9 +3,15 @@ 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"
|
||||
@@ -25,6 +31,27 @@ 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.
|
||||
@@ -43,11 +70,15 @@ REDIRECT_PATH="${8:-}"
|
||||
[ "$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
|
||||
printf '%s' "$SERVER_NAME" | grep -Eq '^[A-Za-z0-9-]{1,32}$' || 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
|
||||
printf '%s' "$CLIENT_IP" | grep -Eq '^[0-9A-Fa-f:.]+$' || fail
|
||||
valid_ip "$CLIENT_IP" || fail
|
||||
case "$REDIRECT_PATH" in
|
||||
/*) ;;
|
||||
*) fail ;;
|
||||
@@ -86,8 +117,8 @@ case "$URL" in
|
||||
*) fail ;;
|
||||
esac
|
||||
|
||||
printf '{"timestamp":%s,"correlation":"%s","username":"%s","source_host":"%s","client_ip":"%s","ttl":%s,"ip_bound":%s}\n' \
|
||||
"$TIMESTAMP" "$CORRELATION" "$USERNAME_ARG" "$SOURCE_HOST" "$CLIENT_IP" "$TTL" "$USER_IP_BOUND" > "$STATE_DIR/$CORRELATION.json"
|
||||
printf '{"timestamp":%s,"expires":%s,"correlation":"%s","username":"%s","source_host":"%s","client_ip":"%s","ttl":%s,"ip_bound":%s,"redirect_path":"%s","status":"created"}\n' \
|
||||
"$TIMESTAMP" "$((TIMESTAMP + TTL))" "$CORRELATION" "$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"
|
||||
|
||||
Reference in New Issue
Block a user