feat: implement mail-login directadmin plugin
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user