From f8647bb15eecc44d1bbd15cd5ea26a41e87f6606 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it Date: Thu, 6 Jul 2023 15:49:06 +0200 Subject: [PATCH] [Web] add keycloak sync crontask --- data/conf/phpfpm/crons/keycloak-sync.php | 221 +++++++++++++++++++++++ docker-compose.yml | 6 + 2 files changed, 227 insertions(+) create mode 100644 data/conf/phpfpm/crons/keycloak-sync.php diff --git a/data/conf/phpfpm/crons/keycloak-sync.php b/data/conf/phpfpm/crons/keycloak-sync.php new file mode 100644 index 000000000..f401f0ae7 --- /dev/null +++ b/data/conf/phpfpm/crons/keycloak-sync.php @@ -0,0 +1,221 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]; +try { + $pdo = new PDO($dsn, $database_user, $database_pass, $opt); +} +catch (PDOException $e) { + logMsg("danger", $e->getMessage()); + session_destroy(); + exit; +} + +// Init Redis +$redis = new Redis(); +try { + if (!empty(getenv('REDIS_SLAVEOF_IP'))) { + $redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT')); + } + else { + $redis->connect('redis-mailcow', 6379); + } +} +catch (Exception $e) { + echo "Exiting: " . $e->getMessage(); + session_destroy(); + exit; +} + +function logMsg($priority, $message, $task = "Keycloak Sync") { + global $redis; + + $finalMsg = array( + "time" => time(), + "priority" => $priority, + "task" => $task, + "message" => $message + ); + $redis->lPush('CRON_LOG', json_encode($finalMsg)); +} + +// Load core functions first +require_once __DIR__ . '/../web/inc/functions.inc.php'; +require_once __DIR__ . '/../web/inc/functions.auth.inc.php'; +require_once __DIR__ . '/../web/inc/sessions.inc.php'; +require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php'; +require_once __DIR__ . '/../web/inc/functions.ratelimit.inc.php'; +require_once __DIR__ . '/../web/inc/functions.acl.inc.php'; + +$_SESSION['mailcow_cc_username'] = "admin"; +$_SESSION['mailcow_cc_role'] = "admin"; +$_SESSION['acl']['tls_policy'] = "1"; +$_SESSION['acl']['quarantine_notification'] = "1"; +$_SESSION['acl']['quarantine_category'] = "1"; +$_SESSION['acl']['ratelimit'] = "1"; +$_SESSION['acl']['sogo_access'] = "1"; +$_SESSION['acl']['protocol_access'] = "1"; +$_SESSION['acl']['mailbox_relayhost'] = "1"; + +// Init Keycloak Provider +$iam_provider = identity_provider('init'); +$iam_settings = identity_provider('get'); +if (intval($iam_settings['periodic_sync']) != 1 && $iam_settings['import_users'] != 1) { + logMsg("warning", "IAM Sync is disabled"); + session_destroy(); + exit; +} + +// Set pagination variables +$start = 0; +$max = 25; + +// lock sync if already running +$lock_file = '/tmp/iam-sync.lock'; +if (file_exists($lock_file)) { + $lock_file_parts = explode("\n", file_get_contents($lock_file)); + $pid = $lock_file_parts[0]; + if (count($lock_file_parts) > 1){ + $last_execution = $lock_file_parts[1]; + $elapsed_time = (time() - $last_execution) / 60; + if ($elapsed_time < intval($iam_settings['sync_interval'])) { + logMsg("warning", "Sync Interval not ready (".number_format((float)$elapsed_time, 2, '.', '')."min / ".$iam_settings['sync_interval']."min)"); + session_destroy(); + exit; + } + } + + if (posix_kill($pid, 0)) { + logMsg("warning", "Sync is already running"); + session_destroy(); + exit; + } else { + unlink($lock_file); + } +} +$lock_file_handle = fopen($lock_file, 'w'); +fwrite($lock_file_handle, getmypid()); +fclose($lock_file_handle); + +// Loop until all users have been retrieved +while (true) { + // Get admin access token + $admin_token = identity_provider("get-keycloak-admin-token"); + + // Make the API request to retrieve the users + $url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users?first=$start&max=$max"; + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + "Content-Type: application/json", + "Authorization: Bearer " . $admin_token + ]); + $response = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code != 200){ + logMsg("danger", "Recieved HTTP {$code}"); + session_destroy(); + exit; + } + try { + $response = json_decode($response, true); + } catch (Exception $e) { + logMsg("danger", $e->getMessage()); + break; + } + if (!is_array($response)){ + logMsg("danger", "Recieved malformed response from keycloak api"); + break; + } + if (count($response) == 0) { + break; + } + + // Process the batch of users + foreach ($response as $user) { + if (empty($user['email'])){ + logMsg("warning", "No email address in keycloak found for user " . $user['name']); + continue; + } + if (!isset($user['attributes'])){ + logMsg("warning", "No attributes in keycloak found for user " . $user['email']); + continue; + } + if (count($user['attributes']['mailcow_template']) == 0) { + logMsg("warning", "No mailcow_template in keycloak found for user " . $user['email']); + continue; + }; + $mailcow_template = $user['attributes']['mailcow_template']; + + // try get mailbox user + $stmt = $pdo->prepare("SELECT `mailbox`.* FROM `mailbox` + INNER JOIN domain on mailbox.domain = domain.domain + WHERE `kind` NOT REGEXP 'location|thing|group' + AND `domain`.`active`='1' + AND `username` = :user"); + $stmt->execute(array(':user' => $user['email'])); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + // check if matching attribute mapping exists + $mbox_template = null; + foreach ($iam_settings['mappers'] as $index => $mapper){ + if (in_array($mapper, $user['attributes']['mailcow_template'])) { + $mbox_template = $mapper; + break; + } + } + if (!$mbox_template){ + logMsg("warning", "No matching mapper found for mailbox_template"); + continue; + } + + if (!$row && intval($iam_settings['import_users']) == 1){ + // mailbox user does not exist, create... + logMsg("info", "Creating user " . $user['email']); + mailbox('add', 'mailbox_from_template', array( + 'domain' => explode('@', $user['email'])[1], + 'local_part' => explode('@', $user['email'])[0], + 'authsource' => 'keycloak', + 'template' => $mbox_template + )); + } else if ($row) { + // mailbox user does exist, sync attribtues... + logMsg("info", "Syncing attributes for user " . $user['email']); + mailbox('edit', 'mailbox_from_template', array( + 'username' => $user['email'], + 'template' => $mbox_template + )); + } else { + // skip mailbox user + logMsg("info", "Skipping user " . $user['email']); + } + + sleep(0.025); + } + + // Update the pagination variables for the next batch + $start += $max; + sleep(1); +} + +logMsg("info", "DONE!"); +// add last execution time to lock file +$lock_file_handle = fopen($lock_file, 'w'); +fwrite($lock_file_handle, getmypid() . "\n" . time()); +fclose($lock_file_handle); +session_destroy(); diff --git a/docker-compose.yml b/docker-compose.yml index fffa193c8..a330e1689 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -128,6 +128,7 @@ services: - mysql-socket-vol-1:/var/run/mysqld/ - ./data/conf/sogo/:/etc/sogo/:z - ./data/conf/rspamd/meta_exporter:/meta_exporter:ro,z + - ./data/conf/phpfpm/crons:/crons:z - ./data/conf/phpfpm/sogo-sso/:/etc/sogo-sso/:z - ./data/conf/phpfpm/php-fpm.d/pools.conf:/usr/local/etc/php-fpm.d/z-pools.conf:Z - ./data/conf/phpfpm/php-conf.d/opcache-recommended.ini:/usr/local/etc/php/conf.d/opcache-recommended.ini:Z @@ -173,6 +174,11 @@ services: - WEBAUTHN_ONLY_TRUSTED_VENDORS=${WEBAUTHN_ONLY_TRUSTED_VENDORS:-n} - CLUSTERMODE=${CLUSTERMODE:-} restart: always + labels: + ofelia.enabled: "true" + ofelia.job-exec.phpfpm_keycloak_sync.schedule: "@every 1m" + ofelia.job-exec.phpfpm_keycloak_sync.no-overlap: "true" + ofelia.job-exec.phpfpm_keycloak_sync.command: "/bin/bash -c \"php /crons/keycloak-sync.php || exit 0\"" networks: mailcow-network: aliases: