From 0dadf499340cf4b5e3431b8cca382be539a13340 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:20:17 +0200 Subject: [PATCH] [Web] Rework API keys: multiple read-only/read-write keys, service keys from mailcow.conf --- _modules/scripts/new_options.sh | 8 +- data/Dockerfiles/phpfpm/docker-entrypoint.sh | 29 -- data/web/admin/system.php | 4 - data/web/edit.php | 5 + data/web/inc/functions.inc.php | 122 --------- data/web/inc/init_db.inc.php | 20 +- .../inc/lib/mailcow/ApiKey/ApiKeyModule.php | 40 +++ .../ApiKey/Controller/ApiKeyApiController.php | 115 ++++++++ .../inc/lib/mailcow/ApiKey/Model/ApiKey.php | 64 +++++ .../ApiKey/Repository/ApiKeyRepository.php | 84 ++++++ .../mailcow/ApiKey/Service/ApiKeyService.php | 249 ++++++++++++++++++ data/web/inc/lib/mailcow/Core/Context.php | 21 +- data/web/inc/lib/mailcow/README.md | 1 + data/web/inc/triggers.admin.inc.php | 16 -- data/web/js/site/admin.js | 142 ++++++++-- data/web/js/site/edit.js | 9 + data/web/json_api.php | 21 ++ .../templates/admin/tab-config-admins.twig | 195 +++++--------- data/web/templates/edit/api-key.twig | 71 +++++ data/web/templates/modals/admin.twig | 57 ++++ generate_config.sh | 4 +- 21 files changed, 942 insertions(+), 335 deletions(-) create mode 100644 data/web/inc/lib/mailcow/ApiKey/ApiKeyModule.php create mode 100644 data/web/inc/lib/mailcow/ApiKey/Controller/ApiKeyApiController.php create mode 100644 data/web/inc/lib/mailcow/ApiKey/Model/ApiKey.php create mode 100644 data/web/inc/lib/mailcow/ApiKey/Repository/ApiKeyRepository.php create mode 100644 data/web/inc/lib/mailcow/ApiKey/Service/ApiKeyService.php create mode 100644 data/web/templates/edit/api-key.twig diff --git a/_modules/scripts/new_options.sh b/_modules/scripts/new_options.sh index 2c39c8a7c..840700148 100644 --- a/_modules/scripts/new_options.sh +++ b/_modules/scripts/new_options.sh @@ -111,16 +111,16 @@ adapt_new_options() { echo "SQL_PORT=127.0.0.1:13306" >> mailcow.conf ;; API_KEY) - echo '# Create or override API key for web UI' >> mailcow.conf + echo '# Read-write service API key, shown read-only in the web UI' >> mailcow.conf echo "#API_KEY=" >> mailcow.conf ;; API_KEY_READ_ONLY) - echo '# Create or override read-only API key for web UI' >> mailcow.conf + echo '# Read-only service API key, shown read-only in the web UI' >> mailcow.conf echo "#API_KEY_READ_ONLY=" >> mailcow.conf ;; API_ALLOW_FROM) - echo '# Must be set for API_KEY to be active' >> mailcow.conf - echo '# IPs only, no networks (networks can be set via UI)' >> mailcow.conf + echo '# Must be set for API_KEY and API_KEY_READ_ONLY to be active' >> mailcow.conf + echo '# Comma separated list of IPs/networks' >> mailcow.conf echo "#API_ALLOW_FROM=" >> mailcow.conf ;; SNAT_TO_SOURCE) diff --git a/data/Dockerfiles/phpfpm/docker-entrypoint.sh b/data/Dockerfiles/phpfpm/docker-entrypoint.sh index d7fa15556..4c6f71af8 100755 --- a/data/Dockerfiles/phpfpm/docker-entrypoint.sh +++ b/data/Dockerfiles/phpfpm/docker-entrypoint.sh @@ -1,7 +1,5 @@ #!/bin/bash -function array_by_comma { local IFS=","; echo "$*"; } - # Wait for containers while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do echo "Waiting for SQL..." @@ -132,33 +130,6 @@ if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then done fi - # Set API options if env vars are not empty - if [[ ${API_ALLOW_FROM} != "invalid" ]] && [[ ! -z ${API_ALLOW_FROM} ]]; then - IFS=',' read -r -a API_ALLOW_FROM_ARR <<< "${API_ALLOW_FROM}" - declare -a VALIDATED_API_ALLOW_FROM_ARR - REGEX_IP6='^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}(/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$' - REGEX_IP4='^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/([0-9]|[1-2][0-9]|3[0-2]))?$' - for IP in "${API_ALLOW_FROM_ARR[@]}"; do - if [[ ${IP} =~ ${REGEX_IP6} ]] || [[ ${IP} =~ ${REGEX_IP4} ]]; then - VALIDATED_API_ALLOW_FROM_ARR+=("${IP}") - fi - done - VALIDATED_IPS=$(array_by_comma ${VALIDATED_API_ALLOW_FROM_ARR[*]}) - if [[ ! -z ${VALIDATED_IPS} ]]; then - if [[ ${API_KEY} != "invalid" ]] && [[ ! -z ${API_KEY} ]]; then - mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF -DELETE FROM api WHERE access = 'rw'; -INSERT INTO api (api_key, active, allow_from, access) VALUES ("${API_KEY}", "1", "${VALIDATED_IPS}", "rw"); -EOF - fi - if [[ ${API_KEY_READ_ONLY} != "invalid" ]] && [[ ! -z ${API_KEY_READ_ONLY} ]]; then - mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF -DELETE FROM api WHERE access = 'ro'; -INSERT INTO api (api_key, active, allow_from, access) VALUES ("${API_KEY_READ_ONLY}", "1", "${VALIDATED_IPS}", "ro"); -EOF - fi - fi - fi # Create events (master only, STATUS for event on slave will be SLAVESIDE_DISABLED) mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF diff --git a/data/web/admin/system.php b/data/web/admin/system.php index c74f8c6a6..326a8a6af 100644 --- a/data/web/admin/system.php +++ b/data/web/admin/system.php @@ -93,10 +93,6 @@ $template_data = [ 'tfa_id' => @$_SESSION['tfa_id'], 'fido2_cid' => @$_SESSION['fido2_cid'], 'fido2_data' => $fido2_data, - 'api' => [ - 'ro' => admin_api('ro', 'get'), - 'rw' => admin_api('rw', 'get'), - ], 'dkim_domains' => $dkim_domains, 'dkim_domains_with_keys' => $dkim_domains_with_keys, 'dkim_blind_domains' => $dkim_blind_domains, diff --git a/data/web/edit.php b/data/web/edit.php index b003d5bf6..4594ae3c8 100644 --- a/data/web/edit.php +++ b/data/web/edit.php @@ -99,6 +99,11 @@ if (isset($_SESSION['mailcow_cc_role'])) { $template = 'edit/oauth2client.twig'; $template_data = ['oauth2client' => $oauth2client]; } + elseif (isset($_GET['api-key']) && + is_numeric($_GET['api-key'])) { + $result = \Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKey($_GET['api-key']); + $template = 'edit/api-key.twig'; + } elseif (isset($_GET['aliasdomain']) && is_valid_domain_name(html_entity_decode(rawurldecode($_GET["aliasdomain"]))) && !empty($_GET["aliasdomain"])) { diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 09e8ec193..286a5bdf7 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -2054,128 +2054,6 @@ function verify_tfa_login($username, $_data) { return true; } } -function admin_api($access, $action, $data = null) { - global $pdo; - if ($_SESSION['mailcow_cc_role'] != "admin") { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__), - 'msg' => 'access_denied' - ); - return false; - } - if ($access !== "ro" && $access !== "rw") { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__), - 'msg' => 'invalid access type' - ); - return false; - } - if ($action == "edit") { - $active = (!empty($data['active'])) ? 1 : 0; - $skip_ip_check = (isset($data['skip_ip_check'])) ? 1 : 0; - $allow_from = array(); - if (isset($data['allow_from'])) { - $allow_from = array_map('trim', preg_split( "/( |,|;|\n)/", $data['allow_from'])); - } - foreach ($allow_from as $key => $val) { - if (empty($val)) { - unset($allow_from[$key]); - continue; - } - if (valid_network($val) !== true) { - $_SESSION['return'][] = array( - 'type' => 'warning', - 'log' => array(__FUNCTION__, $data), - 'msg' => array('ip_invalid', htmlspecialchars($allow_from[$key])) - ); - unset($allow_from[$key]); - continue; - } - } - $allow_from = implode(',', array_unique(array_filter($allow_from))); - if (empty($allow_from) && $skip_ip_check == 0) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $data), - 'msg' => 'ip_list_empty' - ); - return false; - } - $api_key = implode('-', array( - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))) - )); - $stmt = $pdo->query("SELECT `api_key` FROM `api` WHERE `access` = '" . $access . "'"); - $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); - if (empty($num_results)) { - $stmt = $pdo->prepare("INSERT INTO `api` (`api_key`, `skip_ip_check`, `active`, `allow_from`, `access`) - VALUES (:api_key, :skip_ip_check, :active, :allow_from, :access);"); - $stmt->execute(array( - ':api_key' => $api_key, - ':skip_ip_check' => $skip_ip_check, - ':active' => $active, - ':allow_from' => $allow_from, - ':access' => $access - )); - } - else { - if ($skip_ip_check == 0) { - $stmt = $pdo->prepare("UPDATE `api` SET `skip_ip_check` = :skip_ip_check, - `active` = :active, - `allow_from` = :allow_from - WHERE `access` = :access;"); - $stmt->execute(array( - ':active' => $active, - ':skip_ip_check' => $skip_ip_check, - ':allow_from' => $allow_from, - ':access' => $access - )); - } - else { - $stmt = $pdo->prepare("UPDATE `api` SET `skip_ip_check` = :skip_ip_check, - `active` = :active - WHERE `access` = :access;"); - $stmt->execute(array( - ':active' => $active, - ':skip_ip_check' => $skip_ip_check, - ':access' => $access - )); - } - } - } - elseif ($action == "regen_key") { - $api_key = implode('-', array( - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))) - )); - $stmt = $pdo->prepare("UPDATE `api` SET `api_key` = :api_key WHERE `access` = :access"); - $stmt->execute(array( - ':api_key' => $api_key, - ':access' => $access - )); - } - elseif ($action == "get") { - $stmt = $pdo->query("SELECT * FROM `api` WHERE `access` = '" . $access . "'"); - $apidata = $stmt->fetch(PDO::FETCH_ASSOC); - if ($apidata !== false) { - $apidata['allow_from'] = str_replace(',', PHP_EOL, $apidata['allow_from']); - } - return $apidata; - } - $_SESSION['return'][] = array( - 'type' => 'success', - 'log' => array(__FUNCTION__, $data), - 'msg' => 'admin_api_modified' - ); -} function license($action, $data = null) { global $pdo; global $redis; diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index a9344e6c0..02a876a86 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -4,7 +4,7 @@ function init_db_schema() try { global $pdo; - $db_version = "16102026_1200"; + $db_version = "22102026_1200"; $stmt = $pdo->query("SHOW TABLES LIKE 'versions'"); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); @@ -204,18 +204,25 @@ function init_db_schema() ), "api" => array( "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", "api_key" => "VARCHAR(255) NOT NULL", + "description" => "VARCHAR(255) NOT NULL DEFAULT ''", "allow_from" => "VARCHAR(512) NOT NULL", "skip_ip_check" => "TINYINT(1) NOT NULL DEFAULT '0'", "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", "modified" => "DATETIME ON UPDATE NOW(0)", "access" => "ENUM('ro', 'rw') NOT NULL DEFAULT 'rw'", + // 1 = defined in mailcow.conf, read-only in UI and API + "service" => "TINYINT(1) NOT NULL DEFAULT '0'", "active" => "TINYINT(1) NOT NULL DEFAULT '1'" ), "keys" => array( "primary" => array( - "" => array("api_key") + "" => array("id") ), + "unique" => array( + "api_key" => array("api_key") + ) ), "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" ), @@ -1855,4 +1862,13 @@ if (php_sapi_name() == "cli") { // Dunno } init_db_schema(); + // Service API keys from mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) + try { + $api_keys = \Mailcow\ApiKey\ApiKeyModule::create($pdo, \Mailcow\Core\Context::system())->keys; + foreach ($api_keys->syncServiceKeys(getenv()) as $warning) { + echo "API service keys: " . $warning . PHP_EOL; + } + } catch (PDOException $e) { + echo "API service keys: sync failed: " . $e->getMessage() . PHP_EOL; + } } diff --git a/data/web/inc/lib/mailcow/ApiKey/ApiKeyModule.php b/data/web/inc/lib/mailcow/ApiKey/ApiKeyModule.php new file mode 100644 index 000000000..446f0c9d1 --- /dev/null +++ b/data/web/inc/lib/mailcow/ApiKey/ApiKeyModule.php @@ -0,0 +1,40 @@ +api->addKey($attr)); + * ApiKeyModule::create($pdo, Context::system())->keys->syncServiceKeys(getenv()); // php-fpm start + */ +class ApiKeyModule +{ + /** Request keys whose values never reach a log */ + private const SECRET_KEYS = ['api_key']; + + public function __construct(public readonly ApiKeyService $keys, public readonly ApiKeyApiController $api) + { + } + + public static function create(PDO $pdo, Context $context): self + { + $keys = new ApiKeyService(new ApiKeyRepository($pdo), $context); + return new self($keys, new ApiKeyApiController($keys, new ApiResponse(self::SECRET_KEYS))); + } + + /** Legacy entry point for procedural callers: reads $pdo and $_SESSION; $context overrides the session caller */ + public static function fromGlobals(?Context $context = null): self + { + return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? [])); + } +} diff --git a/data/web/inc/lib/mailcow/ApiKey/Controller/ApiKeyApiController.php b/data/web/inc/lib/mailcow/ApiKey/Controller/ApiKeyApiController.php new file mode 100644 index 000000000..294782e47 --- /dev/null +++ b/data/web/inc/lib/mailcow/ApiKey/Controller/ApiKeyApiController.php @@ -0,0 +1,115 @@ +handle('add', $attr, + fn(array $log): bool => $this->response->success(['api_key_added', self::label($this->keys->create($attr))], $log)); + } + + /** POST edit/api-key: one message per id */ + public function editKeys(?array $items, array $attr): bool + { + $data = array_merge(['id' => $items], $attr); + return $this->handle('edit', $data, function (array $log) use ($data, $attr): bool { + $this->keys->requireAdmin(); + return $this->batch((array)($data['id'] ?? []), $log, + fn(int $id): array => ['api_key_modified', self::label($this->keys->update($id, $attr))]); + }); + } + + /** POST delete/api-key: one message per id */ + public function deleteKeys(array $items): bool + { + return $this->handle('delete', ['id' => $items], function (array $log) use ($items): bool { + $this->keys->requireAdmin(); + return $this->batch($items, $log, fn(int $id): array => ['api_key_deleted', self::label($this->keys->delete($id))]); + }); + } + + /** GET api-key/all */ + public function getKeys(): array + { + return array_map(fn(ApiKey $key): array => $key->toArray(), $this->keys->all()); + } + + /** GET api-key/, also edit.php */ + public function getKey(mixed $id): array + { + $id = $this->toId($id); + $key = ($id === null) ? null : $this->keys->get($id); + return ($key === null) ? [] : $key->toArray(); + } + + /** Name of a key in messages: its description, else its id */ + private static function label(ApiKey $key): string + { + return htmlspecialchars($key->description !== '' ? $key->description : (string)$key->id); + } + + /** + * Runs one write call: $work gets the log entry and returns the result for process_*_return(). + * A UserError becomes a danger message, a database error stops the call with one `mysql_error` message. + */ + private function handle(string $action, mixed $data, callable $work): bool + { + $log = ['api', $action, 'key', $data]; + try { + $result = $work($log); + } catch (UserError $e) { + $result = $this->response->error($e, $log); + } catch (PDOException $e) { + $result = $this->response->danger(['mysql_error', $e->getMessage()], $log); + } + $this->response->flush(); + return $result; + } + + /** One message per id: the success message returned by $work(id), or the UserError */ + private function batch(array $ids, array $log, callable $work): bool + { + foreach ($ids as $id) { + try { + $this->response->success($work($this->requireId($id)), $log); + } catch (UserError $e) { + $this->response->error($e, $log); + } + } + return true; + } + + /** Integer id from request input ("12", 12); null for anything else */ + private function toId(mixed $id): ?int + { + return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null; + } + + /** @throws AccessDenied for ids that cannot exist */ + private function requireId(mixed $id): int + { + return $this->toId($id) ?? throw new AccessDenied(); + } +} diff --git a/data/web/inc/lib/mailcow/ApiKey/Model/ApiKey.php b/data/web/inc/lib/mailcow/ApiKey/Model/ApiKey.php new file mode 100644 index 000000000..3b02dd24c --- /dev/null +++ b/data/web/inc/lib/mailcow/ApiKey/Model/ApiKey.php @@ -0,0 +1,64 @@ +id = isset($row['id']) ? (int)$row['id'] : null; + $key->key = (string)($row['api_key'] ?? ''); + $key->description = (string)($row['description'] ?? ''); + $key->access = (string)($row['access'] ?? self::ACCESS_RO); + $key->allowFrom = array_values(array_filter(array_map('trim', explode(',', (string)($row['allow_from'] ?? ''))), 'strlen')); + $key->skipIpCheck = (int)($row['skip_ip_check'] ?? 0); + $key->service = (int)($row['service'] ?? 0); + $key->active = (int)($row['active'] ?? 1); + $key->created = isset($row['created']) ? (string)$row['created'] : null; + $key->modified = isset($row['modified']) ? (string)$row['modified'] : null; + return $key; + } + + /** API representation (GET api-key) */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'description' => $this->description, + 'access' => $this->access, + 'api_key' => $this->key, + 'allow_from' => $this->allowFrom, + 'skip_ip_check' => $this->skipIpCheck, + 'service' => $this->service, + 'active' => $this->active, + 'created' => $this->created, + 'modified' => $this->modified, + ]; + } +} diff --git a/data/web/inc/lib/mailcow/ApiKey/Repository/ApiKeyRepository.php b/data/web/inc/lib/mailcow/ApiKey/Repository/ApiKeyRepository.php new file mode 100644 index 000000000..0f9f512f1 --- /dev/null +++ b/data/web/inc/lib/mailcow/ApiKey/Repository/ApiKeyRepository.php @@ -0,0 +1,84 @@ +query("SELECT * FROM `api` ORDER BY `service` DESC, `id`")->fetchAll(PDO::FETCH_ASSOC)); + } + + public function find(int $id): ?ApiKey + { + $row = $this->query("SELECT * FROM `api` WHERE `id` = :id", ['id' => $id])->fetch(PDO::FETCH_ASSOC); + return $row ? ApiKey::fromRow($row) : null; + } + + /** Inserts the key and returns the new id */ + public function insert(ApiKey $key): int + { + $key->id = $this->insertRow('api', $this->values($key) + ['service' => $key->service]); + return $key->id; + } + + public function update(ApiKey $key): void + { + $this->updateRow('api', (int)$key->id, $this->values($key)); + } + + public function delete(int $id): void + { + $this->query("DELETE FROM `api` WHERE `id` = :id", ['id' => $id]); + } + + /** + * Makes the service keys match $keys in one transaction: other service keys are deleted, + * an existing row with the same key (also a UI key) becomes the service key. + * + * @param ApiKey[] $keys + */ + public function syncService(array $keys): void + { + $this->transaction(function () use ($keys): void { + $params = []; + foreach (array_values($keys) as $i => $key) { + $params['k' . $i] = $key->key; + } + $keep = empty($params) ? '' : " AND `api_key` NOT IN (:" . implode(', :', array_keys($params)) . ")"; + $this->query("DELETE FROM `api` WHERE `service` = '1'" . $keep, $params); + // No INSERT ... ON DUPLICATE KEY UPDATE: it uses up an id on every php-fpm start + foreach ($keys as $key) { + $id = $this->query("SELECT `id` FROM `api` WHERE `api_key` = :api_key", ['api_key' => $key->key])->fetchColumn(); + if ($id === false) { + $this->insertRow('api', $this->values($key) + ['service' => 1]); + } else { + $this->updateRow('api', (int)$id, $this->values($key) + ['service' => 1]); + } + } + }); + } + + private function values(ApiKey $key): array + { + return [ + 'api_key' => $key->key, + 'description' => $key->description, + 'allow_from' => implode(',', $key->allowFrom), + 'skip_ip_check' => $key->skipIpCheck, + 'access' => $key->access, + 'active' => $key->active, + ]; + } +} diff --git a/data/web/inc/lib/mailcow/ApiKey/Service/ApiKeyService.php b/data/web/inc/lib/mailcow/ApiKey/Service/ApiKeyService.php new file mode 100644 index 000000000..73a518db3 --- /dev/null +++ b/data/web/inc/lib/mailcow/ApiKey/Service/ApiKeyService.php @@ -0,0 +1,249 @@ + access of the service key it defines */ + public const SERVICE_KEYS = ['API_KEY' => ApiKey::ACCESS_RW, 'API_KEY_READ_ONLY' => ApiKey::ACCESS_RO]; + + public function __construct(private ApiKeyRepository $repo, private Context $context) + { + } + + /** @throws AccessDenied unless an admin (login or read-write API key) */ + public function requireAdmin(): void + { + if (!$this->context->isAdmin() || $this->context->isReadOnlyApi()) { + throw new AccessDenied(); + } + } + + /** @return ApiKey[] all keys, service keys first; [] for other callers */ + public function all(): array + { + return $this->canRead() ? $this->repo->all() : []; + } + + public function get(int $id): ?ApiKey + { + return $this->canRead() ? $this->repo->find($id) : null; + } + + /** + * Creates a key with a random value; `access` is required. + * + * @throws UserError + */ + public function create(array $input): ApiKey + { + $this->requireAdmin(); + $key = new ApiKey(); + $key->key = self::generate(); + $this->apply($key, $input); + $this->repo->insert($key); + return $key; + } + + /** + * Updates a key; attributes missing in $input keep their value, `regen_key` issues a new key value. + * + * @throws UserError access_denied, api_key_service_locked, validation errors + */ + public function update(int $id, array $input): ApiKey + { + $this->requireAdmin(); + $key = $this->editable($id); + $this->apply($key, $input); + if (self::flag($input['regen_key'] ?? 0) === 1) { + $key->key = self::generate(); + } + $this->repo->update($key); + return $key; + } + + /** + * Deletes a key and returns it. + * + * @throws UserError access_denied, api_key_service_locked + */ + public function delete(int $id): ApiKey + { + $this->requireAdmin(); + $key = $this->editable($id); + $this->repo->delete($id); + return $key; + } + + /** + * Mirrors the service keys of mailcow.conf into the table. Runs on php-fpm start (init_db CLI, master only), + * so there is no permission check. $env holds the container environment (getenv()). + * Without a valid API_ALLOW_FROM no service key is active, like before. Service keys that were removed from + * mailcow.conf are deleted; a UI key with the same value becomes the service key. + * + * @return string[] warnings for the container log + */ + public function syncServiceKeys(array $env): array + { + $warnings = []; + $allowFrom = []; + foreach (self::split($env['API_ALLOW_FROM'] ?? '') as $network) { + // 'invalid' is the docker-compose default for an unset variable + if ($network === 'invalid') { + continue; + } + if (self::isNetwork($network)) { + $allowFrom[] = $network; + } else { + $warnings[] = 'API_ALLOW_FROM: skipped invalid IP/network ' . $network; + } + } + $keys = []; + foreach (self::SERVICE_KEYS as $var => $access) { + $value = trim((string)($env[$var] ?? '')); + if ($value === '' || $value === 'invalid') { + continue; + } + if (!preg_match('/^[a-zA-Z0-9-]+$/', $value)) { + $warnings[] = $var . ': invalid characters (allowed: a-z, A-Z, 0-9, -), key ignored'; + continue; + } + if (empty($allowFrom)) { + $warnings[] = $var . ': API_ALLOW_FROM contains no valid IP/network, key ignored'; + continue; + } + if (isset($keys[$value])) { + $warnings[] = $var . ': same value as ' . $keys[$value]->description . ', key ignored'; + continue; + } + $key = new ApiKey(); + $key->key = $value; + $key->description = $var; + $key->access = $access; + $key->allowFrom = $allowFrom; + $key->service = 1; + $keys[$value] = $key; + } + $this->repo->syncService(array_values($keys)); + return $warnings; + } + + private function canRead(): bool + { + return $this->context->isAdmin() && !$this->context->isReadOnlyApi(); + } + + /** @throws AccessDenied when missing, UserError api_key_service_locked for service keys */ + private function editable(int $id): ApiKey + { + $key = $this->repo->find($id) ?? throw new AccessDenied(); + if ($key->service === 1) { + throw new UserError('api_key_service_locked', [$key->description]); + } + return $key; + } + + /** + * Add/edit rules: attributes missing in $input keep the current value, a new key starts with the + * model defaults. Without skip_ip_check the key needs at least one IP/network. + * + * @throws UserError + */ + private function apply(ApiKey $key, array $input): void + { + if (isset($input['description'])) { + $description = Input::text($input['description']); + if ($description === null || mb_strlen($description) > 255) { + throw new UserError('api_key_description_invalid'); + } + $key->description = $description; + } + if (isset($input['access']) || $key->id === null) { + $access = $input['access'] ?? null; + if (!in_array($access, [ApiKey::ACCESS_RO, ApiKey::ACCESS_RW], true)) { + throw new UserError('api_key_access_invalid'); + } + $key->access = $access; + } + if (isset($input['allow_from'])) { + $key->allowFrom = self::networks($input['allow_from']); + } + if (isset($input['skip_ip_check'])) { + $key->skipIpCheck = self::flag($input['skip_ip_check']); + } + if (isset($input['active'])) { + $key->active = self::flag($input['active']); + } + if ($key->skipIpCheck === 0 && empty($key->allowFrom)) { + throw new UserError('ip_list_empty'); + } + } + + /** New random key in the format of the legacy UI: five groups of six hex digits */ + private static function generate(): string + { + return implode('-', array_map(fn(): string => strtoupper(bin2hex(random_bytes(3))), range(1, 5))); + } + + /** Checkbox or API flag; a hidden "0" + checkbox pair arrives as ["0", "1"] when checked */ + private static function flag(mixed $value): int + { + if (is_array($value)) { + $value = end($value); + } + return filter_var($value, FILTER_VALIDATE_BOOLEAN) ? 1 : 0; + } + + /** + * @return string[] validated IPs/networks + * @throws UserError api_key_network_invalid for the first invalid entry + */ + private static function networks(mixed $value): array + { + $networks = self::split($value); + foreach ($networks as $network) { + if (!self::isNetwork($network)) { + throw new UserError('api_key_network_invalid', [htmlspecialchars($network)]); + } + } + return $networks; + } + + /** @return string[] unique entries of a list: array or string separated by spaces, commas, semicolons or newlines */ + private static function split(mixed $value): array + { + $values = is_array($value) ? $value : preg_split('/[\s,;]+/', is_scalar($value) ? (string)$value : ''); + $values = array_map(fn(mixed $v): string => is_scalar($v) ? trim((string)$v) : '', $values); + return array_values(array_unique(array_filter($values, 'strlen'))); + } + + /** IPv4/IPv6 address or CIDR network */ + private static function isNetwork(string $value): bool + { + [$ip, $prefix] = array_pad(explode('/', $value, 2), 2, null); + if (filter_var($ip, FILTER_VALIDATE_IP) === false) { + return false; + } + if ($prefix === null) { + return true; + } + $max = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? 32 : 128; + return ctype_digit($prefix) && (int)$prefix <= $max; + } +} diff --git a/data/web/inc/lib/mailcow/Core/Context.php b/data/web/inc/lib/mailcow/Core/Context.php index 58c462c59..86f69e113 100644 --- a/data/web/inc/lib/mailcow/Core/Context.php +++ b/data/web/inc/lib/mailcow/Core/Context.php @@ -14,25 +14,32 @@ namespace Mailcow\Core; class Context { /** - * @param array $acl ACL flags of the session (user_acl / da_acl, always set for admins) - * @param bool $accessAll the session may ignore object ownership (`access_all_exception`) + * @param array $acl ACL flags of the session (user_acl / da_acl, always set for admins) + * @param bool $accessAll the session may ignore object ownership (`access_all_exception`) + * @param ?string $apiAccess 'ro' / 'rw' when the request authenticated with an X-API-Key, else null */ public function __construct( public readonly string $role, public readonly ?string $username, private readonly array $acl = [], - public readonly bool $accessAll = false + public readonly bool $accessAll = false, + public readonly ?string $apiAccess = null ) { } public static function fromSession(array $session): self { $username = $session['mailcow_cc_username'] ?? null; + $apiAccess = null; + if (($session['mailcow_cc_api'] ?? false) === true) { + $apiAccess = (($session['mailcow_cc_api_access'] ?? null) === 'rw') ? 'rw' : 'ro'; + } return new self( (string)($session['mailcow_cc_role'] ?? ''), is_scalar($username) ? (string)$username : null, is_array($session['acl'] ?? null) ? $session['acl'] : [], - ($session['access_all_exception'] ?? null) == '1' + ($session['access_all_exception'] ?? null) == '1', + $apiAccess ); } @@ -57,6 +64,12 @@ class Context return $this->role === 'user'; } + /** Request with a read-only API key (role admin, but no write access) */ + public function isReadOnlyApi(): bool + { + return $this->apiAccess === 'ro'; + } + public function hasAcl(string $name): bool { return isset($this->acl[$name]) && $this->acl[$name] == '1'; diff --git a/data/web/inc/lib/mailcow/README.md b/data/web/inc/lib/mailcow/README.md index f66eff55f..7bf2fb2e7 100644 --- a/data/web/inc/lib/mailcow/README.md +++ b/data/web/inc/lib/mailcow/README.md @@ -29,6 +29,7 @@ Core/ shared by all modules ``` Example: `Imapsync/` (sync jobs, sync sources, OAuth). Use it as the template for new modules. +`ApiKey/` (API keys incl. the service keys of mailcow.conf) is a small module with the same layout. ## Layers and rules diff --git a/data/web/inc/triggers.admin.inc.php b/data/web/inc/triggers.admin.inc.php index 92043190e..6af54b469 100644 --- a/data/web/inc/triggers.admin.inc.php +++ b/data/web/inc/triggers.admin.inc.php @@ -99,22 +99,6 @@ if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == "admi if (isset($_POST["license_validate_now"])) { license('verify'); } - if (isset($_POST["admin_api"])) { - if (isset($_POST["admin_api"]["ro"])) { - admin_api('ro', 'edit', $_POST); - } - elseif (isset($_POST["admin_api"]["rw"])) { - admin_api('rw', 'edit', $_POST); - } - } - if (isset($_POST["admin_api_regen_key"])) { - if (isset($_POST["admin_api_regen_key"]["ro"])) { - admin_api('ro', 'regen_key', $_POST); - } - elseif (isset($_POST["admin_api_regen_key"]["rw"])) { - admin_api('rw', 'regen_key', $_POST); - } - } if (isset($_POST["rspamd_ui"])) { rspamd_ui('edit', $_POST); } diff --git a/data/web/js/site/admin.js b/data/web/js/site/admin.js index 91bb0e4cf..b12015710 100644 --- a/data/web/js/site/admin.js +++ b/data/web/js/site/admin.js @@ -219,6 +219,93 @@ jQuery(function($){ ] }); } + function draw_api_keys() { + // just recalc width if instance already exists + if ($.fn.DataTable.isDataTable('#apikeystable') ) { + $('#apikeystable').DataTable().columns.adjust().responsive.recalc(); + return; + } + + $('#apikeystable').DataTable({ + responsive: true, + processing: true, + serverSide: false, + stateSave: true, + pageLength: pagination_size, + dom: "<'row'<'col-sm-12 col-md-6'f><'col-sm-12 col-md-6'l>>" + + "tr" + + "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", + language: lang_datatables, + ajax: { + type: "GET", + url: "/api/v1/get/api-key/all", + dataSrc: function(data){ + return process_table_data(data, 'apikeystable'); + } + }, + createdRow: function(row, data) { + // service keys from mailcow.conf are read-only + if (data.service == 1) $(row).addClass('opacity-75'); + }, + columns: [ + { + // placeholder, so checkbox will not block child row toggle + title: '', + data: null, + searchable: false, + orderable: false, + defaultContent: '' + }, + { + title: '', + data: 'chkbox', + searchable: false, + orderable: false, + defaultContent: '' + }, + { + title: 'ID', + data: 'id', + defaultContent: '' + }, + { + title: lang.description, + data: 'desc', + defaultContent: '' + }, + { + title: lang.access, + data: 'access_type', + defaultContent: '' + }, + { + title: lang.api_key, + data: 'key', + defaultContent: '' + }, + { + title: lang.api_networks, + data: 'networks', + defaultContent: '' + }, + { + title: lang.active, + data: 'active', + defaultContent: '', + render: function (data, type) { + if(data == 1) return '1'; + else return '0'; + } + }, + { + title: lang.action, + data: 'action', + className: 'dt-sm-head-hidden dt-text-right', + defaultContent: '' + }, + ] + }); + } function draw_admins() { // just recalc width if instance already exists if ($.fn.DataTable.isDataTable('#adminstable') ) { @@ -574,6 +661,36 @@ jQuery(function($){ item.grant_types = 'refresh_token password authorization_code'; item.chkbox = ''; }); + } else if (table == 'apikeystable') { + $.each(data, function (i, item) { + item.desc = escapeHtml(item.description); + item.access_type = (item.access == 'rw') ? + '' + lang.api_read_write + '' : + '' + lang.api_read_only + ''; + item.key = '' + escapeHtml(item.api_key) + ' ' + + ''; + if (item.skip_ip_check == 1) { + item.networks = '' + lang.api_skip_ip_check + ''; + } else { + item.networks = $.map(item.allow_from, function(n) { return escapeHtml(n); }).join('
'); + } + if (item.service == 1) { + // service keys are shown, but cannot be changed here + item.desc = ' ' + item.desc + + ' mailcow.conf'; + item.chkbox = ''; + item.action = '
' + + ' ' + lang.edit + '' + + ' ' + lang.remove + '' + + '
'; + } else { + item.chkbox = ''; + item.action = '
' + + ' ' + lang.edit + '' + + ' ' + lang.remove + '' + + '
'; + } + }); } else if (table == 'domainadminstable') { $.each(data, function (i, item) { item.selected_domains = escapeHtml(item.selected_domains); @@ -621,6 +738,7 @@ jQuery(function($){ onVisible("[id^=adminstable]", () => draw_admins()); onVisible("[id^=domainadminstable]", () => draw_domain_admins()); onVisible("[id^=oauth2clientstable]", () => draw_oauth2_clients()); + onVisible("[id^=apikeystable]", () => draw_api_keys()); onVisible("[id^=forwardinghoststable]", () => draw_fwd_hosts()); onVisible("[id^=relayhoststable]", () => draw_relayhosts()); onVisible("[id^=transportstable]", () => draw_transport_maps()); @@ -630,24 +748,14 @@ jQuery(function($){ event.stopPropagation(); }) - // API IP check toggle - $("#skip_ip_check_ro").click(function( event ) { - $("#skip_ip_check_ro").not(this).prop('checked', false); - if ($("#skip_ip_check_ro:checked").length > 0) { - $('#allow_from_ro').prop('disabled', true); - } - else { - $("#allow_from_ro").removeAttr('disabled'); - } + // API key: no allowed networks without IP check + $(".api-skip-ip-check").change(function() { + $($(this).data('target')).prop('disabled', this.checked); }); - $("#skip_ip_check_rw").click(function( event ) { - $("#skip_ip_check_rw").not(this).prop('checked', false); - if ($("#skip_ip_check_rw:checked").length > 0) { - $('#allow_from_rw').prop('disabled', true); - } - else { - $("#allow_from_rw").removeAttr('disabled'); - } + $(document).on('click', '.api-key-copy', function(e) { + e.preventDefault(); + navigator.clipboard.writeText($(this).attr('data-key')); + mailcow_alert_box(lang.copy_to_clipboard, 'success'); }); // Relayhost $('#testRelayhostModal').on('show.bs.modal', function (e) { diff --git a/data/web/js/site/edit.js b/data/web/js/site/edit.js index 308ec8c13..4f65bba2d 100644 --- a/data/web/js/site/edit.js +++ b/data/web/js/site/edit.js @@ -23,6 +23,15 @@ $(document).ready(function() { if ($("form[data-id='editalias'] .goto_checkbox:checked").length > 0) { $('#textarea_alias_goto').prop('disabled', true); } + // API key + $(".api-skip-ip-check").change(function() { + $($(this).data('target')).prop('disabled', this.checked); + }); + $(".api-key-copy").click(function() { + navigator.clipboard.writeText($(this).attr('data-key')); + mailcow_alert_box(lang_admin.copy_to_clipboard, 'success'); + }); + $(".api-key-regen").click(function() { return confirm(lang_admin.ays); }); $("#mailbox-password-warning-close").click(function( event ) { $('#mailbox-passwd-hidden-info').addClass('hidden'); diff --git a/data/web/json_api.php b/data/web/json_api.php index 71c82f1d9..d1b9807ea 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -255,6 +255,9 @@ if (isset($_GET['query'])) { case "oauth2-client": process_add_return(oauth2('add', 'client', $attr)); break; + case "api-key": + process_add_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->addKey($attr)); + break; case "domain": switch ($object) { case "template": @@ -805,6 +808,18 @@ if (isset($_GET['query'])) { } break; + case "api-key": + switch ($object) { + case "all": + process_get_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKeys()); + break; + + default: + process_get_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKey($object)); + break; + } + break; + case "logs": switch ($object) { case "dovecot": @@ -1708,6 +1723,9 @@ if (isset($_GET['query'])) { case "oauth2-client": process_delete_return(oauth2('delete', 'client', array('id' => $items))); break; + case "api-key": + process_delete_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->deleteKeys($items)); + break; case "app-passwd": process_delete_return(app_passwd('delete', array('id' => $items))); break; @@ -1873,6 +1891,9 @@ if (isset($_GET['query'])) { case "oauth2-client": process_edit_return(oauth2('edit', 'client', array_merge(array('id' => $items), $attr))); break; + case "api-key": + process_edit_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->editKeys($items, $attr)); + break; case "recipient_map": process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr))); break; diff --git a/data/web/templates/admin/tab-config-admins.twig b/data/web/templates/admin/tab-config-admins.twig index 44342af4d..2d927fbc8 100644 --- a/data/web/templates/admin/tab-config-admins.twig +++ b/data/web/templates/admin/tab-config-admins.twig @@ -87,141 +87,6 @@
- - - API - -
-
-
-
-

