feat: implement mail-login directadmin plugin

This commit is contained in:
Marek Miklewicz
2026-06-30 11:31:19 +02:00
parent f6506e3293
commit 77c5431db0
34 changed files with 1620 additions and 0 deletions
+82
View File
@@ -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');
});
+65
View File
@@ -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) ?: '');
});
+40
View File
@@ -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);
});
+50
View File
@@ -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
View File
@@ -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);
}
+43
View File
@@ -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());
});
+40
View File
@@ -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()));
}
}
});