diff --git a/exec/bootstrap.php b/exec/bootstrap.php new file mode 100644 index 0000000..68e8ca1 --- /dev/null +++ b/exec/bootstrap.php @@ -0,0 +1,48 @@ + 'plugin_error', + 'result' => 'failure', + 'category' => get_class($e), + 'message' => $e->getMessage(), + ]); + Http::errorPage('Nie można teraz zalogować do serwera poczty.', 500); +} diff --git a/exec/handlers/login.php b/exec/handlers/login.php new file mode 100644 index 0000000..6d817a0 --- /dev/null +++ b/exec/handlers/login.php @@ -0,0 +1,59 @@ +sourceHost(), $settings); + + $auditBase = [ + 'request_id' => bin2hex(random_bytes(8)), + 'source_host' => $ctx->sourceHost(), + 'server_name' => $ctx->serverName(), + 'target_host' => $settings->targetBaseUrl(), + 'username' => $ctx->username(), + 'ttl' => $settings->loginKeyTtl(), + 'user_ip_bound' => $settings->userIpBound() ? 1 : 0, + ]; + + $clientIp = ClientIp::resolve($_SERVER, $settings); + $auditBase['client_ip'] = $clientIp; + $correlation = KeyName::build($ctx->serverName(), $ctx->username(), time()); + $auditBase['correlation'] = $correlation; + + $limiter = new RateLimiter(Settings::STATE_DIR . '/rate-limit'); + $limiter->assertAllowed('user-' . $ctx->username(), $settings->rateLimitPerUserPerMinute(), 60); + $limiter->assertAllowed('ip-' . str_replace([':', '.'], '-', $clientIp), $settings->rateLimitPerIpPerMinute(), 60); + + $redirectPath = UrlValidator::assertSafeRedirectPath($settings->redirectUrl()); + $request = new LoginUrlRequest( + $ctx->username(), + $ctx->sourceHost(), + $ctx->serverName(), + $settings->loginKeyTtl(), + $settings->userIpBound(), + $clientIp, + $redirectPath + ); + + try { + $url = (new RemoteLoginUrlClient())->create($settings, $request); + AuditLog::append(Settings::AUDIT_LOG, $auditBase + [ + 'event' => 'create_login_url', + 'result' => 'success', + ]); + Http::redirectNoStore($url); + } catch (Throwable $e) { + AuditLog::append(Settings::AUDIT_LOG, $auditBase + [ + 'event' => 'create_login_url', + 'result' => 'failure', + 'category' => get_class($e), + 'message' => $e->getMessage(), + ]); + Http::errorPage('Nie można teraz zalogować do serwera poczty.', 502); + } +}; diff --git a/exec/lib/AuditLog.php b/exec/lib/AuditLog.php new file mode 100644 index 0000000..8a14e33 --- /dev/null +++ b/exec/lib/AuditLog.php @@ -0,0 +1,48 @@ + $fields + */ + public static function append(string $path, array $fields): void + { + self::safeAppend($path, $fields); + } + + /** + * @param array $fields + */ + public static function safeAppend(string $path, array $fields): void + { + $dir = dirname($path); + if (!is_dir($dir)) { + @mkdir($dir, 0700, true); + } + $fields = self::redact($fields); + $fields['timestamp'] = gmdate('c'); + $line = json_encode($fields, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if (is_string($line)) { + @file_put_contents($path, $line . "\n", FILE_APPEND | LOCK_EX); + @chmod($path, 0600); + } + } + + /** + * @param array $fields + * @return array + */ + private static function redact(array $fields): array + { + foreach ($fields as $key => $value) { + $lower = strtolower((string)$key); + if (str_contains($lower, 'url') || str_contains($lower, 'key') || str_contains($lower, 'password') || str_contains($lower, 'secret')) { + $fields[$key] = '[redacted]'; + } elseif (is_string($value)) { + $fields[$key] = preg_replace('/key=[A-Za-z0-9._-]+/', 'key=[redacted]', $value) ?? $value; + } + } + return $fields; + } +} diff --git a/exec/lib/ClientIp.php b/exec/lib/ClientIp.php new file mode 100644 index 0000000..a930f68 --- /dev/null +++ b/exec/lib/ClientIp.php @@ -0,0 +1,76 @@ + $server + */ + public static function resolve(array $server, Settings $settings): string + { + $remote = self::validIp((string)($server['REMOTE_ADDR'] ?? '')); + if ($settings->clientIpMode() === 'direct') { + return $remote; + } + if (!self::ipInList($remote, $settings->trustedProxies())) { + throw new RuntimeException('REMOTE_ADDR is not a trusted proxy'); + } + $xff = (string)($server['HTTP_X_FORWARDED_FOR'] ?? ''); + foreach (explode(',', $xff) as $candidate) { + $candidate = trim($candidate); + if (filter_var($candidate, FILTER_VALIDATE_IP)) { + return $candidate; + } + } + throw new RuntimeException('Missing valid forwarded client IP'); + } + + private static function validIp(string $ip): string + { + $ip = trim($ip); + if (!filter_var($ip, FILTER_VALIDATE_IP)) { + throw new InvalidArgumentException('Invalid client IP'); + } + return $ip; + } + + /** + * @param list $allowed + */ + private static function ipInList(string $ip, array $allowed): bool + { + foreach ($allowed as $entry) { + if ($entry === $ip) { + return true; + } + if (str_contains($entry, '/') && self::cidrContains($entry, $ip)) { + return true; + } + } + return false; + } + + private static function cidrContains(string $cidr, string $ip): bool + { + [$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, ''); + if (!filter_var($network, FILTER_VALIDATE_IP) || !preg_match('/^[0-9]+$/', $prefix)) { + return false; + } + $networkBin = inet_pton($network); + $ipBin = inet_pton($ip); + if ($networkBin === false || $ipBin === false || strlen($networkBin) !== strlen($ipBin)) { + return false; + } + $bits = (int)$prefix; + $bytes = intdiv($bits, 8); + $remainder = $bits % 8; + if ($bytes > 0 && substr($networkBin, 0, $bytes) !== substr($ipBin, 0, $bytes)) { + return false; + } + if ($remainder === 0) { + return true; + } + $mask = chr((0xff << (8 - $remainder)) & 0xff); + return (ord($networkBin[$bytes]) & ord($mask)) === (ord($ipBin[$bytes]) & ord($mask)); + } +} diff --git a/exec/lib/DirectAdminContext.php b/exec/lib/DirectAdminContext.php new file mode 100644 index 0000000..de8ae69 --- /dev/null +++ b/exec/lib/DirectAdminContext.php @@ -0,0 +1,56 @@ + $server + */ + public static function fromServer(array $server, Settings $settings): self + { + $username = trim((string)($server['USERNAME'] ?? $server['USER'] ?? '')); + if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/', $username)) { + throw new InvalidArgumentException('Invalid DirectAdmin username'); + } + $host = Settings::normalizeHost((string)($server['HTTP_HOST'] ?? $server['SERVER_NAME'] ?? '')); + if ($host === '') { + throw new InvalidArgumentException('Missing source host'); + } + $serverName = $settings->sourceServerName(); + if ($serverName === '') { + $serverName = $settings->sanitizeServerName(explode('.', $host)[0] ?? ''); + } + if ($serverName === '') { + throw new InvalidArgumentException('Invalid source server name'); + } + return new self($username, $host, $serverName, strtoupper((string)($server['REQUEST_METHOD'] ?? 'GET'))); + } + + public function username(): string + { + return $this->username; + } + + public function sourceHost(): string + { + return $this->sourceHost; + } + + public function serverName(): string + { + return $this->serverName; + } + + public function method(): string + { + return $this->method; + } +} diff --git a/exec/lib/Http.php b/exec/lib/Http.php new file mode 100644 index 0000000..6e37102 --- /dev/null +++ b/exec/lib/Http.php @@ -0,0 +1,62 @@ +'; + echo ''; + echo 'Serwer poczty'; + echo '

Serwer poczty

' . $safe . '

'; + echo ''; + exit; + } +} diff --git a/exec/lib/KeyName.php b/exec/lib/KeyName.php new file mode 100644 index 0000000..d49b040 --- /dev/null +++ b/exec/lib/KeyName.php @@ -0,0 +1,30 @@ +dir)) { + mkdir($this->dir, 0700, true); + } + $file = $this->dir . '/' . preg_replace('/[^A-Za-z0-9._-]/', '_', $bucket) . '.json'; + $now = time(); + $events = []; + if (is_file($file)) { + $decoded = json_decode((string)file_get_contents($file), true); + if (is_array($decoded)) { + foreach ($decoded as $event) { + if (is_int($event) && $event > $now - $windowSeconds) { + $events[] = $event; + } + } + } + } + if (count($events) >= $limit) { + throw new RuntimeException('Rate limit exceeded'); + } + $events[] = $now; + file_put_contents($file, json_encode($events), LOCK_EX); + @chmod($file, 0600); + } +} diff --git a/exec/lib/RemoteLoginUrlClient.php b/exec/lib/RemoteLoginUrlClient.php new file mode 100644 index 0000000..5137ff8 --- /dev/null +++ b/exec/lib/RemoteLoginUrlClient.php @@ -0,0 +1,136 @@ +,int):ProcessResult */ + private $runner; + + /** + * @param null|callable(array,int):ProcessResult $runner + */ + public function __construct(?callable $runner = null) + { + $this->runner = $runner ?? [self::class, 'runProcess']; + } + + /** + * @return list + */ + public static function buildCommand(Settings $settings, LoginUrlRequest $request): array + { + return [ + 'ssh', + '-o', + 'BatchMode=yes', + '-o', + 'StrictHostKeyChecking=yes', + '-o', + 'UserKnownHostsFile=' . $settings->sshKnownHosts(), + '-o', + 'IdentitiesOnly=yes', + '-i', + $settings->sshIdentity(), + '-p', + (string)$settings->sshPort(), + $settings->sshUser() . '@' . $settings->sshHost(), + 'mail-login-create-url', + $request->username, + $request->sourceHost, + $request->serverName, + (string)$request->ttl, + $request->userIpBound ? '1' : '0', + $request->clientIp, + $request->redirectPath, + ]; + } + + public function create(Settings $settings, LoginUrlRequest $request): string + { + $command = self::buildCommand($settings, $request); + $result = ($this->runner)($command, $settings->connectTimeout()); + if (!$result instanceof ProcessResult) { + throw new RuntimeException('Invalid process runner result'); + } + if ($result->exitCode !== 0) { + throw new RuntimeException('MX helper failed'); + } + if (!preg_match('/https:\/\/\S+/', $result->stdout, $match)) { + throw new RuntimeException('MX helper did not return a login URL'); + } + try { + return UrlValidator::assertGeneratedLoginUrl(trim($match[0]), $settings); + } catch (InvalidArgumentException $e) { + throw new RuntimeException($e->getMessage(), 0, $e); + } + } + + /** + * @param list $command + */ + public static function runProcess(array $command, int $timeout): ProcessResult + { + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open($command, $descriptors, $pipes); + if (!is_resource($process)) { + throw new RuntimeException('Cannot start MX helper'); + } + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $stdout = ''; + $stderr = ''; + $deadline = time() + $timeout; + do { + $stdout .= stream_get_contents($pipes[1]) ?: ''; + $stderr .= stream_get_contents($pipes[2]) ?: ''; + $status = proc_get_status($process); + if (!$status['running']) { + break; + } + if (time() >= $deadline) { + proc_terminate($process); + foreach ([$pipes[1], $pipes[2]] as $pipe) { + fclose($pipe); + } + proc_close($process); + throw new RuntimeException('MX helper timed out'); + } + usleep(100000); + } while (true); + $stdout .= stream_get_contents($pipes[1]) ?: ''; + $stderr .= stream_get_contents($pipes[2]) ?: ''; + fclose($pipes[1]); + fclose($pipes[2]); + $exit = proc_close($process); + return new ProcessResult($exit, substr($stdout, 0, 8192), substr($stderr, 0, 8192)); + } +} diff --git a/exec/lib/Settings.php b/exec/lib/Settings.php new file mode 100644 index 0000000..9012656 --- /dev/null +++ b/exec/lib/Settings.php @@ -0,0 +1,246 @@ + */ + private array $values; + + /** + * @param array $values + */ + private function __construct(array $values) + { + $this->values = $values; + $this->validate(); + } + + public static function load(string $path): self + { + $values = self::defaults(); + if (is_file($path)) { + $lines = file($path, FILE_IGNORE_NEW_LINES); + if ($lines !== false) { + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { + continue; + } + [$key, $value] = explode('=', $line, 2); + $values[trim($key)] = trim($value); + } + } + } + return new self($values); + } + + /** + * @param array $values + */ + public static function fromArray(array $values): self + { + return new self(array_merge(self::defaults(), $values)); + } + + /** + * @return array + */ + public static function defaults(): array + { + return [ + 'DEFAULT_ENABLE' => 'true', + 'MAIL_LOGIN_METHOD' => 'login_url', + 'MAIL_LOGIN_TARGET_NAME' => 'mx1', + 'MAIL_LOGIN_TARGET_BASE_URL' => 'https://mx1.mojserwer.pl:2222', + 'MAIL_LOGIN_REDIRECT_URL' => '/', + 'LOGIN_KEY_TTL' => '30', + 'USER_IP_BOUND' => '1', + 'MAIL_LOGIN_ALLOWED_SOURCE_HOSTS' => 's1.abc.pl,serwer.mojserwer.pl,serwer2.xxb.ovh', + 'MAIL_LOGIN_SOURCE_SERVER_NAME' => '', + 'MAIL_LOGIN_CLIENT_IP_MODE' => 'direct', + 'MAIL_LOGIN_TRUSTED_PROXIES' => '', + 'MAIL_LOGIN_REMOTE_TRANSPORT' => 'ssh', + 'MAIL_LOGIN_SSH_HOST' => 'mx1.mojserwer.pl', + 'MAIL_LOGIN_SSH_PORT' => '22', + 'MAIL_LOGIN_SSH_USER' => 'root', + 'MAIL_LOGIN_SSH_IDENTITY' => self::SECRET_DIR . '/mx1_ed25519', + 'MAIL_LOGIN_SSH_KNOWN_HOSTS' => self::SECRET_DIR . '/known_hosts', + 'MAIL_LOGIN_CONNECT_TIMEOUT_SECONDS' => '5', + 'MAIL_LOGIN_RATE_LIMIT_PER_USER_PER_MINUTE' => '5', + 'MAIL_LOGIN_RATE_LIMIT_PER_IP_PER_MINUTE' => '20', + 'MAIL_LOGIN_ALLOW_PASSWORD_FALLBACK' => 'false', + ]; + } + + public static function normalizeHost(string $host): string + { + $host = trim($host); + $host = preg_replace('/^https?:\/\//i', '', $host) ?? $host; + $host = preg_replace('/\/.*$/', '', $host) ?? $host; + if (str_starts_with($host, '[')) { + $end = strpos($host, ']'); + if ($end !== false) { + return strtolower(substr($host, 1, $end - 1)); + } + } + $host = preg_replace('/:[0-9]+$/', '', $host) ?? $host; + return strtolower(trim($host, ". \t\n\r\0\x0B")); + } + + public function getString(string $key, string $default = ''): string + { + return $this->values[$key] ?? $default; + } + + public function targetBaseUrl(): string + { + return rtrim($this->getString('MAIL_LOGIN_TARGET_BASE_URL'), '/'); + } + + public function redirectUrl(): string + { + return $this->getString('MAIL_LOGIN_REDIRECT_URL', '/'); + } + + public function loginKeyTtl(): int + { + return (int)$this->getString('LOGIN_KEY_TTL', '30'); + } + + public function userIpBound(): bool + { + return $this->getString('USER_IP_BOUND', '1') === '1'; + } + + /** + * @return list + */ + public function allowedSourceHosts(): array + { + return $this->csvHosts('MAIL_LOGIN_ALLOWED_SOURCE_HOSTS'); + } + + public function sourceServerName(): string + { + return $this->sanitizeServerName($this->getString('MAIL_LOGIN_SOURCE_SERVER_NAME', '')); + } + + public function clientIpMode(): string + { + return $this->getString('MAIL_LOGIN_CLIENT_IP_MODE', 'direct'); + } + + /** + * @return list + */ + public function trustedProxies(): array + { + return $this->csvHosts('MAIL_LOGIN_TRUSTED_PROXIES'); + } + + public function sshHost(): string + { + return self::normalizeHost($this->getString('MAIL_LOGIN_SSH_HOST')); + } + + public function sshPort(): int + { + return (int)$this->getString('MAIL_LOGIN_SSH_PORT', '22'); + } + + public function sshUser(): string + { + return $this->getString('MAIL_LOGIN_SSH_USER', 'root'); + } + + public function sshIdentity(): string + { + return $this->getString('MAIL_LOGIN_SSH_IDENTITY'); + } + + public function sshKnownHosts(): string + { + return $this->getString('MAIL_LOGIN_SSH_KNOWN_HOSTS'); + } + + public function connectTimeout(): int + { + return (int)$this->getString('MAIL_LOGIN_CONNECT_TIMEOUT_SECONDS', '5'); + } + + public function rateLimitPerUserPerMinute(): int + { + return (int)$this->getString('MAIL_LOGIN_RATE_LIMIT_PER_USER_PER_MINUTE', '5'); + } + + public function rateLimitPerIpPerMinute(): int + { + return (int)$this->getString('MAIL_LOGIN_RATE_LIMIT_PER_IP_PER_MINUTE', '20'); + } + + public function sanitizeServerName(string $name): string + { + $name = strtolower(trim($name)); + $name = preg_replace('/[^a-z0-9-]+/', '-', $name) ?? ''; + $name = trim($name, '-'); + if ($name === '') { + return ''; + } + return substr($name, 0, 32); + } + + /** + * @return list + */ + private function csvHosts(string $key): array + { + $raw = $this->getString($key, ''); + if ($raw === '') { + return []; + } + $out = []; + foreach (explode(',', $raw) as $item) { + $host = self::normalizeHost($item); + if ($host !== '') { + $out[] = $host; + } + } + return array_values(array_unique($out)); + } + + private function validate(): void + { + $ttlRaw = $this->getString('LOGIN_KEY_TTL', ''); + if (!preg_match('/^[0-9]+$/', $ttlRaw)) { + throw new InvalidArgumentException('LOGIN_KEY_TTL must be an integer'); + } + $ttl = (int)$ttlRaw; + if ($ttl < 5 || $ttl > 300) { + throw new InvalidArgumentException('LOGIN_KEY_TTL must be between 5 and 300 seconds'); + } + if (!in_array($this->getString('USER_IP_BOUND', ''), ['0', '1'], true)) { + throw new InvalidArgumentException('USER_IP_BOUND must be 0 or 1'); + } + if (!in_array($this->clientIpMode(), ['direct', 'trusted_proxy'], true)) { + throw new InvalidArgumentException('MAIL_LOGIN_CLIENT_IP_MODE must be direct or trusted_proxy'); + } + $base = parse_url($this->targetBaseUrl()); + if (!is_array($base) || ($base['scheme'] ?? '') !== 'https' || empty($base['host'])) { + throw new InvalidArgumentException('MAIL_LOGIN_TARGET_BASE_URL must be an https URL'); + } + if ($this->sshPort() < 1 || $this->sshPort() > 65535) { + throw new InvalidArgumentException('MAIL_LOGIN_SSH_PORT is invalid'); + } + if ($this->connectTimeout() < 1 || $this->connectTimeout() > 30) { + throw new InvalidArgumentException('MAIL_LOGIN_CONNECT_TIMEOUT_SECONDS is invalid'); + } + } +} diff --git a/exec/lib/SourceAuthorizer.php b/exec/lib/SourceAuthorizer.php new file mode 100644 index 0000000..1e7cd46 --- /dev/null +++ b/exec/lib/SourceAuthorizer.php @@ -0,0 +1,16 @@ +allowedSourceHosts(); + if ($allowed === []) { + throw new RuntimeException('No source host allowlist configured'); + } + if (!in_array(Settings::normalizeHost($sourceHost), $allowed, true)) { + throw new RuntimeException('Source host is not allowed'); + } + } +} diff --git a/exec/lib/UrlValidator.php b/exec/lib/UrlValidator.php new file mode 100644 index 0000000..c506600 --- /dev/null +++ b/exec/lib/UrlValidator.php @@ -0,0 +1,39 @@ +targetBaseUrl()); + if (!is_array($parts) || !is_array($base)) { + throw new InvalidArgumentException('Invalid login URL'); + } + $scheme = strtolower((string)($parts['scheme'] ?? '')); + $host = strtolower((string)($parts['host'] ?? '')); + $baseHost = strtolower((string)($base['host'] ?? '')); + $port = (int)($parts['port'] ?? ($scheme === 'https' ? 443 : 80)); + $basePort = (int)($base['port'] ?? 443); + parse_str((string)($parts['query'] ?? ''), $query); + if ($scheme !== 'https' || $host !== $baseHost || $port !== $basePort || ($parts['path'] ?? '') !== '/api/login/url' || empty($query['key'])) { + throw new InvalidArgumentException('Invalid login URL'); + } + return $url; + } +} diff --git a/hooks/user_img.html b/hooks/user_img.html new file mode 100644 index 0000000..38cfba6 --- /dev/null +++ b/hooks/user_img.html @@ -0,0 +1 @@ +Serwer poczty diff --git a/hooks/user_txt.html b/hooks/user_txt.html new file mode 100644 index 0000000..85e7833 --- /dev/null +++ b/hooks/user_txt.html @@ -0,0 +1 @@ +Serwer poczty diff --git a/images/user_icon.svg b/images/user_icon.svg new file mode 100644 index 0000000..b14c434 --- /dev/null +++ b/images/user_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mx-helper/cleanup-expired-login-urls.php b/mx-helper/cleanup-expired-login-urls.php new file mode 100755 index 0000000..2bfbce0 --- /dev/null +++ b/mx-helper/cleanup-expired-login-urls.php @@ -0,0 +1,89 @@ +#!/usr/local/bin/php + 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); +} + +$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); +} + +$deleted = 0; +foreach ($entries as $entry) { + if (!is_array($entry) || empty($entry['id']) || empty($entry['expires'])) { + continue; + } + $expires = strtotime((string)$entry['expires']); + if ($expires !== false && $expires < $now) { + apiRequest('DELETE', $base . '/api/login-keys/urls/' . rawurlencode((string)$entry['id']), $config); + $deleted++; + } +} + +audit($config['audit_log'], [ + 'event' => 'cleanup_expired_login_urls', + 'result' => 'success', + 'deleted' => $deleted, +]); + +echo "Deleted expired login URLs: {$deleted}\n"; + +/** + * @param array $config + * @return mixed + */ +function apiRequest(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 $fields + */ +function audit(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); +} diff --git a/mx-helper/install-helper.sh b/mx-helper/install-helper.sh new file mode 100755 index 0000000..c443e75 --- /dev/null +++ b/mx-helper/install-helper.sh @@ -0,0 +1,16 @@ +#!/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/logs" "$BASE_DIR/state" +chmod 700 "$BASE_DIR" "$BASE_DIR/bin" "$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" + +echo "mail-login MX helper installed at $BASE_DIR" diff --git a/mx-helper/mail-login-authorized-command.sh b/mx-helper/mail-login-authorized-command.sh new file mode 100755 index 0000000..5f0b08b --- /dev/null +++ b/mx-helper/mail-login-authorized-command.sh @@ -0,0 +1,93 @@ +#!/bin/sh +set -eu +set -f + +DA_BIN="${MAIL_LOGIN_DA_BIN:-/usr/bin/da}" +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}" + +audit() { + dir=$(dirname "$AUDIT_LOG") + mkdir -p "$dir" + chmod 700 "$dir" + printf '{"timestamp":"%s","event":"%s","result":"%s","correlation":"%s","username":"%s","source_host":"%s","client_ip":"%s"}\n' \ + "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$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/-*$//' +} + +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:-}" + +[ "$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 +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 +case "$REDIRECT_PATH" in + /*) ;; + *) fail ;; +esac +case "$REDIRECT_PATH" in + //*|*\\*) fail ;; +esac +printf '%s' "$REDIRECT_PATH" | grep -Eq '^/[A-Za-z0-9._~%/?=+-]*$' || fail + +SERVER_CLEAN=$(clean_label "$SERVER_NAME") +USER_CLEAN=$(clean_label "$USERNAME_ARG") +TIMESTAMP=$(date '+%s') +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 + +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" +chmod 600 "$STATE_DIR/$CORRELATION.json" +audit "create_login_url" "success" "$CORRELATION" "$USERNAME_ARG" "$SOURCE_HOST" "$CLIENT_IP" +printf 'URL: %s\n' "$URL" diff --git a/php.ini b/php.ini new file mode 100644 index 0000000..b0a9cf1 --- /dev/null +++ b/php.ini @@ -0,0 +1,7 @@ +display_errors=0 +log_errors=1 +html_errors=0 +expose_php=0 +memory_limit=64M +max_execution_time=10 +default_charset=UTF-8 diff --git a/plugin-settings.conf b/plugin-settings.conf new file mode 100644 index 0000000..2ef726e --- /dev/null +++ b/plugin-settings.conf @@ -0,0 +1,42 @@ +# Czy plugin jest domyślnie dostępny dla wszystkich użytkowników. +DEFAULT_ENABLE=true + +# Metoda produkcyjna. Wersja 1 używa jednorazowego DirectAdmin login URL generowanego na MX. +MAIL_LOGIN_METHOD=login_url + +# Nazwa i adres docelowego panelu poczty. +MAIL_LOGIN_TARGET_NAME=mx1 +MAIL_LOGIN_TARGET_BASE_URL=https://mx1.mojserwer.pl:2222 +MAIL_LOGIN_REDIRECT_URL=/ + +# Czas życia jednorazowego login URL w sekundach. Dozwolony zakres: 5-300. +LOGIN_KEY_TTL=30 + +# 1 = przypnij URL do IP przeglądarki użytkownika, 0 = nie przypinaj do IP. +USER_IP_BOUND=1 + +# Hosty DirectAdmin WWW, z których wolno inicjować autologowanie do MX. +MAIL_LOGIN_ALLOWED_SOURCE_HOSTS=s1.abc.pl,serwer.mojserwer.pl,serwer2.xxb.ovh + +# Opcjonalna jawna nazwa źródłowego serwera. Puste = pierwsza etykieta HTTP_HOST. +MAIL_LOGIN_SOURCE_SERVER_NAME= + +# direct = REMOTE_ADDR, trusted_proxy = pierwszy X-Forwarded-For tylko od zaufanego proxy. +MAIL_LOGIN_CLIENT_IP_MODE=direct +MAIL_LOGIN_TRUSTED_PROXIES= + +# Serwerowy kanał SSH do helpera na MX. Klucz jest root-owned i nigdy nie trafia do użytkownika. +MAIL_LOGIN_REMOTE_TRANSPORT=ssh +MAIL_LOGIN_SSH_HOST=mx1.mojserwer.pl +MAIL_LOGIN_SSH_PORT=22 +MAIL_LOGIN_SSH_USER=root +MAIL_LOGIN_SSH_IDENTITY=/usr/local/hitme_plugins/mail-login/secrets/mx1_ed25519 +MAIL_LOGIN_SSH_KNOWN_HOSTS=/usr/local/hitme_plugins/mail-login/secrets/known_hosts +MAIL_LOGIN_CONNECT_TIMEOUT_SECONDS=5 + +# Limity bezpieczeństwa na źródłowym serwerze DirectAdmin. +MAIL_LOGIN_RATE_LIMIT_PER_USER_PER_MINUTE=5 +MAIL_LOGIN_RATE_LIMIT_PER_IP_PER_MINUTE=20 + +# Awaryjny fallback hasłowy pozostaje wyłączony. +MAIL_LOGIN_ALLOW_PASSWORD_FALLBACK=false diff --git a/plugin.conf b/plugin.conf new file mode 100644 index 0000000..00d69b9 --- /dev/null +++ b/plugin.conf @@ -0,0 +1,8 @@ +name=mail-login +id=mail-login +type=user +author=HITME.PL +version=1.0.1 +active=no +installed=no +user_run_as=root diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..57e84cc --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +BASE_DIR="/usr/local/hitme_plugins/mail-login" +PLUGIN_DIR="/usr/local/directadmin/plugins/mail-login" + +mkdir -p "$BASE_DIR/config" "$BASE_DIR/secrets" "$BASE_DIR/logs" "$BASE_DIR/state/rate-limit" +chmod 700 "$BASE_DIR/config" "$BASE_DIR/secrets" "$BASE_DIR/logs" "$BASE_DIR/state" "$BASE_DIR/state/rate-limit" + +if [ ! -f "$BASE_DIR/config/plugin-settings.conf" ]; then + cp "$PLUGIN_DIR/plugin-settings.conf" "$BASE_DIR/config/plugin-settings.conf" + chmod 600 "$BASE_DIR/config/plugin-settings.conf" +fi + +touch "$BASE_DIR/logs/audit.log" +chmod 600 "$BASE_DIR/logs/audit.log" + +echo "mail-login installed" diff --git a/scripts/package.sh b/scripts/package.sh new file mode 100755 index 0000000..fdcd96d --- /dev/null +++ b/scripts/package.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -eu + +VERSION="$(awk -F= '$1=="version"{print $2}' plugin.conf)" +if [ -z "$VERSION" ]; then + echo "Cannot determine version from plugin.conf" >&2 + exit 1 +fi + +ROOT_DIR="$(cd .. && pwd)" +ARCHIVE_DIR="$ROOT_DIR/archives/$VERSION" +mkdir -p "$ARCHIVE_DIR" + +tar -czf "$ARCHIVE_DIR/mail-login.tar.gz" \ + --exclude='./.git' \ + --exclude='./tests' \ + --exclude='./scripts/package.sh' \ + --exclude='./data' \ + --exclude='./state' \ + --exclude='./logs' \ + --exclude='./*.log' \ + --exclude='./.DS_Store' \ + . + +echo "$ARCHIVE_DIR/mail-login.tar.gz" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..88ca19e --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +BASE_DIR="/usr/local/hitme_plugins/mail-login" + +if [ "${PURGE_MAIL_LOGIN_DATA:-0}" = "1" ]; then + rm -rf "$BASE_DIR" + echo "mail-login data purged" +else + echo "mail-login uninstalled; external data preserved at $BASE_DIR" +fi diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100755 index 0000000..598ffec --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +BASE_DIR="/usr/local/hitme_plugins/mail-login" +mkdir -p "$BASE_DIR/config" "$BASE_DIR/secrets" "$BASE_DIR/logs" "$BASE_DIR/state/rate-limit" +chmod 700 "$BASE_DIR/config" "$BASE_DIR/secrets" "$BASE_DIR/logs" "$BASE_DIR/state" "$BASE_DIR/state/rate-limit" +touch "$BASE_DIR/logs/audit.log" +chmod 600 "$BASE_DIR/logs/audit.log" + +echo "mail-login updated; external configuration preserved" diff --git a/tests/context_test.php b/tests/context_test.php new file mode 100644 index 0000000..b53ea40 --- /dev/null +++ b/tests/context_test.php @@ -0,0 +1,82 @@ + '']); + $ctx = DirectAdminContext::fromServer([ + 'USERNAME' => 'mxtest', + 'HTTP_HOST' => 'https://h4.domena.pl:2222', + 'REMOTE_ADDR' => '198.51.100.10', + 'REQUEST_METHOD' => 'GET', + ], $settings); + + assert_same('mxtest', $ctx->username()); + assert_same('h4.domena.pl', $ctx->sourceHost()); + assert_same('h4', $ctx->serverName()); +}); + +test('directadmin context rejects invalid usernames', function (): void { + try { + DirectAdminContext::fromServer([ + 'USERNAME' => '../../root', + 'HTTP_HOST' => 'h4.domena.pl:2222', + 'REMOTE_ADDR' => '198.51.100.10', + 'REQUEST_METHOD' => 'GET', + ], Settings::fromArray([])); + throw new RuntimeException('Expected invalid username to fail'); + } catch (InvalidArgumentException $e) { + assert_contains('username', strtolower($e->getMessage())); + } +}); + +test('client ip resolves direct remote address by default', function (): void { + $settings = Settings::fromArray(['MAIL_LOGIN_CLIENT_IP_MODE' => 'direct']); + $ip = ClientIp::resolve([ + 'REMOTE_ADDR' => '198.51.100.10', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.20', + ], $settings); + + assert_same('198.51.100.10', $ip); +}); + +test('client ip honors forwarded header only from trusted proxy', function (): void { + $settings = Settings::fromArray([ + 'MAIL_LOGIN_CLIENT_IP_MODE' => 'trusted_proxy', + 'MAIL_LOGIN_TRUSTED_PROXIES' => '10.0.0.1', + ]); + $ip = ClientIp::resolve([ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.20, 10.0.0.1', + ], $settings); + + assert_same('203.0.113.20', $ip); +}); + +test('source authorizer rejects hosts outside allowlist', function (): void { + $settings = Settings::fromArray(['MAIL_LOGIN_ALLOWED_SOURCE_HOSTS' => 'h4.domena.pl']); + + SourceAuthorizer::assertAllowed('h4.domena.pl', $settings); + + try { + SourceAuthorizer::assertAllowed('evil.example.net', $settings); + throw new RuntimeException('Expected disallowed source host to fail'); + } catch (RuntimeException $e) { + assert_contains('source host', strtolower($e->getMessage())); + } +}); + +test('key name uses requested format and truncates long values deterministically', function (): void { + assert_same('autologin-h4-mxtest-1782810000', KeyName::build('h4', 'mxtest', 1782810000)); + + $long = KeyName::build(str_repeat('server-', 15), str_repeat('user-', 15), 1782810000); + assert_true(strlen($long) <= 96, 'Key name must be at most 96 chars'); + assert_true(str_starts_with($long, 'autologin-')); + assert_true((bool)preg_match('/-[a-f0-9]{10}-1782810000$/', $long), 'Long key should contain hash suffix'); +}); + diff --git a/tests/mx_helper_test.php b/tests/mx_helper_test.php new file mode 100644 index 0000000..37aa491 --- /dev/null +++ b/tests/mx_helper_test.php @@ -0,0 +1,65 @@ +> " . 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 %s %s', + escapeshellarg($bin), + escapeshellarg($audit), + escapeshellarg($state), + escapeshellarg($script), + 'mail-login-create-url mxtest h4.domena.pl h4 30 1 198.51.100.10 /' + ); + 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 %s %s', + escapeshellarg($bin), + escapeshellarg($audit), + escapeshellarg($state), + escapeshellarg($script), + 'mail-login-create-url mxtest h4.domena.pl h4 30 0 198.51.100.10 /' + ); + 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 SSH_ORIGINAL_COMMAND=%s %s', + escapeshellarg($bin), + escapeshellarg($audit), + escapeshellarg($state), + escapeshellarg('mail-login-create-url mxtest h4.domena.pl h4 30 1 198.51.100.10 /'), + 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) ?: ''); +}); diff --git a/tests/package_contents_test.php b/tests/package_contents_test.php new file mode 100644 index 0000000..772a2ef --- /dev/null +++ b/tests/package_contents_test.php @@ -0,0 +1,40 @@ +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()) ?: ''; + if (preg_match('/TODO|TBD|FIXME|placeholder|not implemented/i', $content)) { + $bad[] = $relative; + } + } + assert_same([], $bad); +}); + diff --git a/tests/remote_login_url_client_test.php b/tests/remote_login_url_client_test.php new file mode 100644 index 0000000..94dcee0 --- /dev/null +++ b/tests/remote_login_url_client_test.php @@ -0,0 +1,50 @@ + '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', + ]); + $request = new LoginUrlRequest('mxtest', 'h4.domena.pl', 'h4', 30, true, '198.51.100.10', '/'); + $command = RemoteLoginUrlClient::buildCommand($settings, $request); + + assert_same('ssh', $command[0]); + assert_contains('UserKnownHostsFile=/secure/known_hosts', implode(' ', $command)); + assert_contains('root@mx1.domena.pl', implode(' ', $command)); + assert_same('mail-login-create-url', $command[count($command) - 8]); + assert_same('mxtest', $command[count($command) - 7]); + assert_same('1', $command[count($command) - 3]); +}); + +test('remote login url client validates helper output before returning it', function (): void { + $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', '/'); + + $client = new RemoteLoginUrlClient(function (array $command, int $timeout): ProcessResult { + return new ProcessResult(0, "URL: https://mx1.domena.pl:2222/api/login/url?key=SECRET\n", ''); + }); + + assert_same('https://mx1.domena.pl:2222/api/login/url?key=SECRET', $client->create($settings, $request)); + + $badClient = new RemoteLoginUrlClient(function (array $command, int $timeout): ProcessResult { + return new ProcessResult(0, "https://evil.example/api/login/url?key=SECRET\n", ''); + }); + + try { + $badClient->create($settings, $request); + throw new RuntimeException('Expected invalid helper URL to fail'); + } catch (RuntimeException $e) { + assert_contains('login url', strtolower($e->getMessage())); + } +}); + diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..4ed84ef --- /dev/null +++ b/tests/run.php @@ -0,0 +1,101 @@ +isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($path); +} + +foreach (glob(__DIR__ . '/*_test.php') ?: [] as $file) { + require $file; +} + +$failures = 0; +foreach ($tests as [$name, $fn]) { + try { + $fn(); + echo "PASS {$name}\n"; + } catch (Throwable $e) { + $failures++; + echo "FAIL {$name}: " . $e->getMessage() . "\n"; + } +} + +test_rm_rf(TEST_TMP); + +if ($failures > 0) { + exit(1); +} + diff --git a/tests/settings_test.php b/tests/settings_test.php new file mode 100644 index 0000000..aa6b61f --- /dev/null +++ b/tests/settings_test.php @@ -0,0 +1,43 @@ +loginKeyTtl()); + assert_true($settings->userIpBound()); + assert_same('root', $settings->sshUser()); + assert_same('https://mx1.mojserwer.pl:2222', $settings->targetBaseUrl()); +}); + +test('settings reject login key ttl outside production range', function (): void { + foreach (['4', '301', 'abc'] as $ttl) { + try { + Settings::fromArray(['LOGIN_KEY_TTL' => $ttl]); + throw new RuntimeException('Expected invalid TTL to fail: ' . $ttl); + } catch (InvalidArgumentException $e) { + assert_contains('LOGIN_KEY_TTL', $e->getMessage()); + } + } +}); + +test('settings accept explicit ttl and user ip binding toggle', function (): void { + $settings = Settings::fromArray([ + 'LOGIN_KEY_TTL' => '5', + 'USER_IP_BOUND' => '0', + ]); + + assert_same(5, $settings->loginKeyTtl()); + assert_false($settings->userIpBound()); +}); + +test('settings parse allowed source hosts as normalized hostnames', function (): void { + $settings = Settings::fromArray([ + 'MAIL_LOGIN_ALLOWED_SOURCE_HOSTS' => 'https://h4.domena.pl:2222, serwer2.example.net:2222', + ]); + + assert_same(['h4.domena.pl', 'serwer2.example.net'], $settings->allowedSourceHosts()); +}); + diff --git a/tests/url_validator_test.php b/tests/url_validator_test.php new file mode 100644 index 0000000..c2cf50a --- /dev/null +++ b/tests/url_validator_test.php @@ -0,0 +1,40 @@ +getMessage())); + } + } +}); + +test('url validator accepts only configured mx login url', function (): void { + $settings = Settings::fromArray(['MAIL_LOGIN_TARGET_BASE_URL' => 'https://mx1.domena.pl:2222']); + $url = 'https://mx1.domena.pl:2222/api/login/url?key=SECRET'; + + assert_same($url, UrlValidator::assertGeneratedLoginUrl($url, $settings)); + + foreach ([ + 'https://mx1.domena.pl:2222/CMD_LOGIN?key=SECRET', + 'https://evil.domena.pl:2222/api/login/url?key=SECRET', + 'http://mx1.domena.pl:2222/api/login/url?key=SECRET', + 'https://mx1.domena.pl:2222/api/login/url', + ] as $bad) { + try { + UrlValidator::assertGeneratedLoginUrl($bad, $settings); + throw new RuntimeException('Expected bad generated URL to fail: ' . $bad); + } catch (InvalidArgumentException $e) { + assert_contains('login url', strtolower($e->getMessage())); + } + } +}); diff --git a/user/index.html b/user/index.html new file mode 100755 index 0000000..db0d1d8 --- /dev/null +++ b/user/index.html @@ -0,0 +1,12 @@ +#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/mail-login/php.ini +'; +echo ''; +echo 'Serwer poczty'; +echo '

Zaloguj do serwera poczty

'; +echo ''; diff --git a/user/login.raw b/user/login.raw new file mode 100755 index 0000000..3bae715 --- /dev/null +++ b/user/login.raw @@ -0,0 +1,7 @@ +#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/mail-login/php.ini +