{{ lang.admin.api_info|raw }}

-
-
-
-
-

{{ lang.admin.cors_settings }}

-
-
-
-
- -
- -
-
-
- -
- -
-
-
-
- -
-
-
-
-
-
-
-
-
-

{{ lang.admin.api_read_only }}

-
-
-
-
- -
- -
-
-
-
- -
-
-
- -
- -
-
-
-
- -
-
-
-
-
- - -
-
-
-
-
-
-
-
-
-
-

{{ lang.admin.api_read_write }}

-
-
-
-
- -
- -
-
-
-
- -
-
-
- -
- -
-
-
-
- -
-
-
-
-
- - -
-
-
-
-
-
-
-
-
@@ -251,4 +116,64 @@ + +
+
+ + API +
+
+

{{ lang.admin.api_info|raw }}

+ + {# API keys #} + {{ lang.admin.api_keys }} +
+
+ + + {# CORS #} + {{ lang.admin.cors_settings }} +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
diff --git a/data/web/templates/edit/api-key.twig b/data/web/templates/edit/api-key.twig new file mode 100644 index 000000000..2fd3ca436 --- /dev/null +++ b/data/web/templates/edit/api-key.twig @@ -0,0 +1,71 @@ +{% extends 'edit.twig' %} + +{% block inner_content %} +{% if result %} +{% set locked = result.service == 1 %} +

{{ lang.admin.api_key }}

+{% if locked %} + +{% endif %} +
+ + +
+ +
+
+ + +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+ {% if not locked %} +
+
+
+ + +
+
+
+ {% endif %} +
+{% else %} + {{ parent() }} +{% endif %} +{% endblock %} diff --git a/data/web/templates/modals/admin.twig b/data/web/templates/modals/admin.twig index 2271ca7f5..f1d2e5d3d 100644 --- a/data/web/templates/modals/admin.twig +++ b/data/web/templates/modals/admin.twig @@ -150,6 +150,63 @@ + +