feat: implement mail-login directadmin plugin
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (!defined('IN_DA_PLUGIN') || IN_DA_PLUGIN !== true) {
|
||||
http_response_code(403);
|
||||
echo 'Forbidden';
|
||||
exit;
|
||||
}
|
||||
|
||||
define('PLUGIN_ROOT', dirname(__DIR__));
|
||||
define('PLUGIN_EXEC_DIR', __DIR__);
|
||||
|
||||
foreach ([
|
||||
'Settings.php',
|
||||
'Http.php',
|
||||
'AuditLog.php',
|
||||
'ClientIp.php',
|
||||
'DirectAdminContext.php',
|
||||
'KeyName.php',
|
||||
'RateLimiter.php',
|
||||
'RemoteLoginUrlClient.php',
|
||||
'SourceAuthorizer.php',
|
||||
'UrlValidator.php',
|
||||
] as $file) {
|
||||
require_once PLUGIN_EXEC_DIR . '/lib/' . $file;
|
||||
}
|
||||
|
||||
try {
|
||||
Http::bootstrapGlobals();
|
||||
$action = basename((string)PLUGIN_ACTION);
|
||||
$handlerPath = PLUGIN_EXEC_DIR . '/handlers/' . $action . '.php';
|
||||
if (!is_file($handlerPath)) {
|
||||
throw new RuntimeException('Missing handler');
|
||||
}
|
||||
$handler = require $handlerPath;
|
||||
if (!is_callable($handler)) {
|
||||
throw new RuntimeException('Invalid handler');
|
||||
}
|
||||
$handler();
|
||||
} catch (Throwable $e) {
|
||||
AuditLog::safeAppend(Settings::AUDIT_LOG, [
|
||||
'event' => 'plugin_error',
|
||||
'result' => 'failure',
|
||||
'category' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
Http::errorPage('Nie można teraz zalogować do serwera poczty.', 500);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return static function (): void {
|
||||
if (Http::method() !== 'GET') {
|
||||
Http::errorPage('Niedozwolona metoda żądania.', 405);
|
||||
}
|
||||
|
||||
$settings = Settings::load(Settings::EXTERNAL_SETTINGS);
|
||||
$ctx = DirectAdminContext::fromServer($_SERVER, $settings);
|
||||
SourceAuthorizer::assertAllowed($ctx->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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class AuditLog
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $fields
|
||||
*/
|
||||
public static function append(string $path, array $fields): void
|
||||
{
|
||||
self::safeAppend($path, $fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $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<string,mixed> $fields
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ClientIp
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $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<string> $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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class DirectAdminContext
|
||||
{
|
||||
private function __construct(
|
||||
private string $username,
|
||||
private string $sourceHost,
|
||||
private string $serverName,
|
||||
private string $method
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Http
|
||||
{
|
||||
public static function bootstrapGlobals(): void
|
||||
{
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
return;
|
||||
}
|
||||
$_SERVER['REQUEST_METHOD'] = strtoupper((string)(getenv('REQUEST_METHOD') ?: 'GET'));
|
||||
foreach ([
|
||||
'USER',
|
||||
'USERNAME',
|
||||
'SESSION_ID',
|
||||
'REQUEST_URI',
|
||||
'HTTP_HOST',
|
||||
'SERVER_NAME',
|
||||
'SERVER_PORT',
|
||||
'REMOTE_ADDR',
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_FORWARDED_PROTO',
|
||||
] as $key) {
|
||||
if (!isset($_SERVER[$key])) {
|
||||
$value = getenv($key);
|
||||
if ($value !== false) {
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function method(): string
|
||||
{
|
||||
return strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
}
|
||||
|
||||
public static function redirectNoStore(string $url): void
|
||||
{
|
||||
header('Cache-Control: no-store, private');
|
||||
header('Pragma: no-cache');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Location: ' . $url, true, 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function errorPage(string $message, int $status): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
header('Cache-Control: no-store, private');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
$safe = htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
echo '<!doctype html><html lang="pl"><head><meta charset="utf-8">';
|
||||
echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
|
||||
echo '<title>Serwer poczty</title></head><body>';
|
||||
echo '<h1>Serwer poczty</h1><p>' . $safe . '</p>';
|
||||
echo '</body></html>';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class KeyName
|
||||
{
|
||||
public const MAX_LENGTH = 96;
|
||||
|
||||
public static function build(string $serverName, string $username, int $timestamp): string
|
||||
{
|
||||
$server = self::clean($serverName);
|
||||
$user = self::clean($username);
|
||||
$raw = 'autologin-' . $server . '-' . $user . '-' . $timestamp;
|
||||
if (strlen($raw) <= self::MAX_LENGTH) {
|
||||
return $raw;
|
||||
}
|
||||
$hash = substr(hash('sha256', $raw), 0, 10);
|
||||
$suffix = '-' . $hash . '-' . $timestamp;
|
||||
$bodyMax = self::MAX_LENGTH - strlen($suffix);
|
||||
$body = rtrim(substr('autologin-' . $server . '-' . $user, 0, $bodyMax), '-');
|
||||
return $body . $suffix;
|
||||
}
|
||||
|
||||
private static function clean(string $value): string
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
$value = preg_replace('/[^a-z0-9-]+/', '-', $value) ?? '';
|
||||
$value = trim($value, '-');
|
||||
return $value !== '' ? $value : 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class RateLimiter
|
||||
{
|
||||
public function __construct(private string $dir)
|
||||
{
|
||||
}
|
||||
|
||||
public function assertAllowed(string $bucket, int $limit, int $windowSeconds): void
|
||||
{
|
||||
if ($limit < 1) {
|
||||
throw new RuntimeException('Rate limit is invalid');
|
||||
}
|
||||
if (!is_dir($this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class LoginUrlRequest
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $username,
|
||||
public readonly string $sourceHost,
|
||||
public readonly string $serverName,
|
||||
public readonly int $ttl,
|
||||
public readonly bool $userIpBound,
|
||||
public readonly string $clientIp,
|
||||
public readonly string $redirectPath
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
final class ProcessResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $exitCode,
|
||||
public readonly string $stdout,
|
||||
public readonly string $stderr
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
final class RemoteLoginUrlClient
|
||||
{
|
||||
/** @var callable(array<int,string>,int):ProcessResult */
|
||||
private $runner;
|
||||
|
||||
/**
|
||||
* @param null|callable(array<int,string>,int):ProcessResult $runner
|
||||
*/
|
||||
public function __construct(?callable $runner = null)
|
||||
{
|
||||
$this->runner = $runner ?? [self::class, 'runProcess'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
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<string> $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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Settings
|
||||
{
|
||||
public const BASE_DIR = '/usr/local/hitme_plugins/mail-login';
|
||||
public const CONFIG_DIR = self::BASE_DIR . '/config';
|
||||
public const SECRET_DIR = self::BASE_DIR . '/secrets';
|
||||
public const LOG_DIR = self::BASE_DIR . '/logs';
|
||||
public const STATE_DIR = self::BASE_DIR . '/state';
|
||||
public const AUDIT_LOG = self::LOG_DIR . '/audit.log';
|
||||
public const EXTERNAL_SETTINGS = self::CONFIG_DIR . '/plugin-settings.conf';
|
||||
|
||||
/** @var array<string,string> */
|
||||
private array $values;
|
||||
|
||||
/**
|
||||
* @param array<string,string> $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<string,string> $values
|
||||
*/
|
||||
public static function fromArray(array $values): self
|
||||
{
|
||||
return new self(array_merge(self::defaults(), $values));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class SourceAuthorizer
|
||||
{
|
||||
public static function assertAllowed(string $sourceHost, Settings $settings): void
|
||||
{
|
||||
$allowed = $settings->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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class UrlValidator
|
||||
{
|
||||
public static function assertSafeRedirectPath(string $path): string
|
||||
{
|
||||
$path = trim($path);
|
||||
if ($path === '' || !str_starts_with($path, '/') || str_starts_with($path, '//') || str_contains($path, '\\')) {
|
||||
throw new InvalidArgumentException('Invalid redirect path');
|
||||
}
|
||||
if (!preg_match('/^\/[A-Za-z0-9._~%\/?=+-]*$/', $path)) {
|
||||
throw new InvalidArgumentException('Invalid redirect path');
|
||||
}
|
||||
if (preg_match('/^[a-z][a-z0-9+.-]*:\/\//i', $path)) {
|
||||
throw new InvalidArgumentException('Invalid redirect path');
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
public static function assertGeneratedLoginUrl(string $url, Settings $settings): string
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
$base = parse_url($settings->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<a href="/CMD_PLUGINS/mail-login/login.raw" target="_top"><img src="/CMD_PLUGINS/mail-login/images/user_icon.svg" width="16" height="16" alt="Serwer poczty"/></a>
|
||||
@@ -0,0 +1 @@
|
||||
<a href="/CMD_PLUGINS/mail-login/login.raw" target="_top">Serwer poczty</a>
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Serwer poczty">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" fill="#14532d"/>
|
||||
<path d="M4 7l8 6 8-6" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7 16h10" fill="none" stroke="#bbf7d0" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/local/bin/php
|
||||
<?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);
|
||||
}
|
||||
|
||||
$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<string,string> $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<string,mixed> $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);
|
||||
}
|
||||
Executable
+16
@@ -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"
|
||||
Executable
+93
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+18
@@ -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"
|
||||
Executable
+25
@@ -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"
|
||||
Executable
+11
@@ -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
|
||||
Executable
+10
@@ -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"
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/Settings.php';
|
||||
require_once PLUGIN_ROOT . '/exec/lib/DirectAdminContext.php';
|
||||
require_once PLUGIN_ROOT . '/exec/lib/ClientIp.php';
|
||||
require_once PLUGIN_ROOT . '/exec/lib/KeyName.php';
|
||||
require_once PLUGIN_ROOT . '/exec/lib/SourceAuthorizer.php';
|
||||
|
||||
test('directadmin context derives server name from first host label', function (): void {
|
||||
$settings = Settings::fromArray(['MAIL_LOGIN_SOURCE_SERVER_NAME' => '']);
|
||||
$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');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?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 %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) ?: '');
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
test('plugin metadata and packaging docs match project rules', function (): void {
|
||||
$conf = file_get_contents(PLUGIN_ROOT . '/plugin.conf') ?: '';
|
||||
$packing = file_get_contents(PROJECT_ROOT . '/PACKING.md') ?: '';
|
||||
|
||||
assert_contains('name=mail-login', $conf);
|
||||
assert_contains('id=mail-login', $conf);
|
||||
assert_contains('type=user', $conf);
|
||||
assert_contains('version=1.0.1', $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);
|
||||
});
|
||||
|
||||
test('package source excludes local-only files and dangerous placeholders', function (): void {
|
||||
$package = file_get_contents(PLUGIN_ROOT . '/scripts/package.sh') ?: '';
|
||||
assert_contains("--exclude='./.git'", $package);
|
||||
assert_contains("--exclude='./tests'", $package);
|
||||
assert_contains('mail-login.tar.gz', $package);
|
||||
|
||||
$bad = [];
|
||||
$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()) ?: '';
|
||||
if (preg_match('/TODO|TBD|FIXME|placeholder|not implemented/i', $content)) {
|
||||
$bad[] = $relative;
|
||||
}
|
||||
}
|
||||
assert_same([], $bad);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/Settings.php';
|
||||
require_once PLUGIN_ROOT . '/exec/lib/RemoteLoginUrlClient.php';
|
||||
|
||||
test('remote login url client builds fixed ssh command with positional arguments', 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',
|
||||
]);
|
||||
$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()));
|
||||
}
|
||||
});
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
define('PLUGIN_ROOT', dirname(__DIR__));
|
||||
define('PROJECT_ROOT', dirname(PLUGIN_ROOT));
|
||||
define('TEST_TMP', sys_get_temp_dir() . '/mail-login-tests-' . getmypid());
|
||||
|
||||
@mkdir(TEST_TMP, 0700, true);
|
||||
|
||||
$tests = [];
|
||||
|
||||
function test(string $name, callable $fn): void
|
||||
{
|
||||
global $tests;
|
||||
$tests[] = [$name, $fn];
|
||||
}
|
||||
|
||||
function assert_true(bool $condition, string $message = 'Expected true'): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
function assert_false(bool $condition, string $message = 'Expected false'): void
|
||||
{
|
||||
if ($condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
function assert_same(mixed $expected, mixed $actual, string $message = ''): void
|
||||
{
|
||||
if ($expected !== $actual) {
|
||||
throw new RuntimeException($message !== '' ? $message : 'Expected ' . var_export($expected, true) . ', got ' . var_export($actual, true));
|
||||
}
|
||||
}
|
||||
|
||||
function assert_contains(string $needle, string $haystack, string $message = ''): void
|
||||
{
|
||||
if (!str_contains($haystack, $needle)) {
|
||||
throw new RuntimeException($message !== '' ? $message : 'Missing text: ' . $needle);
|
||||
}
|
||||
}
|
||||
|
||||
function assert_not_contains(string $needle, string $haystack, string $message = ''): void
|
||||
{
|
||||
if (str_contains($haystack, $needle)) {
|
||||
throw new RuntimeException($message !== '' ? $message : 'Unexpected text: ' . $needle);
|
||||
}
|
||||
}
|
||||
|
||||
function test_write(string $path, string $content): void
|
||||
{
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0700, true);
|
||||
}
|
||||
file_put_contents($path, $content);
|
||||
}
|
||||
|
||||
function test_rm_rf(string $path): void
|
||||
{
|
||||
if (!is_dir($path) && !is_file($path)) {
|
||||
return;
|
||||
}
|
||||
if (is_file($path) || is_link($path)) {
|
||||
unlink($path);
|
||||
return;
|
||||
}
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($it as $item) {
|
||||
$item->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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/Settings.php';
|
||||
|
||||
test('settings expose secure defaults from plugin-settings.conf', function (): void {
|
||||
$settings = Settings::fromArray([]);
|
||||
|
||||
assert_same(30, $settings->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());
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/Settings.php';
|
||||
require_once PLUGIN_ROOT . '/exec/lib/UrlValidator.php';
|
||||
|
||||
test('url validator accepts local redirect paths only', function (): void {
|
||||
assert_same('/', UrlValidator::assertSafeRedirectPath('/'));
|
||||
assert_same('/user/email/accounts', UrlValidator::assertSafeRedirectPath('/user/email/accounts'));
|
||||
|
||||
foreach (['', 'https://evil.example/', '//evil.example/', '/\\evil', '/mail;id', '/mail&x=1', '/mail path'] as $bad) {
|
||||
try {
|
||||
UrlValidator::assertSafeRedirectPath($bad);
|
||||
throw new RuntimeException('Expected bad redirect to fail: ' . $bad);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
assert_contains('redirect', strtolower($e->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()));
|
||||
}
|
||||
}
|
||||
});
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/mail-login/php.ini
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
header('Cache-Control: no-store, private');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
echo '<!doctype html><html lang="pl"><head><meta charset="utf-8">';
|
||||
echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
|
||||
echo '<title>Serwer poczty</title></head><body>';
|
||||
echo '<p><a href="/CMD_PLUGINS/mail-login/login.raw">Zaloguj do serwera poczty</a></p>';
|
||||
echo '</body></html>';
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/mail-login/php.ini
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
define('IN_DA_PLUGIN', true);
|
||||
define('PLUGIN_ACTION', 'login');
|
||||
require dirname(__DIR__) . '/exec/bootstrap.php';
|
||||
Reference in New Issue
Block a user