Files
alt-mysql/exec/lib/MySQLService.php
T
Marek Miklewicz ba64342702 Fix phpMyAdmin SSO login and harden SSO/security posture
phpMyAdmin login was broken because phpmyadmin_install.sh never wired
the private phpMyAdmin copy into the web server: no Apache Alias was
registered via DirectAdmin CustomBuild, so the SSO redirect target was
unreachable. Add configure_apache_alias()/apply_apache_alias() (Alias +
Directory blocks, ./build rewrite_confs, httpd reload), detect the real
Apache group instead of hardcoding diradmin:diradmin, stop silently
swallowing chown/chmod failures, and verify an optional SHA256 for the
downloaded phpMyAdmin tarball. Extend phpmyadmin_health_check.sh to
detect a missing/incorrect Apache alias and to probe HTTP reachability.

Security hardening: scope temporary phpMyAdmin SSO MySQL roles to
127.0.0.1 instead of '%', tighten SSO ticket file permissions to 0640
(they contain a plaintext MySQL password), and log MySQL connection
failures for diagnosis instead of swallowing them silently.

Bump version to 1.2.12 and rebuild the release archive.
2026-07-04 19:16:09 +02:00

1176 lines
38 KiB
PHP

<?php
declare(strict_types=1);
final class MySQLService
{
private const TMP_ROLE_PREFIX = 'da_tmp_phpmyadmin_';
public const TMP_ROLE_HOST = '127.0.0.1';
/** @var array{host:string,port:int,database:string,user:string,password:string,socket:string} */
private array $credentials;
private Settings $settings;
/** @var array<string,mysqli> */
private array $connections = [];
public function __construct(array $credentials, Settings $settings)
{
$database = $credentials['database'] ?? 'mysql';
if ($database === '*' || trim((string)$database) === '') {
$database = 'mysql';
}
$host = trim((string)($credentials['host'] ?? 'localhost'));
if ($host === '') {
$host = 'localhost';
}
$this->credentials = [
'host' => $host,
'port' => (int)($credentials['port'] ?? 3306),
'database' => (string)$database,
'user' => (string)($credentials['user'] ?? 'root'),
'password' => (string)($credentials['password'] ?? ''),
'socket' => (string)($credentials['socket'] ?? ''),
];
$this->settings = $settings;
}
public function ensureMetadataSchema(): void
{
$this->query(
'CREATE TABLE IF NOT EXISTS da_plugin_access_hosts (
da_user VARCHAR(255) NOT NULL,
role_name VARCHAR(255) NOT NULL,
host_pattern VARCHAR(255) NOT NULL,
note VARCHAR(180) NOT NULL DEFAULT \'\',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (role_name, host_pattern),
KEY access_hosts_da_user_idx (da_user)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
$this->query(
'CREATE TABLE IF NOT EXISTS da_plugin_grant_profiles (
db_name VARCHAR(255) NOT NULL,
role_name VARCHAR(255) NOT NULL,
privileges TEXT NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (db_name, role_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
$this->query(
'CREATE TABLE IF NOT EXISTS da_plugin_database_owners (
db_name VARCHAR(255) NOT NULL,
da_user VARCHAR(255) NOT NULL,
role_name VARCHAR(255) NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (db_name),
KEY database_owners_da_user_idx (da_user),
KEY database_owners_role_idx (role_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
$this->query(
'CREATE TABLE IF NOT EXISTS da_plugin_sso_accounts (
role_name VARCHAR(255) NOT NULL,
host_pattern VARCHAR(255) NOT NULL,
expires_at INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (role_name, host_pattern),
KEY sso_accounts_expires_idx (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
$this->query(
'CREATE TABLE IF NOT EXISTS da_plugin_system_databases (
service_name VARCHAR(64) NOT NULL,
db_name VARCHAR(255) NOT NULL,
db_user VARCHAR(255) NOT NULL,
endpoint_mode VARCHAR(32) NOT NULL DEFAULT \'tcp\',
config_path VARCHAR(512) NOT NULL DEFAULT \'\',
enabled TINYINT(1) NOT NULL DEFAULT 1,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (service_name),
KEY system_databases_db_idx (db_name),
KEY system_databases_user_idx (db_user)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
}
/**
* @return array{host:string,port:int}
*/
public function connectionEndpoint(): array
{
return [
'host' => $this->credentials['host'],
'port' => (int)$this->credentials['port'],
];
}
public function serverVersion(): string
{
$row = $this->fetchOne('SELECT VERSION() AS version');
return trim((string)($row['version'] ?? ''));
}
public function countDatabasesForUser(string $daUser): int
{
$row = $this->fetchOne(
'SELECT COUNT(*) AS cnt
FROM information_schema.SCHEMATA
WHERE SCHEMA_NAME LIKE ? ESCAPE \'\\\\\'',
[$this->likePattern($daUser . '_')]
);
return isset($row['cnt']) ? (int)$row['cnt'] : 0;
}
/**
* @return array<int,array{name:string,owner:string,size:string}>
*/
public function listDatabasesForUser(string $daUser, bool $includeSize = true): array
{
$sizeSql = $includeSize
? 'COALESCE((
SELECT SUM(t.DATA_LENGTH + t.INDEX_LENGTH)
FROM information_schema.TABLES t
WHERE t.TABLE_SCHEMA = s.SCHEMA_NAME
), 0) AS size_bytes'
: '0 AS size_bytes';
$rows = $this->fetchAll(
'SELECT s.SCHEMA_NAME AS name,
COALESCE(o.role_name, \'\') AS owner,
' . $sizeSql . '
FROM information_schema.SCHEMATA s
LEFT JOIN da_plugin_database_owners o ON o.db_name = s.SCHEMA_NAME
WHERE s.SCHEMA_NAME LIKE ? ESCAPE \'\\\\\'
ORDER BY s.SCHEMA_NAME',
[$this->likePattern($daUser . '_')]
);
$out = [];
foreach ($rows as $row) {
$out[] = [
'name' => (string)$row['name'],
'owner' => (string)($row['owner'] ?? ''),
'size' => $this->formatBytes((int)($row['size_bytes'] ?? 0)),
];
}
return $out;
}
/**
* @return array<int,array{name:string,can_login:bool}>
*/
public function listRolesForUser(string $daUser): array
{
$rows = $this->fetchAll(
'SELECT DISTINCT User AS name
FROM mysql.user
WHERE User LIKE ? ESCAPE \'\\\\\'
ORDER BY User',
[$this->likePattern($daUser . '_')]
);
$out = [];
foreach ($rows as $row) {
$name = (string)($row['name'] ?? '');
if ($name === '' || strpos($name, self::TMP_ROLE_PREFIX) === 0) {
continue;
}
$out[] = [
'name' => $name,
'can_login' => true,
];
}
return $out;
}
/**
* @return array<string,array<int,string>>
*/
public function listDatabaseUsersMap(string $daUser): array
{
$rows = $this->fetchAll(
'SELECT db_name, role_name
FROM da_plugin_grant_profiles
WHERE db_name LIKE ? ESCAPE \'\\\\\'
AND role_name LIKE ? ESCAPE \'\\\\\'
ORDER BY db_name, role_name',
[$this->likePattern($daUser . '_'), $this->likePattern($daUser . '_')]
);
$map = [];
foreach ($rows as $row) {
$db = (string)($row['db_name'] ?? '');
$role = (string)($row['role_name'] ?? '');
if ($db === '' || $role === '') {
continue;
}
$map[$db] ??= [];
$map[$db][] = $role;
}
return $map;
}
/**
* @return array<string,array<int,string>>
*/
public function listRoleDatabasesMap(string $daUser): array
{
$dbMap = $this->listDatabaseUsersMap($daUser);
$out = [];
foreach ($dbMap as $db => $roles) {
foreach ($roles as $role) {
$out[$role] ??= [];
$out[$role][] = $db;
}
}
ksort($out);
return $out;
}
/**
* @return string[]
*/
public function listDatabaseUsers(string $daUser, string $database): array
{
$rows = $this->fetchAll(
'SELECT role_name
FROM da_plugin_grant_profiles
WHERE db_name = ?
AND role_name LIKE ? ESCAPE \'\\\\\'
ORDER BY role_name',
[$database, $this->likePattern($daUser . '_')]
);
$out = [];
foreach ($rows as $row) {
$role = (string)($row['role_name'] ?? '');
if ($role !== '') {
$out[] = $role;
}
}
return $out;
}
public function roleExists(string $role): bool
{
$row = $this->fetchOne('SELECT 1 AS ok FROM mysql.user WHERE User = ? LIMIT 1', [$role]);
return !empty($row);
}
public function databaseExists(string $dbName): bool
{
$row = $this->fetchOne('SELECT 1 AS ok FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ? LIMIT 1', [$dbName]);
return !empty($row);
}
public function getDatabaseOwner(string $dbName): ?string
{
$row = $this->fetchOne('SELECT role_name FROM da_plugin_database_owners WHERE db_name = ?', [$dbName]);
if (empty($row['role_name'])) {
return null;
}
return (string)$row['role_name'];
}
/**
* @return array{name:string,owner:string,size:string,encoding:string,collation:string}
*/
public function getDatabaseInfo(string $dbName): array
{
$row = $this->fetchOne(
'SELECT s.SCHEMA_NAME AS name,
COALESCE(o.role_name, \'\') AS owner,
s.DEFAULT_CHARACTER_SET_NAME AS encoding,
s.DEFAULT_COLLATION_NAME AS collation,
COALESCE((
SELECT SUM(t.DATA_LENGTH + t.INDEX_LENGTH)
FROM information_schema.TABLES t
WHERE t.TABLE_SCHEMA = s.SCHEMA_NAME
), 0) AS size_bytes
FROM information_schema.SCHEMATA s
LEFT JOIN da_plugin_database_owners o ON o.db_name = s.SCHEMA_NAME
WHERE s.SCHEMA_NAME = ?',
[$dbName]
);
if (empty($row)) {
throw new RuntimeException('Nie znaleziono bazy danych: ' . $dbName);
}
return [
'name' => (string)($row['name'] ?? $dbName),
'owner' => (string)($row['owner'] ?? ''),
'size' => $this->formatBytes((int)($row['size_bytes'] ?? 0)),
'encoding' => (string)($row['encoding'] ?? 'utf8mb4'),
'collation' => (string)($row['collation'] ?? ''),
];
}
/**
* @return array{tables:int,views:int,triggers:int,routines:int}
*/
public function getDatabaseObjectStats(string $dbName): array
{
$row = $this->fetchOne(
'SELECT
COALESCE(SUM(CASE WHEN TABLE_TYPE = \'BASE TABLE\' THEN 1 ELSE 0 END), 0) AS tables_cnt,
COALESCE(SUM(CASE WHEN TABLE_TYPE = \'VIEW\' THEN 1 ELSE 0 END), 0) AS views_cnt
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ?',
[$dbName]
);
$triggerRow = $this->fetchOne(
'SELECT COUNT(*) AS cnt
FROM information_schema.TRIGGERS
WHERE TRIGGER_SCHEMA = ?',
[$dbName]
);
$routineRow = $this->fetchOne(
'SELECT COUNT(*) AS cnt
FROM information_schema.ROUTINES
WHERE ROUTINE_SCHEMA = ?',
[$dbName]
);
return [
'tables' => (int)($row['tables_cnt'] ?? 0),
'views' => (int)($row['views_cnt'] ?? 0),
'triggers' => (int)($triggerRow['cnt'] ?? 0),
'routines' => (int)($routineRow['cnt'] ?? 0),
];
}
/**
* @return string[]
*/
public function roleOwnedDatabases(string $role, string $daUser): array
{
$rows = $this->fetchAll(
'SELECT db_name
FROM da_plugin_database_owners
WHERE role_name = ?
AND db_name LIKE ? ESCAPE \'\\\\\'
ORDER BY db_name',
[$role, $this->likePattern($daUser . '_')]
);
$out = [];
foreach ($rows as $row) {
$name = (string)($row['db_name'] ?? '');
if ($name !== '') {
$out[] = $name;
}
}
return $out;
}
/**
* @return string[]
*/
public function roleAssignedDatabases(string $role, string $daUser): array
{
$rows = $this->fetchAll(
'SELECT db_name
FROM da_plugin_grant_profiles
WHERE role_name = ?
AND db_name LIKE ? ESCAPE \'\\\\\'
ORDER BY db_name',
[$role, $this->likePattern($daUser . '_')]
);
$out = [];
foreach ($rows as $row) {
$name = (string)($row['db_name'] ?? '');
if ($name !== '') {
$out[] = $name;
}
}
return $out;
}
/**
* @return array<int,array{host:string,note:string}>
*/
public function getRoleHostEntries(string $daUser, string $role): array
{
$rows = $this->fetchAll(
'SELECT host_pattern, note
FROM da_plugin_access_hosts
WHERE da_user = ? AND role_name = ?
ORDER BY host_pattern',
[$daUser, $role]
);
$map = [];
foreach ($rows as $row) {
$host = (string)$row['host_pattern'];
if ($host === '') {
continue;
}
$map[$host] = $this->sanitizeHostNote((string)($row['note'] ?? ''));
}
if ($this->roleExists($role)) {
$this->ensureDefaultRoleHostsProvisioned($role);
foreach ($this->listActualRoleHosts($role) as $host) {
if (!isset($map[$host])) {
$map[$host] = $this->requiredAccessHostNote($host);
}
}
foreach ($this->requiredAccessHosts() as $host) {
if (!isset($map[$host])) {
$map[$host] = $this->requiredAccessHostNote($host);
} elseif ($map[$host] === '') {
$map[$host] = $this->requiredAccessHostNote($host);
}
}
}
if (empty($map) && $this->roleExists($role)) {
$map['localhost'] = $this->requiredAccessHostNote('localhost');
}
ksort($map);
$out = [];
foreach ($map as $host => $note) {
$out[] = ['host' => $host, 'note' => $note];
}
return $out;
}
/**
* @return string[]
*/
public function getRoleHosts(string $daUser, string $role): array
{
$entries = $this->getRoleHostEntries($daUser, $role);
$out = [];
foreach ($entries as $entry) {
$out[] = $entry['host'];
}
return $out;
}
/**
* @param string[] $hosts
*/
public function replaceRoleHosts(string $daUser, string $role, array $hosts): void
{
$entries = [];
foreach ($hosts as $host) {
$entries[] = ['host' => (string)$host, 'note' => ''];
}
$this->replaceRoleHostsWithNotes($daUser, $role, $entries);
}
/**
* @param array<int,array{host:string,note:string}> $hostEntries
*/
public function replaceRoleHostsWithNotes(string $daUser, string $role, array $hostEntries): void
{
$normalized = [];
foreach ($hostEntries as $entry) {
$host = trim((string)($entry['host'] ?? ''));
if ($host === '') {
continue;
}
$normalized[$host] = $this->sanitizeHostNote((string)($entry['note'] ?? ''));
}
foreach ($this->requiredAccessHosts() as $requiredHost) {
if (!isset($normalized[$requiredHost])) {
$normalized[$requiredHost] = $this->requiredAccessHostNote($requiredHost);
} elseif ($normalized[$requiredHost] === '') {
$normalized[$requiredHost] = $this->requiredAccessHostNote($requiredHost);
}
}
$currentHosts = $this->listActualRoleHosts($role);
if (empty($currentHosts) && !$this->roleExists($role)) {
throw new RuntimeException('Użytkownik MySQL nie istnieje: ' . $role);
}
foreach (array_keys($normalized) as $host) {
if (!in_array($host, $currentHosts, true)) {
$this->cloneRoleToHost($role, $host);
}
}
foreach ($currentHosts as $host) {
if ($host === 'localhost' || isset($normalized[$host])) {
continue;
}
$this->dropRoleHost($role, $host);
}
$this->query('DELETE FROM da_plugin_access_hosts WHERE da_user = ? AND role_name = ?', [$daUser, $role]);
foreach ($normalized as $host => $note) {
$this->query(
'INSERT INTO da_plugin_access_hosts (da_user, role_name, host_pattern, note)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE da_user = VALUES(da_user), note = VALUES(note), updated_at = CURRENT_TIMESTAMP',
[$daUser, $role, $host, $note]
);
}
foreach ($this->roleAssignedDatabases($role, $daUser) as $dbName) {
$profile = $this->getGrantProfile($dbName, $role);
if (!empty($profile)) {
$this->grantPrivileges($dbName, $role, $profile);
}
}
}
public function deleteRoleHosts(string $daUser, string $role): void
{
foreach ($this->listActualRoleHosts($role) as $host) {
if ($host === 'localhost') {
continue;
}
$this->dropRoleHost($role, $host);
}
$this->query('DELETE FROM da_plugin_access_hosts WHERE da_user = ? AND role_name = ?', [$daUser, $role]);
}
public function createRole(string $role, string $password): void
{
$qRole = $this->quoteLiteral($role);
$qPassword = $this->quoteLiteral($password);
$this->query('CREATE USER ' . $qRole . '@\'localhost\' IDENTIFIED BY ' . $qPassword);
}
public function changeRolePassword(string $role, string $password): void
{
$qPassword = $this->quoteLiteral($password);
$this->ensureDefaultRoleHostsProvisioned($role);
$hosts = $this->listActualRoleHosts($role);
if (empty($hosts)) {
throw new RuntimeException('Użytkownik MySQL nie istnieje: ' . $role);
}
foreach ($hosts as $host) {
$this->query(
'ALTER USER ' . $this->quoteLiteral($role) . '@' . $this->quoteLiteral($host) . ' IDENTIFIED BY ' . $qPassword
);
}
}
public function createDatabase(string $dbName, string $owner): void
{
$qDb = $this->quoteIdentifier($dbName);
$this->query('CREATE DATABASE ' . $qDb . ' CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
$this->query(
'INSERT INTO da_plugin_database_owners (db_name, da_user, role_name)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE da_user = VALUES(da_user), role_name = VALUES(role_name), updated_at = CURRENT_TIMESTAMP',
[$dbName, '', $owner]
);
$this->grantAllPrivileges($dbName, $owner);
}
public function dropDatabase(string $dbName): void
{
$this->query('DROP DATABASE IF EXISTS ' . $this->quoteIdentifier($dbName));
$this->query('DELETE FROM da_plugin_database_owners WHERE db_name = ?', [$dbName]);
$this->query('DELETE FROM da_plugin_grant_profiles WHERE db_name = ?', [$dbName]);
}
public function dropRole(string $role): void
{
foreach ($this->listActualRoleHosts($role) as $host) {
$this->dropRoleHost($role, $host);
}
$this->query('DELETE FROM da_plugin_access_hosts WHERE role_name = ?', [$role]);
$this->query('DELETE FROM da_plugin_grant_profiles WHERE role_name = ?', [$role]);
$this->query('DELETE FROM da_plugin_database_owners WHERE role_name = ?', [$role]);
$this->query('DELETE FROM da_plugin_sso_accounts WHERE role_name = ?', [$role]);
}
public function reassignOwnedObjects(string $dbName, string $fromRole, string $toRole): void
{
if ($fromRole === $toRole) {
return;
}
$this->query(
'UPDATE da_plugin_database_owners
SET role_name = ?, da_user = ?, updated_at = CURRENT_TIMESTAMP
WHERE db_name = ? AND role_name = ?',
[$toRole, '', $dbName, $fromRole]
);
if ($this->databaseExists($dbName) && $this->roleExists($toRole)) {
$this->grantAllPrivileges($dbName, $toRole);
}
}
/**
* @param string[] $privileges
*/
public function grantPrivileges(string $dbName, string $role, array $privileges): void
{
$privileges = array_values(array_unique($privileges));
if (empty($privileges)) {
throw new RuntimeException('Brak uprawnień do nadania.');
}
$this->ensureDefaultRoleHostsProvisioned($role);
$hosts = $this->listActualRoleHosts($role);
if (empty($hosts)) {
throw new RuntimeException('Użytkownik MySQL nie istnieje: ' . $role);
}
if (in_array('ALL', $privileges, true)) {
$this->grantAllPrivileges($dbName, $role);
$this->saveGrantProfile($dbName, $role, ['ALL']);
return;
}
$this->revokePrivilegesInternal($dbName, $role, false);
$grantSql = implode(', ', array_map([$this, 'mapPrivilegeCodeToSql'], $privileges));
foreach ($hosts as $host) {
$this->query(
'GRANT ' . $grantSql . ' ON ' . $this->quoteIdentifier($dbName) . '.* TO ' .
$this->quoteLiteral($role) . '@' . $this->quoteLiteral($host)
);
}
sort($privileges);
$this->saveGrantProfile($dbName, $role, $privileges);
}
public function revokePrivileges(string $dbName, string $role): void
{
$this->revokePrivilegesInternal($dbName, $role, true);
}
/**
* @return string[]
*/
public function getGrantProfile(string $dbName, string $role): array
{
$row = $this->fetchOne(
'SELECT privileges FROM da_plugin_grant_profiles WHERE db_name = ? AND role_name = ?',
[$dbName, $role]
);
if (empty($row['privileges'])) {
return [];
}
$parts = array_filter(array_map('trim', explode(',', (string)$row['privileges'])));
$parts = array_map('strtoupper', $parts);
return array_values(array_unique($parts));
}
/**
* @return array{ok:bool,output:string}
*/
public function syncHostRules(): array
{
if (!$this->settings->modifyServerConfiguration()) {
return ['ok' => true, 'output' => ''];
}
$script = $this->settings->hostSyncScript();
if (!is_file($script)) {
return ['ok' => false, 'output' => 'Brak skryptu synchronizacji hostów MySQL: ' . $script];
}
$cmd = escapeshellarg($script) . ' 2>&1';
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
return [
'ok' => $exitCode === 0,
'output' => trim(implode("\n", $output)),
];
}
public function createTemporaryRole(string $role, string $password, int $expiresAt): void
{
$this->query(
'CREATE USER ' . $this->quoteLiteral($role) . '@' . $this->quoteLiteral(self::TMP_ROLE_HOST) .
' IDENTIFIED BY ' . $this->quoteLiteral($password)
);
$this->query(
'INSERT INTO da_plugin_sso_accounts (role_name, host_pattern, expires_at)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at)',
[$role, self::TMP_ROLE_HOST, (string)$expiresAt]
);
}
public function grantTemporaryAdminerAccess(string $dbName, string $role): void
{
$this->query(
'GRANT ALL PRIVILEGES ON ' . $this->quoteIdentifier($dbName) . '.* TO ' .
$this->quoteLiteral($role) . '@' . $this->quoteLiteral(self::TMP_ROLE_HOST)
);
}
public function dropTemporaryRole(string $role): void
{
$this->tryQuery('DROP USER IF EXISTS ' . $this->quoteLiteral($role) . '@' . $this->quoteLiteral(self::TMP_ROLE_HOST));
$this->query('DELETE FROM da_plugin_sso_accounts WHERE role_name = ?', [$role]);
}
public function cleanupExpiredAdminerRoles(string $rolePrefix = self::TMP_ROLE_PREFIX): int
{
$rows = $this->fetchAll(
'SELECT role_name
FROM da_plugin_sso_accounts
WHERE role_name LIKE ? ESCAPE \'\\\\\'
AND expires_at < ?
ORDER BY role_name',
[$this->likePattern($rolePrefix), (string)time()]
);
$removed = 0;
foreach ($rows as $row) {
$role = (string)($row['role_name'] ?? '');
if ($role === '') {
continue;
}
try {
$this->dropTemporaryRole($role);
$removed++;
} catch (Throwable $e) {
// Ignorujemy błędy cleanupu, aby nie blokować logowania.
}
}
return $removed;
}
private function grantAllPrivileges(string $dbName, string $role): void
{
$this->ensureDefaultRoleHostsProvisioned($role);
foreach ($this->listActualRoleHosts($role) as $host) {
$this->query(
'GRANT ALL PRIVILEGES ON ' . $this->quoteIdentifier($dbName) . '.* TO ' .
$this->quoteLiteral($role) . '@' . $this->quoteLiteral($host)
);
}
}
private function revokePrivilegesInternal(string $dbName, string $role, bool $removeProfile): void
{
foreach ($this->listActualRoleHosts($role) as $host) {
$this->tryQuery(
'REVOKE ALL PRIVILEGES, GRANT OPTION ON ' . $this->quoteIdentifier($dbName) . '.* FROM ' .
$this->quoteLiteral($role) . '@' . $this->quoteLiteral($host)
);
}
if ($removeProfile) {
$this->query('DELETE FROM da_plugin_grant_profiles WHERE db_name = ? AND role_name = ?', [$dbName, $role]);
}
}
/**
* @param string[] $privileges
*/
private function saveGrantProfile(string $dbName, string $role, array $privileges): void
{
sort($privileges);
$serialized = implode(',', $privileges);
$this->query(
'INSERT INTO da_plugin_grant_profiles (db_name, role_name, privileges)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE privileges = VALUES(privileges), updated_at = CURRENT_TIMESTAMP',
[$dbName, $role, $serialized]
);
}
/**
* @return string[]
*/
private function listActualRoleHosts(string $role): array
{
$rows = $this->fetchAll(
'SELECT Host AS host
FROM mysql.user
WHERE User = ?
ORDER BY CASE WHEN Host = \'localhost\' THEN 0 ELSE 1 END, Host',
[$role]
);
$hosts = [];
foreach ($rows as $row) {
$host = (string)($row['host'] ?? '');
if ($host !== '') {
$hosts[] = $host;
}
}
return array_values(array_unique($hosts));
}
private function ensureDefaultRoleHostsProvisioned(string $role): void
{
$hosts = $this->listActualRoleHosts($role);
if (empty($hosts)) {
return;
}
foreach ($this->requiredAccessHosts() as $requiredHost) {
if (!in_array($requiredHost, $hosts, true)) {
$this->cloneRoleToHost($role, $requiredHost);
$hosts[] = $requiredHost;
}
}
}
private function cloneRoleToHost(string $role, string $targetHost): void
{
$source = $this->fetchOne(
'SELECT Host AS host, plugin, authentication_string
FROM mysql.user
WHERE User = ?
ORDER BY CASE WHEN Host = \'localhost\' THEN 0 ELSE 1 END, Host
LIMIT 1',
[$role]
);
if (empty($source)) {
throw new RuntimeException('Nie można utworzyć hosta dla użytkownika MySQL bez konta źródłowego.');
}
$plugin = (string)($source['plugin'] ?? '');
$authString = (string)($source['authentication_string'] ?? '');
$quotedRole = $this->quoteLiteral($role);
$quotedHost = $this->quoteLiteral($targetHost);
if ($plugin !== '' && $authString !== '') {
$sql = 'CREATE USER ' . $quotedRole . '@' . $quotedHost .
' IDENTIFIED WITH ' . $this->quotePluginName($plugin) .
' AS ' . $this->quoteLiteral($authString);
} elseif ($authString !== '') {
$sql = 'CREATE USER ' . $quotedRole . '@' . $quotedHost .
' IDENTIFIED BY PASSWORD ' . $this->quoteLiteral($authString);
} else {
throw new RuntimeException('Nie można sklonować uwierzytelnienia użytkownika MySQL dla nowego hosta.');
}
$this->query($sql);
}
private function dropRoleHost(string $role, string $host): void
{
$this->tryQuery('DROP USER IF EXISTS ' . $this->quoteLiteral($role) . '@' . $this->quoteLiteral($host));
}
private function sanitizeHostNote(string $note): string
{
$note = preg_replace('/[\r\n\t]+/', ' ', $note) ?? '';
$note = preg_replace('/\s+/', ' ', $note) ?? '';
$note = trim($note);
if (strlen($note) > NamePolicy::MAX_HOST_COMMENT_LEN) {
$note = substr($note, 0, NamePolicy::MAX_HOST_COMMENT_LEN);
}
return $note;
}
/**
* @return string[]
*/
public function requiredAccessHosts(): array
{
$hosts = ['localhost'];
if (!$this->isRemoteDatabaseHost()) {
return $hosts;
}
foreach ($this->detectDirectAdminServerHosts() as $host) {
if (!in_array($host, $hosts, true)) {
$hosts[] = $host;
}
}
return $hosts;
}
private function requiredAccessHostNote(string $host): string
{
if ($host === 'localhost') {
return 'Dodawany automatycznie dla polaczen lokalnych.';
}
if (in_array($host, $this->requiredAccessHosts(), true)) {
return 'Adres serwera DirectAdmin dodawany automatycznie dla zdalnego MySQL.';
}
return '';
}
private function isRemoteDatabaseHost(): bool
{
$host = trim($this->credentials['host']);
return !$this->isLocalHost($host);
}
/**
* @return string[]
*/
private function detectDirectAdminServerHosts(): array
{
$hosts = [];
$manual = trim($this->settings->getString('MYSQL_PLUGIN_LOCAL_SERVER_HOST', ''));
if ($manual !== '') {
foreach (preg_split('/[\s,;]+/', $manual) ?: [] as $candidate) {
$candidate = trim((string)$candidate);
if ($this->isUsableRemoteAccessHost($candidate) && !in_array($candidate, $hosts, true)) {
$hosts[] = $candidate;
}
}
}
$serverAddr = trim(Http::server('SERVER_ADDR'));
if ($this->isUsableRemoteAccessHost($serverAddr) && !in_array($serverAddr, $hosts, true)) {
$hosts[] = $serverAddr;
}
$daConf = '/usr/local/directadmin/conf/directadmin.conf';
if (is_readable($daConf)) {
$lines = file($daConf, FILE_IGNORE_NEW_LINES);
if ($lines !== false) {
foreach ($lines as $line) {
$line = trim((string)$line);
if ($line === '' || $line[0] === '#') {
continue;
}
if (!preg_match('/^(?:ip|server_ip)\s*=\s*(.+)$/i', $line, $m)) {
continue;
}
$candidate = trim((string)$m[1]);
if ($this->isUsableRemoteAccessHost($candidate) && !in_array($candidate, $hosts, true)) {
$hosts[] = $candidate;
}
}
}
}
$resolved = @gethostbyname((string)php_uname('n'));
if ($this->isUsableRemoteAccessHost($resolved) && !in_array($resolved, $hosts, true)) {
$hosts[] = $resolved;
}
return $hosts;
}
private function isLocalHost(string $host): bool
{
$host = strtolower(trim($host));
return $host === '' || in_array($host, ['localhost', '127.0.0.1', '::1'], true);
}
private function isUsableRemoteAccessHost(string $host): bool
{
$host = trim($host);
if ($host === '' || $this->isLocalHost($host)) {
return false;
}
return filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
}
private function likePattern(string $prefix): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $prefix) . '%';
}
private function quoteIdentifier(string $name): string
{
if (!preg_match('/^[A-Za-z0-9_]+$/', $name)) {
throw new RuntimeException('Nieprawidłowy identyfikator MySQL: ' . $name);
}
if (strlen($name) > NamePolicy::MAX_IDENTIFIER_LEN) {
throw new RuntimeException('Identyfikator MySQL przekracza 64 znaki: ' . $name);
}
return '`' . str_replace('`', '``', $name) . '`';
}
private function quotePluginName(string $name): string
{
if (!preg_match('/^[A-Za-z0-9_]+$/', $name)) {
throw new RuntimeException('Nieprawidłowa nazwa pluginu uwierzytelniania MySQL: ' . $name);
}
return $name;
}
private function quoteLiteral(string $value, string $database = ''): string
{
return '\'' . $this->connect($database)->real_escape_string($value) . '\'';
}
private function mapPrivilegeCodeToSql(string $code): string
{
return match ($code) {
'CREATE_VIEW' => 'CREATE VIEW',
'SHOW_VIEW' => 'SHOW VIEW',
'LOCK_TABLES' => 'LOCK TABLES',
default => $code,
};
}
private function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . ' B';
}
$units = ['KB', 'MB', 'GB', 'TB'];
$value = $bytes;
foreach ($units as $unit) {
$value /= 1024;
if ($value < 1024 || $unit === 'TB') {
return number_format($value, $value >= 10 ? 0 : 1, '.', '') . ' ' . $unit;
}
}
return $bytes . ' B';
}
/**
* @param array<int,mixed> $params
*/
private function query(string $sql, array $params = [], string $database = ''): mysqli_result|bool
{
$conn = $this->connect($database);
if ($params !== []) {
$sql = $this->interpolateQuery($conn, $sql, $params);
}
$res = @$conn->query($sql);
if ($res === false) {
throw new RuntimeException(trim((string)$conn->error) . ' | SQL: ' . $sql);
}
return $res;
}
/**
* @param array<int,mixed> $params
*/
private function interpolateQuery(mysqli $conn, string $sql, array $params): string
{
$parts = explode('?', $sql);
if (count($parts) - 1 !== count($params)) {
throw new RuntimeException('Nieprawidłowa liczba parametrów SQL.');
}
$out = array_shift($parts);
foreach ($params as $index => $param) {
$value = is_bool($param) ? ($param ? '1' : '0') : (string)$param;
$out .= '\'' . $conn->real_escape_string($value) . '\'' . $parts[$index];
}
return (string)$out;
}
/**
* @param array<int,mixed> $params
*/
private function tryQuery(string $sql, array $params = [], string $database = ''): void
{
try {
$this->query($sql, $params, $database);
} catch (Throwable $e) {
// celowo ignorujemy błędy pomocnicze
}
}
/**
* @param array<int,mixed> $params
* @return array<int,array<string,mixed>>
*/
private function fetchAll(string $sql, array $params = [], string $database = ''): array
{
$res = $this->query($sql, $params, $database);
if (!$res instanceof mysqli_result) {
return [];
}
$rows = $res->fetch_all(MYSQLI_ASSOC);
$res->free();
return is_array($rows) ? $rows : [];
}
/**
* @param array<int,mixed> $params
* @return array<string,mixed>
*/
private function fetchOne(string $sql, array $params = [], string $database = ''): array
{
$rows = $this->fetchAll($sql, $params, $database);
return $rows[0] ?? [];
}
private function connect(string $database = ''): mysqli
{
$targetDb = $database !== '' ? $database : $this->credentials['database'];
if (isset($this->connections[$targetDb])) {
return $this->connections[$targetDb];
}
mysqli_report(MYSQLI_REPORT_OFF);
$conn = mysqli_init();
if (!$conn instanceof mysqli) {
throw new RuntimeException('Nie udało się zainicjalizować połączenia MySQL.');
}
$socket = '';
if ($this->credentials['host'] === 'localhost' && $this->credentials['socket'] !== '') {
$socket = $this->credentials['socket'];
}
$ok = @$conn->real_connect(
$this->credentials['host'],
$this->credentials['user'],
$this->credentials['password'],
$targetDb,
$this->credentials['port'],
$socket !== '' ? $socket : null
);
if ($ok !== true) {
error_log(sprintf(
'[%s] MYSQL_CONNECT_FAIL host=%s port=%d database=%s error=%s',
date('c'),
$this->credentials['host'],
$this->credentials['port'],
$targetDb,
$conn->connect_error ?? 'unknown'
));
throw new RuntimeException('Nie udało się połączyć z MySQL.');
}
$conn->set_charset('utf8mb4');
$this->connections[$targetDb] = $conn;
return $conn;
}
}