Compare commits

...
58 changed files with 1670 additions and 389 deletions

No files matched your search

+4 -4
View File
@@ -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)
@@ -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
-4
View File
@@ -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,
+284
View File
@@ -15,6 +15,10 @@ info:
- The read only key can only be used for all get endpoints
- The read write key can be used for all endpoints
You can create as many keys of both types as you need. Service keys
defined in mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) are
shown in the UI, but can only be changed in mailcow.conf.
title: mailcow API
version: "1.0.0"
@@ -206,6 +210,88 @@ paths:
type: boolean
type: object
summary: Create time limited alias
/api/v1/add/api-key:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- log:
- api
- add
- key
- description: provisioning
access: rw
allow_from: "192.0.2.10, 2001:db8::/64"
skip_ip_check: "0"
active: "1"
msg:
- api_key_added
- provisioning
type: success
schema:
properties:
log:
description: contains request object
items: {}
type: array
msg:
items: {}
type: array
type:
enum:
- success
- danger
- error
type: string
type: object
description: OK
headers: {}
tags:
- API Keys
description: >-
Create an API key with a random value (admin only, not with a read-only
API key). Get the key value with the GET method. Without
`skip_ip_check` at least one IP/network in `allow_from` is required.
operationId: Create API key
requestBody:
content:
application/json:
schema:
example:
description: provisioning
access: rw
allow_from: "192.0.2.10, 2001:db8::/64"
skip_ip_check: "0"
active: "1"
properties:
description:
description: free text, max. 255 characters
type: string
access:
description: "ro (read-only, GET endpoints only) or rw (read-write), required"
enum:
- ro
- rw
type: string
allow_from:
description: >-
IPs/CIDR networks the key may be used from, separated by
commas, spaces or newlines (or an array)
type: string
skip_ip_check:
description: allow the key from any IP
type: boolean
active:
description: is key active or not
type: boolean
type: object
summary: Create API key
/api/v1/add/app-passwd:
post:
responses:
@@ -2034,6 +2120,63 @@ paths:
type: string
type: array
summary: Delete alias
/api/v1/delete/api-key:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- log:
- api
- delete
- key
- id:
- "3"
msg:
- api_key_deleted
- provisioning
type: success
schema:
properties:
log:
description: contains request object
items: {}
type: array
msg:
items: {}
type: array
type:
enum:
- success
- danger
- error
type: string
type: object
description: OK
headers: {}
tags:
- API Keys
description: >-
Delete API keys by ID. Service keys from mailcow.conf cannot be
deleted (`api_key_service_locked`).
operationId: Delete API keys
requestBody:
content:
application/json:
schema:
example:
- "3"
properties:
items:
description: contains list of API key IDs you want to delete
type: object
type: object
summary: Delete API keys
/api/v1/delete/app-passwd:
post:
responses:
@@ -3389,6 +3532,94 @@ paths:
type: object
type: object
summary: Update alias
/api/v1/edit/api-key:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- log:
- api
- edit
- key
- id:
- "3"
regen_key: "1"
msg:
- api_key_modified
- provisioning
type: success
schema:
properties:
log:
description: contains request object
items: {}
type: array
msg:
items: {}
type: array
type:
enum:
- success
- danger
- error
type: string
type: object
description: OK
headers: {}
tags:
- API Keys
description: >-
Update API keys. Attributes you do not send keep their value,
`regen_key` replaces the key value with a new random one. Service keys
from mailcow.conf cannot be changed (`api_key_service_locked`).
operationId: Update API keys
requestBody:
content:
application/json:
schema:
example:
items:
- "3"
attr:
description: provisioning
access: ro
allow_from: "192.0.2.10"
skip_ip_check: "0"
active: "1"
regen_key: "0"
properties:
attr:
properties:
description:
description: free text, max. 255 characters
type: string
access:
enum:
- ro
- rw
type: string
allow_from:
description: IPs/CIDR networks, separated by commas, spaces or newlines (or an array)
type: string
skip_ip_check:
type: boolean
active:
type: boolean
regen_key:
description: issue a new key value
type: boolean
type: object
items:
description: contains list of API key IDs you want to update
type: object
type: object
summary: Update API keys
/api/v1/edit/domain:
post:
responses:
@@ -4827,6 +5058,57 @@ paths:
description: You can list time limited mailbox aliases existing in system.
operationId: Get time limited aliases
summary: Get time limited aliases
"/api/v1/get/api-key/{id}":
get:
parameters:
- description: id of entry you want to get
example: all
in: path
name: id
required: true
schema:
enum:
- all
- "1"
- "2"
type: string
- description: e.g. api-key-string
example: api-key-string
in: header
name: X-API-Key
required: false
schema:
type: string
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- id: 1
description: API_KEY
access: rw
api_key: 1A2B3C-4D5E6F-7A8B9C-0D1E2F-3A4B5C
allow_from:
- 192.0.2.10
skip_ip_check: 0
service: 1
active: 1
created: "2026-10-22 12:00:00"
modified: null
description: OK
headers: {}
tags:
- API Keys
description: >-
Admin only, not with a read-only API key (`{}` otherwise). Returns all
API keys incl. their values, service keys from mailcow.conf
(`service: 1`) first.
operationId: Get API keys
summary: Get API keys
"/api/v1/get/app-passwd/all/{mailbox}":
get:
parameters:
@@ -7036,6 +7318,8 @@ tags:
description: Force global TLS policys
- name: oAuth Clients
description: Use mailcow as a oAuth server
- name: API Keys
description: Manage API keys
- name: Routing
description: Define your own email routes
- name: Resources
+5
View File
@@ -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"])) {
+4
View File
@@ -10,6 +10,10 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != 'admin') {
exit();
}
// Read-only API keys must not control containers
if (!empty($_SESSION['mailcow_cc_api']) && ($_SESSION['mailcow_cc_api_access'] ?? '') != 'rw') {
exit();
}
if (preg_match('/^[a-z\-]{0,}-mailcow/', $_GET['service'])) {
if ($_GET['action'] == "start") {
+4
View File
@@ -193,6 +193,10 @@ function acl($_action, $_scope = null, $_data = null, $_extra = null) {
while ($row = array_shift($acl_all)) {
$acl['acl'][$row['Field']] = 1;
}
// Read-only API keys must not log in as other accounts (SOGo SSO, dual login)
if (!empty($_SESSION['mailcow_cc_api']) && ($_SESSION['mailcow_cc_api_access'] ?? '') != 'rw') {
$acl['acl']['login_as'] = 0;
}
}
elseif ($_SESSION['mailcow_cc_role'] == 'domainadmin' ||
(isset($_SESSION["dual-login"]["role"]) && $_SESSION["dual-login"]["role"] == 'domainadmin')) {
-122
View File
@@ -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;
+18 -2
View File
@@ -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;
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Mailcow\ApiKey;
use Mailcow\ApiKey\Controller\ApiKeyApiController;
use Mailcow\ApiKey\Repository\ApiKeyRepository;
use Mailcow\ApiKey\Service\ApiKeyService;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\Context;
use PDO;
/**
* Composition root of the API key module.
*
* process_add_return(ApiKeyModule::fromGlobals()->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 ?? []));
}
}
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Mailcow\ApiKey\Controller;
use Mailcow\ApiKey\Model\ApiKey;
use Mailcow\ApiKey\Service\ApiKeyService;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\UserError;
use PDOException;
/**
* HTTP API adapter for json_api.php and edit.php (routes `api-key`).
*
* Write methods return what json_api passes to process_*_return() and write their messages via ApiResponse.
* Messages are logged as ['api', action, 'key', data].
*/
class ApiKeyApiController
{
public function __construct(private ApiKeyService $keys, private ApiResponse $response)
{
}
/** POST add/api-key */
public function addKey(array $attr): bool
{
return $this->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/<id>, 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();
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Mailcow\ApiKey\Model;
/**
* An API key (table `api`), sent by clients in the X-API-Key header (see sessions.inc.php).
*
* Unlike other secrets the key is part of toArray(): admins copy it from the UI. ApiKeyService limits
* read access to admins (not with a read-only API key).
*/
class ApiKey
{
public const ACCESS_RO = 'ro';
public const ACCESS_RW = 'rw';
public ?int $id = null;
public string $key = '';
public string $description = '';
public string $access = self::ACCESS_RO;
/** @var string[] IPs and CIDR networks the key may be used from */
public array $allowFrom = [];
public int $skipIpCheck = 0;
/** 1 = service key from mailcow.conf: shown, but neither editable nor deletable in UI and API */
public int $service = 0;
public int $active = 1;
public ?string $created = null;
public ?string $modified = null;
/** Builds a key from an `api` row (column names as in the table) */
public static function fromRow(array $row): self
{
$key = new self();
$key->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,
];
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Mailcow\ApiKey\Repository;
use Mailcow\ApiKey\Model\ApiKey;
use Mailcow\Core\Repository;
use PDO;
/**
* SQL for the API keys (table `api`).
*/
class ApiKeyRepository extends Repository
{
/** @return ApiKey[] service keys first, then by id */
public function all(): array
{
return array_map([ApiKey::class, 'fromRow'],
$this->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,
];
}
}
@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace Mailcow\ApiKey\Service;
use Mailcow\ApiKey\Model\ApiKey;
use Mailcow\ApiKey\Repository\ApiKeyRepository;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
/**
* Use cases and rules for API keys.
*
* Admins only. A read-only API key may not even read the keys, since they include read-write keys.
* Service keys are defined in mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) and mirrored
* into the table on every php-fpm start (syncServiceKeys()). UI and API show them, but cannot change them.
*/
class ApiKeyService
{
/** mailcow.conf variable => 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;
}
}
+17 -4
View File
@@ -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';
+1
View File
@@ -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
+23
View File
@@ -67,6 +67,9 @@ if (!empty($_SERVER['HTTP_X_API_KEY'])) {
else {
$_SESSION['mailcow_cc_api_access'] = 'ro';
}
// Bind the session to this key, see the check below
$_SESSION['mailcow_cc_api_key_id'] = $api_return['id'];
$_SESSION['mailcow_cc_api_key_hash'] = hash('sha256', $api_return['api_key']);
}
else {
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for API_USER by " . $_SERVER['REMOTE_ADDR']);
@@ -92,6 +95,26 @@ if (!empty($_SERVER['HTTP_X_API_KEY'])) {
exit();
}
}
elseif (!empty($_SESSION['mailcow_cc_api'])) {
// API session without header: end it when its key was deleted, deactivated, regenerated or its access changed
if (!isset($pdo)) {
// sogo-auth.php (nginx auth_request) loads no prerequisites
$pdo = new PDO($database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name, $database_user, $database_pass, array(
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
));
}
$stmt = $pdo->prepare("SELECT `api_key`, `access` FROM `api` WHERE `id` = :id AND `active` = '1';");
$stmt->execute(array(':id' => intval($_SESSION['mailcow_cc_api_key_id'] ?? 0)));
$api_return = $stmt->fetch(PDO::FETCH_ASSOC);
if (empty($api_return['api_key']) ||
!hash_equals((string)($_SESSION['mailcow_cc_api_key_hash'] ?? ''), hash('sha256', $api_return['api_key'])) ||
$api_return['access'] != ($_SESSION['mailcow_cc_api_access'] ?? '')) {
session_unset();
session_destroy();
}
}
// Handle logouts
if (isset($_POST["logout"])) {
-16
View File
@@ -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);
}
+125 -17
View File
@@ -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 '<i class="bi bi-check-lg"><span class="sorting-value">1</span></i>';
else return '<i class="bi bi-x-lg"><span class="sorting-value">0</span></i>';
}
},
{
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 = '<input type="checkbox" class="form-check-input" data-id="oauth2_clients" name="multi_select" value="' + item.id + '" />';
});
} else if (table == 'apikeystable') {
$.each(data, function (i, item) {
item.desc = escapeHtml(item.description);
item.access_type = (item.access == 'rw') ?
'<span class="badge bg-warning text-dark">' + lang.api_read_write + '</span>' :
'<span class="badge bg-info">' + lang.api_read_only + '</span>';
item.key = '<code>' + escapeHtml(item.api_key) + '</code> ' +
'<a href="#" class="api-key-copy" data-key="' + escapeHtml(item.api_key) + '" title="' + lang.copy_to_clipboard + '"><i class="bi bi-clipboard"></i></a>';
if (item.skip_ip_check == 1) {
item.networks = '<span class="text-muted">' + lang.api_skip_ip_check + '</span>';
} else {
item.networks = $.map(item.allow_from, function(n) { return escapeHtml(n); }).join('<br>');
}
if (item.service == 1) {
// service keys are shown, but cannot be changed here
item.desc = '<i class="bi bi-lock-fill" title="' + lang.api_service_key + '"></i> ' + item.desc +
' <span class="badge bg-secondary">mailcow.conf</span>';
item.chkbox = '<input type="checkbox" class="form-check-input" disabled />';
item.action = '<div class="btn-group">' +
'<a class="btn btn-xs btn-xs-lg btn-xs-half btn-secondary disabled" aria-disabled="true"><i class="bi bi-pencil-fill"></i> ' + lang.edit + '</a>' +
'<a class="btn btn-xs btn-xs-lg btn-xs-half btn-danger disabled" aria-disabled="true"><i class="bi bi-trash"></i> ' + lang.remove + '</a>' +
'</div>';
} else {
item.chkbox = '<input type="checkbox" class="form-check-input" data-id="api_keys" name="multi_select" value="' + item.id + '" />';
item.action = '<div class="btn-group">' +
'<a href="/edit/api-key/' + encodeURI(item.id) + '" class="btn btn-xs btn-xs-lg btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + lang.edit + '</a>' +
'<a href="#" data-action="delete_selected" data-id="single-api-key" data-api-url="delete/api-key" data-item="' + encodeURI(item.id) + '" class="btn btn-xs btn-xs-lg btn-xs-half btn-danger"><i class="bi bi-trash"></i> ' + lang.remove + '</a>' +
'</div>';
}
});
} 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) {
+9
View File
@@ -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');
+21
View File
@@ -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;
-2
View File
@@ -99,7 +99,6 @@
"admin": {
"access": "Достъп",
"action": "Действие",
"activate_api": "Активиране на API",
"activate_send": "Активиране на бутона за изпращане",
"active": "Активен",
"active_rspamd_settings_map": "Активна карта с настройки",
@@ -1051,7 +1050,6 @@
"success": {
"acl_saved": "ACL за обект %s е запазен",
"admin_added": "Администраторът %s е добавен",
"admin_api_modified": "Промените в API са запазени",
"admin_modified": "Промените в администратора са запазени",
"admin_removed": "Администраторът %s е премахнат",
"alias_added": "Адресът на псевдонима %s (%d) е добавен",
-1
View File
@@ -93,7 +93,6 @@
"admin": {
"access": "Accés",
"action": "Acció",
"activate_api": "Activar API",
"active": "Actiu",
"add": "Afegir",
"add_domain_admin": "Afegir Administrador del dominio",
-2
View File
@@ -102,7 +102,6 @@
"admin": {
"access": "Přístupy",
"action": "Akce",
"activate_api": "Zapnout API",
"activate_send": "Povolit tlačítko \"Odeslat\"",
"active": "Aktivní",
"active_rspamd_settings_map": "Aktivní nastavení",
@@ -1087,7 +1086,6 @@
"success": {
"acl_saved": "ACL pro objekt %s uloženo",
"admin_added": "Správce %s přidán",
"admin_api_modified": "Změna API uložena",
"admin_modified": "Změny správce uloženy",
"admin_removed": "Správce %s odebrán",
"alias_added": "Byl přidán alias %s (%d)",
-2
View File
@@ -98,7 +98,6 @@
"admin": {
"access": "Adgang",
"action": "Handling",
"activate_api": "Aktivér API",
"activate_send": "Aktivér send knap",
"active": "Aktiv",
"active_rspamd_settings_map": "Kort over aktive indstillinger",
@@ -799,7 +798,6 @@
"success": {
"acl_saved": "ACL til objekt %s gemt",
"admin_added": "Administrator %s er tilføjet",
"admin_api_modified": "Ændringer i API er gemt",
"admin_modified": "Ændringer til administrator er gemt",
"admin_removed": "Administrator %s er blevet fjernet",
"alias_added": "Alias-adresse %s (%d) er tilføjet",
+13 -2
View File
@@ -103,7 +103,6 @@
"admin": {
"access": "Zugang",
"action": "Aktion",
"activate_api": "API aktivieren",
"activate_send": "Senden-Button freischalten",
"active": "Aktiv",
"active_rspamd_settings_map": "Derzeit aktive Settings Map",
@@ -128,6 +127,11 @@
"api_allow_from": "IP-Adressen oder Netzwerke (CIDR Notation) für Zugriff auf API",
"api_info": "Die API befindet sich noch in Entwicklung, die Dokumentation kann unter <a href=\"/api\">/api</a> abgerufen werden.",
"api_key": "API-Key",
"api_key_add": "API-Key hinzufügen",
"api_keys": "API-Keys",
"api_networks": "Erlaubte IPs/Netzwerke",
"api_service_key": "Service-Key aus der mailcow.conf, nur dort änderbar",
"api_service_key_info": "Dieser API-Key ist in der mailcow.conf definiert (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) und kann nur dort geändert werden.",
"api_skip_ip_check": "IP-Check für API nicht ausführen",
"app_hide": "Für Login verstecken",
"app_links": "App-Links",
@@ -146,6 +150,7 @@
"customer_id": "Kunde",
"customize": "UI-Anpassung",
"login_page": "Login-Seite",
"description": "Beschreibung",
"destination": "Ziel",
"dkim_add_key": "ARC/DKIM-Key hinzufügen",
"dkim_domains_selector": "Selector",
@@ -418,6 +423,10 @@
"alias_invalid": "Alias-Adresse %s ist ungültig",
"aliasd_targetd_identical": "Alias-Domain darf nicht gleich Ziel-Domain sein: %s",
"aliases_in_use": "Maximale Anzahl an Aliassen muss größer oder gleich %d sein",
"api_key_access_invalid": "Ungültige API-Zugriffsart, erlaubt sind ro und rw",
"api_key_description_invalid": "Die Beschreibung des API-Keys darf höchstens 255 Zeichen lang sein",
"api_key_network_invalid": "Ungültige IP-Adresse oder ungültiges Netzwerk: %s",
"api_key_service_locked": "API-Key %s ist in der mailcow.conf definiert und kann nur dort geändert werden",
"app_name_empty": "App-Name darf nicht leer sein",
"app_passwd_id_invalid": "App-Passwort ID %s ist ungültig",
"authsource_in_use": "Der Identity Provider kann nicht geändert oder gelöscht werden, da er derzeit von einem oder mehreren Benutzern verwendet wird.",
@@ -1122,7 +1131,6 @@
"success": {
"acl_saved": "ACL für Objekt %s wurde gesetzt",
"admin_added": "Administrator %s wurde angelegt",
"admin_api_modified": "Änderungen an API wurden gespeichert",
"admin_modified": "Änderungen am Administrator wurden gespeichert",
"admin_removed": "Administrator %s wurde entfernt",
"alias_added": "Alias-Adresse %s (%d) wurde angelegt",
@@ -1131,6 +1139,9 @@
"alias_removed": "Alias-Adresse %s wurde entfernt",
"aliasd_added": "Alias-Domain %s wurde angelegt",
"aliasd_modified": "Änderungen an Alias-Domain %s wurden gespeichert",
"api_key_added": "API-Key %s wurde hinzugefügt",
"api_key_deleted": "API-Key %s wurde entfernt",
"api_key_modified": "Änderungen an API-Key %s wurden gespeichert",
"app_links": "Änderungen an App-Links wurden gespeichert",
"app_passwd_added": "App-Passwort wurde gespeichert",
"app_passwd_removed": "App-Passwort-ID %s wurde entfernt",
+13 -2
View File
@@ -103,7 +103,6 @@
"admin": {
"access": "Access",
"action": "Action",
"activate_api": "Activate API",
"activate_send": "Activate send button",
"active": "Active",
"active_rspamd_settings_map": "Active settings map",
@@ -130,8 +129,13 @@
"api_allow_from": "Allow API access from these IPs/CIDR network notations",
"api_info": "The API is a work in progress. The documentation can be found at <a href=\"/api\">/api</a>",
"api_key": "API key",
"api_key_add": "Add API key",
"api_keys": "API keys",
"api_networks": "Allowed IPs/networks",
"api_read_only": "Read-Only Access",
"api_read_write": "Read-Write Access",
"api_service_key": "Service key from mailcow.conf, can only be changed there",
"api_service_key_info": "This API key is defined in mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) and can only be changed there.",
"api_skip_ip_check": "Skip IP check for API",
"app_hide": "Hide for login",
"app_links": "App links",
@@ -152,6 +156,7 @@
"customer_id": "Customer ID",
"customize": "Customize",
"login_page": "Login Page",
"description": "Description",
"destination": "Destination",
"dkim_add_key": "Add ARC/DKIM key",
"dkim_domains_selector": "Selector",
@@ -412,6 +417,10 @@
},
"danger": {
"access_denied": "Access denied or invalid form data",
"api_key_access_invalid": "Invalid API access type, allowed values are ro and rw",
"api_key_description_invalid": "API key description must not exceed 255 characters",
"api_key_network_invalid": "Invalid IP address or network: %s",
"api_key_service_locked": "API key %s is defined in mailcow.conf and can only be changed there",
"tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.",
"alias_domain_invalid": "Alias domain %s is invalid",
"alias_empty": "Alias address must not be empty",
@@ -1129,7 +1138,6 @@
"success": {
"acl_saved": "ACL for object %s saved",
"admin_added": "Administrator %s has been added",
"admin_api_modified": "Changes to API have been saved",
"admin_modified": "Changes to administrator have been saved",
"admin_removed": "Administrator %s has been removed",
"alias_added": "Alias address %s (%d) has been added",
@@ -1138,6 +1146,9 @@
"alias_removed": "Alias %s has been removed",
"aliasd_added": "Added alias domain %s",
"aliasd_modified": "Changes to alias domain %s have been saved",
"api_key_added": "API key %s has been added",
"api_key_deleted": "API key %s has been deleted",
"api_key_modified": "Changes to API key %s have been saved",
"app_links": "Saved changes to app links",
"app_passwd_added": "Added new app password",
"app_passwd_removed": "Removed app password ID %s",
-1
View File
@@ -98,7 +98,6 @@
"admin": {
"access": "Acceso",
"action": "Acción",
"activate_api": "Activar API",
"activate_send": "Activar botón de envío",
"active": "Activo",
"active_rspamd_settings_map": "Reglas de ajustes activos",
-2
View File
@@ -81,7 +81,6 @@
"admin": {
"access": "Hallinta",
"action": "Toiminnot",
"activate_api": "Aktivoi API",
"activate_send": "Aktivoi Lähetä-painike",
"active": "Aktiivinen",
"active_rspamd_settings_map": "Aktiiviset asetukset",
@@ -668,7 +667,6 @@
"success": {
"acl_saved": "Objektin ACL %s tallenettu",
"admin_added": "Järjestelmänvalvoja %s on lisätty",
"admin_api_modified": "API-muutokset on tallennettu",
"admin_modified": "Järjestelmänvalvojan muutokset on tallennettu",
"admin_removed": "Ylläpitäjä %s on poistettu",
"alias_added": "Alias osoite %s (%d) on lisätty",
-2
View File
@@ -102,7 +102,6 @@
"admin": {
"access": "Accès",
"action": "Action",
"activate_api": "Activer l'API",
"activate_send": "Activer le bouton d'envoi",
"active": "Actif",
"active_rspamd_settings_map": "Paramètres de mappage actifs",
@@ -1043,7 +1042,6 @@
"success": {
"acl_saved": "ACL (Access Control List) pour l'objet %s enregistré",
"admin_added": "Administrateur %s a été ajouté",
"admin_api_modified": "Les modifications apportées à l’API ont été enregistrées",
"admin_modified": "Les modifications apportées à l’administrateur ont été enregistrées",
"admin_removed": "Administrateur %s a été effacé",
"alias_added": "L'adresse alias %s (%d) a été ajoutée",
-2
View File
@@ -19,7 +19,6 @@
"upload": "Feltöltés",
"username": "Felhasználónév",
"verify": "Ellenőrzés",
"activate_api": "API aktiválása",
"activate_send": "Küldés gomb aktiválása",
"add": "Hozzáad",
"active": "Aktív",
@@ -727,7 +726,6 @@
},
"success": {
"admin_added": "Adminisztrátor %s hozzáadva",
"admin_api_modified": "APImódosítások mentve",
"admin_modified": "Adminisztrátor módosításai mentve",
"admin_removed": "Adminisztrátor %s eltávolítva",
"alias_added": "Alias cím %s (%d) hozzáadva",
-2
View File
@@ -103,7 +103,6 @@
"admin": {
"access": "Accedi",
"action": "Azione",
"activate_api": "Attiva API",
"activate_send": "Attiva bottone di invio",
"active": "Attiva",
"active_rspamd_settings_map": "Mappa delle impostazioni attive",
@@ -979,7 +978,6 @@
"success": {
"acl_saved": "ACL for object %s saved",
"admin_added": "L'amministratore %s è stato aggiunto",
"admin_api_modified": "Le modifiche alle API sono state salvate",
"admin_modified": "I cambiamenti all'amministratore sono stati salvati",
"admin_removed": "L'amministratore %s è stato rimosso",
"alias_added": "Uno o più alias sono stati correttamente aggiunti",
-2
View File
@@ -99,7 +99,6 @@
"admin": {
"access": "アクセス",
"action": "アクション",
"activate_api": "APIを有効化",
"activate_send": "送信ボタンを有効化",
"active": "アクティブ",
"active_rspamd_settings_map": "アクティブな設定マップ",
@@ -1002,7 +1001,6 @@
"success": {
"acl_saved": "オブジェクト %s のACLを保存しました",
"admin_added": "管理者 %s が追加されました",
"admin_api_modified": "APIの変更が保存されました",
"admin_modified": "管理者の変更が保存されました",
"admin_removed": "管理者 %s が削除されました",
"alias_added": "エイリアスアドレス %s (%d) が追加されました",
-2
View File
@@ -98,7 +98,6 @@
"admin": {
"access": "접근",
"action": "동작",
"activate_api": "API 활성화",
"activate_send": "발신 버튼 활성화",
"active": "활성화",
"active_rspamd_settings_map": "설정 맵 활성화",
@@ -854,7 +853,6 @@
"success": {
"acl_saved": "ACL for object %s saved",
"admin_added": "Administrator %s has been added",
"admin_api_modified": "Changes to API have been saved",
"admin_modified": "Changes to administrator have been saved",
"admin_removed": "Administrator %s has been removed",
"alias_added": "Alias address %s (%d) has been added",
-1
View File
@@ -79,7 +79,6 @@
"admin": {
"access": "Pieeja",
"action": "Rīcība",
"activate_api": "Aktivizēt API",
"active": "Aktīvs",
"add": "Pievienot",
"add_domain_admin": "Pievienot domēna pārvaldītāju",
-1
View File
@@ -27,7 +27,6 @@
"access": "Tilgang",
"add_domain_admin": "Legg til domeneadministrator",
"add_forwarding_host": "Legg til videresendingsvert",
"activate_api": "Aktivér API",
"add_relayhost_hint": "Vennligst vær oppmerksom på at autentiseringsdata, om noe, vil bli lagret i klartekst.",
"app_links": "App-lenker",
"app_name": "App-navn",
-2
View File
@@ -100,7 +100,6 @@
"admin": {
"access": "Toegang",
"action": "Handeling",
"activate_api": "Activeer API",
"activate_send": "Bevestig bovenstaande gegevens",
"active": "Actief",
"active_rspamd_settings_map": "Huidige instellingen",
@@ -1035,7 +1034,6 @@
"success": {
"acl_saved": "Toegangscontrole voor object %s is opgeslagen",
"admin_added": "Administrator %s is toegevoegd",
"admin_api_modified": "Wijzigingen aan de API zijn opgeslagen",
"admin_modified": "Wijzigingen aan administrator zijn opgeslagen",
"admin_removed": "Administrator %s is verwijderd",
"alias_added": "Aliasadres %s (%d) is toegevoegd",
-2
View File
@@ -153,7 +153,6 @@
"time": "Czas",
"unchanged_if_empty": "W przypadku braku zmian, nie wypełniaj",
"username": "Nazwa użytkownika",
"activate_api": "Aktywuj API",
"activate_send": "Aktywuj przycisk wysyłania",
"active_rspamd_settings_map": "Aktywne ustawienia mapy",
"add_admin": "Dodaj administratora",
@@ -919,7 +918,6 @@
"verified_yotp_login": "Zweryfikowany login Yubico OTP",
"acl_saved": "ACL dla obiektu %s zapisany",
"admin_added": "Administrator %s został dodany",
"admin_api_modified": "Zmiany w API zostały zapisane",
"admin_removed": "Administrator %s został usunięty",
"app_links": "Zapisane zmiany w linkach aplikacji",
"app_passwd_added": "Dodano nowe hasło aplikacji",
-2
View File
@@ -102,7 +102,6 @@
"admin": {
"access": "Acesso",
"action": "Ação",
"activate_api": "Ativar API",
"activate_send": "Ativar botão de envio",
"active": "Ativo",
"active_rspamd_settings_map": "Mapa de configurações ativas",
@@ -1092,7 +1091,6 @@
"success": {
"acl_saved": "ACL para o objeto %s salvo",
"admin_added": "O administrador %s foi adicionado",
"admin_api_modified": "As alterações na API foram salvas",
"admin_modified": "As alterações no administrador foram salvas",
"admin_removed": "O administrador %s foi removido",
"alias_added": "O endereço de alias %s (%d) foi adicionado",
-1
View File
@@ -84,7 +84,6 @@
"customize": "Personalizar",
"destination": "Destino",
"dkim_from": "De",
"activate_api": "Ativar API",
"add_admin": "Adicionar administrador",
"admins": "Administradores",
"advanced_settings": "Configurações avançadas",
-2
View File
@@ -98,7 +98,6 @@
"admin": {
"access": "Acces",
"action": "Acțiune",
"activate_api": "Activează API",
"activate_send": "Activează butonul de trimitere",
"active": "Activ",
"active_rspamd_settings_map": "Harta setărilor active",
@@ -889,7 +888,6 @@
"success": {
"acl_saved": "ACL pentru obiectul %s a fost salvat",
"admin_added": "Administratorul %s a fost adăugat",
"admin_api_modified": "Modificările API-ului au fost salvate",
"admin_modified": "Modificările administratorului au fost salvate",
"admin_removed": "Administratorul %s a fost eliminat",
"alias_added": "Adresa alias %s a fost adăugată",
-2
View File
@@ -102,7 +102,6 @@
"admin": {
"access": "Настройки доступа",
"action": "Действия",
"activate_api": "Включить API",
"activate_send": "Активировать кнопку отправки",
"active": "Активный",
"active_rspamd_settings_map": "Отобразить текущие правила",
@@ -1088,7 +1087,6 @@
"success": {
"acl_saved": "ACL для %s сохранено",
"admin_added": "Администратор %s добавлен",
"admin_api_modified": "Изменения в API сохранены",
"admin_modified": "Изменения администратора сохранены",
"admin_removed": "Администратор %s удалён",
"alias_added": "Псевдоним %s (%d) добавлен",
-2
View File
@@ -103,7 +103,6 @@
"admin": {
"access": "Dostop",
"action": "Dejanje",
"activate_api": "Aktiviraj API",
"activate_send": "Aktiviraj gumb \"Pošlji\"",
"active": "Aktivno",
"active_rspamd_settings_map": "Aktivna preslikava nastavitev",
@@ -1100,7 +1099,6 @@
"relayhost_removed": "Vnos na zemljevidu %s je bil odstranjen",
"acl_saved": "ACL za objekt %s shranjen",
"admin_added": "Dodan je bil skrbnik %s",
"admin_api_modified": "Spremembe API-ja so bile shranjene",
"admin_modified": "Spremembe skrbnika so bile shranjene",
"admin_removed": "Administrator %s je bil odstranjen",
"alias_domain_removed": "Vzdevek domene %s je bil odstranjen",
-2
View File
@@ -98,7 +98,6 @@
"admin": {
"access": "Prístup",
"action": "Akcia",
"activate_api": "Aktivovať API",
"activate_send": "Aktivovať tlačidlo odoslania",
"active": "Aktívny",
"active_rspamd_settings_map": "Aktívne nastavenia",
@@ -935,7 +934,6 @@
"success": {
"acl_saved": "ACL pre objekt %s uložený",
"admin_added": "Administrátor %s pridaný",
"admin_api_modified": "Zmeny v API uložené",
"admin_modified": "Zmeny v administrácii uložené",
"admin_removed": "Administrátor %s odstránený ",
"alias_added": "Alias adresa %s (%d) pridaná",
-2
View File
@@ -97,7 +97,6 @@
"admin": {
"access": "Åtkomst",
"action": "Åtgärd",
"activate_api": "Aktivera API:et",
"activate_send": "Aktivera skicka-knappen",
"active": "Aktiv",
"active_rspamd_settings_map": "Nuvarande inställningsprofil",
@@ -818,7 +817,6 @@
"success": {
"acl_saved": "Åtkomstlistan för objektet %s har sparats",
"admin_added": "Administratören %s har lagts till",
"admin_api_modified": "Ändringarna på API:et har sparats",
"admin_modified": "Ändringarna för administratören har sparats",
"admin_removed": "Administratören %s har tagits bort",
"alias_added": "Aliasadressen %s (%d) har lagts till",
-2
View File
@@ -196,7 +196,6 @@
"f2b_list_info": "Kara listeye alınmış bir ana bilgisayar veya ağ, her zaman beyaz listedeki bir varlıktan daha ağır basacaktır. <b>Liste güncellemelerinin uygulanması birkaç saniye sürecektir.</b>",
"ip_check": "IP Kontrol",
"access": "Erişim",
"activate_api": "API etkinleştir",
"activate_send": "Gönder düğmesini etkinleştir",
"active": "Etkinleştir",
"active_rspamd_settings_map": "Aktif ayarlar haritası",
@@ -895,7 +894,6 @@
"template_added": "%s şablonu eklendi",
"acl_saved": "%s nesnesi için ACL kaydedildi",
"admin_added": "%s yöneticisi eklendi",
"admin_api_modified": "API'deki değişiklikler kaydedildi",
"admin_modified": "Yönetici değişiklikleri kaydedildi",
"admin_removed": "%s yöneticisi kaldırıldı",
"alias_domain_removed": "Takma ad alan adı %s kaldırıldı",
-2
View File
@@ -276,7 +276,6 @@
"validate_license_now": "Перевірити ліцензію на основі GUID із сервера ліцензій",
"verify": "Перевірити",
"yes": "&#10003;",
"activate_api": "Увімкнути API",
"active_rspamd_settings_map": "Відобразити поточні правила",
"add_relayhost": "Додавання проміжного вузла",
"add_relayhost_hint": "Будь ласка, майте на увазі, що дані для автентифікації зберігатимуться у вигляді звичайного тексту.",
@@ -954,7 +953,6 @@
"success": {
"acl_saved": "ACL для %s збережено",
"admin_added": "Адміністратора %s додано",
"admin_api_modified": "Зміни в API збережено",
"admin_modified": "Зміни адміністратора збережено",
"admin_removed": "Адміністратора %s видалено",
"alias_added": "Псевдонім %s (%d) додано",
-2
View File
@@ -102,7 +102,6 @@
"admin": {
"access": "Kirish",
"action": "Amal",
"activate_api": "API ni faollashtirish",
"activate_send": "Joʻnatish tugmasini faollashtirish",
"active": "Faol",
"active_rspamd_settings_map": "Faol sozlamalar xaritasi",
@@ -1090,7 +1089,6 @@
"success": {
"acl_saved": "%s obyekti uchun ACL saqlandi",
"admin_added": "Administrator %s qoʻshildi",
"admin_api_modified": "API ga oʻzgarishlar saqlandi",
"admin_modified": "Administrator oʻzgarishlari saqlandi",
"admin_removed": "Administrator %s olib tashlandi",
"alias_added": "Taxallus manzili %s (%d) qoʻshildi",
-1
View File
@@ -102,7 +102,6 @@
"admin": {
"access": "Truy cập",
"action": "Hành động",
"activate_api": "Kích hoạt API",
"activate_send": "Kích hoạt nút gửi",
"active": "Đang hoạt động",
"active_rspamd_settings_map": "Ánh xạ cài đặt đang hoạt động",
-2
View File
@@ -103,7 +103,6 @@
"admin": {
"access": "权限管理",
"action": "操作",
"activate_api": "启用 API",
"activate_send": "启用发送按钮",
"active": "启用",
"active_rspamd_settings_map": "启用的规则",
@@ -1067,7 +1066,6 @@
"success": {
"acl_saved": "已保存对象 %s 的 ACL 设置",
"admin_added": "已添加管理员 %s",
"admin_api_modified": "已保存 API 的更改",
"admin_modified": "已保存管理员更改",
"admin_removed": "已删除管理员 %s",
"alias_added": "已添加别名地址 %s (%d)",
-2
View File
@@ -99,7 +99,6 @@
"admin": {
"access": "存取",
"action": "動作",
"activate_api": "啟用 API",
"activate_send": "啟用發送按鈕",
"active": "啟用",
"active_rspamd_settings_map": "啟用設定規則",
@@ -952,7 +951,6 @@
"success": {
"acl_saved": "%s 的存取權限表已儲存",
"admin_added": "管理員 %s 已新增",
"admin_api_modified": "API 的更改已儲存",
"admin_modified": "管理員更改已儲存",
"admin_removed": "管理員 %s 已刪除",
"alias_added": "別名地址 %s (%d) 已新增",
+60 -135
View File
@@ -87,141 +87,6 @@
</div>
<br>
</div>
<legend style="cursor:pointer;margin-top:20px" data-bs-target="#admin_api" unselectable="on" data-bs-toggle="collapse">
<i style="font-size:10pt;" class="bi bi-plus-square"></i> API
</legend>
<hr />
<div id="admin_api" class="collapse">
<div class="row">
<div class="col-lg-12">
<p class="text-muted">{{ lang.admin.api_info|raw }}</p>
</div>
<div class="col-lg-12">
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title"><i class="bi bi-file-earmark-arrow-down"></i> {{ lang.admin.cors_settings }}</h4>
</div>
<div class="card-body">
<form class="form-horizontal" autocapitalize="none" autocorrect="off" role="form" data-id="editcors" method="post">
<div class="row mb-4">
<label class="control-label col-sm-2 mb-4" for="allowed_origins">{{ lang.admin.allowed_origins }}</label>
<div class="col-sm-9 mb-4">
<textarea class="form-control textarea-code" rows="7" name="allowed_origins" id="allowed_origins">{{ cors_settings.allowed_origins }}</textarea>
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-2" for="allowed_methods">{{ lang.admin.allowed_methods }}</label>
<div class="col-sm-9">
<select name="allowed_methods" id="allowed_methods" multiple class="form-control">
<option value="POST"{% if "POST" in cors_settings.allowed_methods %} selected{% endif %}>POST</option>
<option value="GET"{% if "GET" in cors_settings.allowed_methods %} selected{% endif %}>GET</option>
<option value="DELETE"{% if "DELETE" in cors_settings.allowed_methods %} selected{% endif %}>DELETE</option>
<option value="PUT"{% if "PUT" in cors_settings.allowed_methods %} selected{% endif %}>PUT</option>
</select>
</div>
</div>
<div class="row mb-4">
<div class="offset-sm-2 col-sm-9 d-grid d-sm-block">
<button class="btn btn-sm btn-xs-lg btn-success" data-item="cors" data-api-url="edit/cors" data-id="editcors" data-action="edit_selected" href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title"><i class="bi bi-file-earmark-arrow-down"></i> {{ lang.admin.api_read_only }}</h4>
</div>
<div class="card-body">
<form class="form-horizontal" autocapitalize="none" autocorrect="off" role="form" method="post">
<div class="row mb-4">
<label class="control-label col-sm-3" for="allow_from_ro">{{ lang.admin.api_allow_from }}:</label>
<div class="col-sm-9">
<textarea class="form-control textarea-code" rows="7" name="allow_from" id="allow_from_ro" {% if api.ro.skip_ip_check %}disabled{% endif %} required>{{ api.ro.allow_from }}</textarea>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<label>
<input type="checkbox" class="form-check-input" name="skip_ip_check" id="skip_ip_check_ro" {% if api.ro.skip_ip_check %}checked{% endif %}> {{ lang.admin.api_skip_ip_check }}
</label>
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-3">{{ lang.admin.api_key }}:</label>
<div class="col-sm-9">
<input type="text" class="form-control" value="{{ api.ro.api_key|default('-') }}" readonly />
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<label>
<input type="checkbox" class="form-check-input" name="active" {% if api.ro.active %}checked{% endif %}> {{ lang.admin.activate_api }}
</label>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<div class="btn-group">
<button class="btn btn-sm btn-xs-lg btn-xs-half d-block d-sm-inline btn-success" name="admin_api[ro]" type="submit" href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
<button class="btn btn-sm btn-xs-lg btn-xs-half d-block d-sm-inline btn-secondary admin-ays-dialog" name="admin_api_regen_key[ro]" type="submit" href="#" {% if not api.ro.api_key %}disabled{% endif %}>{{ lang.admin.regen_api_key }}</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title"><i class="bi bi-file-earmark-diff"></i> {{ lang.admin.api_read_write }}</h4>
</div>
<div class="card-body">
<form class="form-horizontal" autocapitalize="none" autocorrect="off" role="form" method="post">
<div class="row mb-4">
<label class="control-label col-sm-3" for="allow_from_rw">{{ lang.admin.api_allow_from }}:</label>
<div class="col-sm-9">
<textarea class="form-control textarea-code" rows="7" name="allow_from" id="allow_from_rw" {% if api.rw.skip_ip_check %}disabled{% endif %} required>{{ api.rw.allow_from }}</textarea>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<label>
<input type="checkbox" class="form-check-input" name="skip_ip_check" id="skip_ip_check_rw" {% if api.rw.skip_ip_check %}checked{% endif %}> {{ lang.admin.api_skip_ip_check }}
</label>
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-3" for="admin_api_key">{{ lang.admin.api_key }}:</label>
<div class="col-sm-9">
<input type="text" class="form-control" value="{{ api.rw.api_key|default('-') }}" readonly />
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<label>
<input type="checkbox" class="form-check-input" name="active" {% if api.rw.active %}checked{% endif %}> {{ lang.admin.activate_api }}
</label>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<div class="btn-group">
<button class="btn btn-sm btn-xs-half d-block d-sm-inline btn-success" name="admin_api[rw]" type="submit" href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
<button class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary admin-ays-dialog" name="admin_api_regen_key[rw]" type="submit" {% if not api.rw.api_key %}disabled{% endif %} href="#">{{ lang.admin.regen_api_key }}</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -251,4 +116,64 @@
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header d-flex fs-5">
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-api" data-bs-toggle="collapse" aria-controls="collapse-tab-config-api">
API
</button>
<span class="d-none d-md-block">API</span>
</div>
<div id="collapse-tab-config-api" class="card-body collapse" data-bs-parent="#admin-content">
<p class="text-muted">{{ lang.admin.api_info|raw }}</p>
{# API keys #}
<legend style="margin-top:20px"><i class="bi bi-key"></i> {{ lang.admin.api_keys }}</legend>
<hr />
<table id="apikeystable" class="table table-striped dt-responsive w-100"></table>
<div class="mass-actions-admin mb-4">
<div class="btn-group">
<a class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary" id="toggle_multi_select_all" data-id="api_keys" href="#"><i class="bi bi-check-all"></i> {{ lang.mailbox.toggle_all }}</a>
<a class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary dropdown-toggle" data-bs-toggle="dropdown" href="#">{{ lang.mailbox.quick_actions }}</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" data-action="edit_selected" data-id="api_keys" data-api-url='edit/api-key' data-api-attr='{"active":"1"}' href="#">{{ lang.mailbox.activate }}</a></li>
<li><a class="dropdown-item" data-action="edit_selected" data-id="api_keys" data-api-url='edit/api-key' data-api-attr='{"active":"0"}' href="#">{{ lang.mailbox.deactivate }}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item admin-ays-dialog" data-action="edit_selected" data-id="api_keys" data-api-url='edit/api-key' data-api-attr='{"regen_key":"1"}' href="#">{{ lang.admin.regen_api_key }}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" data-action="delete_selected" data-id="api_keys" data-api-url='delete/api-key' href="#">{{ lang.mailbox.remove }}</a></li>
</ul>
<a class="btn btn-sm d-block d-sm-inline btn-success" data-id="add_api_key" data-bs-toggle="modal" data-bs-target="#addApiKeyModal" href="#"><i class="bi bi-plus-lg"></i> {{ lang.admin.api_key_add }}</a>
</div>
</div>
{# CORS #}
<legend style="margin-top:20px"><i class="bi bi-file-earmark-arrow-down"></i> {{ lang.admin.cors_settings }}</legend>
<hr />
<form class="form-horizontal" autocapitalize="none" autocorrect="off" role="form" data-id="editcors" method="post">
<div class="row mb-4">
<label class="control-label col-sm-2" for="allowed_origins">{{ lang.admin.allowed_origins }}</label>
<div class="col-sm-9">
<textarea class="form-control textarea-code" rows="7" name="allowed_origins" id="allowed_origins">{{ cors_settings.allowed_origins }}</textarea>
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-2" for="allowed_methods">{{ lang.admin.allowed_methods }}</label>
<div class="col-sm-9">
<select name="allowed_methods" id="allowed_methods" multiple class="form-control">
<option value="POST"{% if "POST" in cors_settings.allowed_methods %} selected{% endif %}>POST</option>
<option value="GET"{% if "GET" in cors_settings.allowed_methods %} selected{% endif %}>GET</option>
<option value="DELETE"{% if "DELETE" in cors_settings.allowed_methods %} selected{% endif %}>DELETE</option>
<option value="PUT"{% if "PUT" in cors_settings.allowed_methods %} selected{% endif %}>PUT</option>
</select>
</div>
</div>
<div class="row">
<div class="offset-sm-2 col-sm-9 d-grid d-sm-block">
<button class="btn btn-sm btn-xs-lg btn-success" data-item="cors" data-api-url="edit/cors" data-id="editcors" data-action="edit_selected" href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
</div>
</div>
</form>
</div>
</div>
</div>
+71
View File
@@ -0,0 +1,71 @@
{% extends 'edit.twig' %}
{% block inner_content %}
{% if result %}
{% set locked = result.service == 1 %}
<h4>{{ lang.admin.api_key }}</h4>
{% if locked %}
<div class="alert alert-secondary" role="alert"><i class="bi bi-lock-fill"></i> {{ lang.admin.api_service_key_info }}</div>
{% endif %}
<form data-id="edit_api_key" class="form-horizontal" role="form" method="post" autocapitalize="none" autocorrect="off">
<input type="hidden" value="0" name="skip_ip_check">
<input type="hidden" value="0" name="active">
<div class="row mb-2">
<label class="control-label col-sm-2" for="api_key">{{ lang.admin.api_key }}</label>
<div class="col-sm-10">
<div class="input-group">
<input type="text" class="form-control textarea-code" id="api_key" value="{{ result.api_key }}" readonly>
<button type="button" class="btn btn-secondary api-key-copy" data-key="{{ result.api_key }}"><i class="bi bi-clipboard"></i></button>
</div>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2" for="description">{{ lang.admin.description }}</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="description" id="description" value="{{ result.description }}" maxlength="255"{% if locked %} disabled{% endif %}>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2" for="access">{{ lang.admin.access }}</label>
<div class="col-sm-10">
<select class="form-control" name="access" id="access"{% if locked %} disabled{% endif %}>
<option value="ro"{% if result.access == 'ro' %} selected{% endif %}>{{ lang.admin.api_read_only }}</option>
<option value="rw"{% if result.access == 'rw' %} selected{% endif %}>{{ lang.admin.api_read_write }}</option>
</select>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2" for="allow_from">{{ lang.admin.api_allow_from }}</label>
<div class="col-sm-10">
<textarea class="form-control textarea-code" rows="5" name="allow_from" id="allow_from"{% if locked or result.skip_ip_check == 1 %} disabled{% endif %}>{{ result.allow_from|join("\n") }}</textarea>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input api-skip-ip-check" value="1" name="skip_ip_check" data-target="#allow_from"{% if result.skip_ip_check == 1 %} checked{% endif %}{% if locked %} disabled{% endif %}> {{ lang.admin.api_skip_ip_check }}</label>
</div>
</div>
</div>
<div class="row mb-4">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="active"{% if result.active == 1 %} checked{% endif %}{% if locked %} disabled{% endif %}> {{ lang.edit.active }}</label>
</div>
</div>
</div>
{% if not locked %}
<div class="row">
<div class="offset-sm-2 col-sm-10">
<div class="btn-group">
<button class="btn btn-xs-lg btn-xs-half d-block d-sm-inline btn-success" data-action="edit_selected" data-id="edit_api_key" data-item="{{ result.id }}" data-api-url='edit/api-key' data-api-attr='{}' href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
<button class="btn btn-xs-lg btn-xs-half d-block d-sm-inline btn-secondary api-key-regen" data-action="edit_selected" data-id="regen_api_key" data-item="{{ result.id }}" data-api-url='edit/api-key' data-api-attr='{"regen_key":"1"}' href="#"><i class="bi bi-arrow-repeat"></i> {{ lang.admin.regen_api_key }}</button>
</div>
</div>
</div>
{% endif %}
</form>
{% else %}
{{ parent() }}
{% endif %}
{% endblock %}
+57
View File
@@ -150,6 +150,63 @@
</div>
</div>
</div><!-- add domain admin modal -->
<!-- add api key modal -->
<div class="modal fade" id="addApiKeyModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">{{ lang.admin.api_key_add }}</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form class="form-horizontal" data-id="add_api_key" role="form" method="post" autocapitalize="none" autocorrect="off">
<input type="hidden" value="0" name="skip_ip_check">
<input type="hidden" value="0" name="active">
<div class="row mb-2">
<label class="control-label col-sm-2 text-sm-end" for="add_api_description">{{ lang.admin.description }}:</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="description" id="add_api_description" maxlength="255">
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2 text-sm-end" for="add_api_access">{{ lang.admin.access }}:</label>
<div class="col-sm-10">
<select class="form-control" name="access" id="add_api_access">
<option value="ro">{{ lang.admin.api_read_only }}</option>
<option value="rw">{{ lang.admin.api_read_write }}</option>
</select>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2 text-sm-end" for="add_api_allow_from">{{ lang.admin.api_allow_from }}:</label>
<div class="col-sm-10">
<textarea class="form-control textarea-code" rows="5" name="allow_from" id="add_api_allow_from" required></textarea>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input api-skip-ip-check" value="1" name="skip_ip_check" data-target="#add_api_allow_from"> {{ lang.admin.api_skip_ip_check }}</label>
</div>
</div>
</div>
<div class="row mb-4">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="active" checked> {{ lang.admin.active }}</label>
</div>
</div>
</div>
<div class="row mb-4">
<div class="offset-sm-2 col-sm-10">
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="add_item" data-id="add_api_key" data-api-url='add/api-key' data-api-attr='{}' href="#"><i class="bi bi-plus-lg"></i> {{ lang.admin.add }}</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div><!-- add api key modal -->
<!-- add admin modal -->
<div class="modal fade" id="addAdminModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-xl">
+2 -2
View File
@@ -403,8 +403,8 @@ IPV6_NETWORK=fd4d:6169:6c63:6f77::/64
# Use this IPv6 for outgoing connections (SNAT)
#SNAT6_TO_SOURCE=
# Create or override an API key for the web UI
# You _must_ define API_ALLOW_FROM, which is a comma separated list of IPs
# Service API keys: shown in the web UI, but can only be changed here (more keys can be added in the UI)
# You _must_ define API_ALLOW_FROM, which is a comma separated list of IPs/networks
# An API key defined as API_KEY has read-write access
# An API key defined as API_KEY_READ_ONLY has read-only access
# Allowed chars for API_KEY and API_KEY_READ_ONLY: a-z, A-Z, 0-9, -
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\ApiKey\Controller;
use Mailcow\ApiKey\Controller\ApiKeyApiController;
use Mailcow\ApiKey\Model\ApiKey;
use Mailcow\ApiKey\Service\ApiKeyService;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\UserError;
use PDOException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ApiKeyApiControllerTest extends TestCase
{
private ApiKeyService&MockObject $keys;
private ApiKeyApiController $api;
protected function setUp(): void
{
$_SESSION = [];
$this->keys = $this->createMock(ApiKeyService::class);
$this->api = new ApiKeyApiController($this->keys, new ApiResponse(['api_key']));
}
private static function key(int $id, string $description = ''): ApiKey
{
return ApiKey::fromRow(['id' => $id, 'api_key' => 'KEY-' . $id, 'description' => $description, 'access' => 'rw',
'allow_from' => '192.0.2.1']);
}
/** Messages written to the session as [type, msg] */
private function messages(): array
{
return array_map(fn(array $m): array => [$m['type'], $m['msg']], $_SESSION['return'] ?? []);
}
public function testAddLogsWithoutKeyAndNamesTheKey(): void
{
$this->keys->method('create')->willReturn(self::key(4, '<crm>'));
$this->assertTrue($this->api->addKey(['access' => 'rw', 'api_key' => 'chosen']));
$this->assertSame([[
'type' => 'success',
'log' => ['api', 'add', 'key', ['access' => 'rw', 'api_key' => '*']],
'msg' => ['api_key_added', '&lt;crm&gt;'],
]], $_SESSION['return']);
}
public function testErrorsBecomeDangerMessages(): void
{
$this->keys->method('create')->willReturnOnConsecutiveCalls(
$this->throwException(new UserError('ip_list_empty')),
$this->throwException(new PDOException('gone away'))
);
$this->assertFalse($this->api->addKey([]));
$this->assertFalse($this->api->addKey([]));
$this->assertSame([['danger', 'ip_list_empty'], ['danger', ['mysql_error', 'gone away']]], $this->messages());
}
public function testEditReportsEachIdAndSkipsInvalidIds(): void
{
$this->keys->method('update')->willReturnCallback(fn(int $id, array $attr): ApiKey => ($id === 2)
? throw new UserError('api_key_service_locked', ['API_KEY']) : self::key($id));
$this->assertTrue($this->api->editKeys(['1', '2', 'x'], ['active' => '0']));
$this->assertSame([
['success', ['api_key_modified', '1']],
['danger', ['api_key_service_locked', 'API_KEY']],
['danger', 'access_denied'],
], $this->messages());
}
public function testEditAndDeleteNeedAdminOnce(): void
{
$this->keys->method('requireAdmin')->willThrowException(new AccessDenied());
$this->keys->expects($this->never())->method('update');
$this->keys->expects($this->never())->method('delete');
$this->assertFalse($this->api->editKeys(['1', '2'], []));
$this->assertFalse($this->api->deleteKeys(['1', '2']));
$this->assertSame([['danger', 'access_denied'], ['danger', 'access_denied']], $this->messages());
}
public function testDelete(): void
{
$this->keys->expects($this->once())->method('delete')->with(5)->willReturn(self::key(5, 'old'));
$this->assertTrue($this->api->deleteKeys(['5']));
$this->assertSame([['success', ['api_key_deleted', 'old']]], $this->messages());
}
public function testGetReturnsApiShape(): void
{
$this->keys->method('all')->willReturn([self::key(1, 'crm')]);
$this->keys->method('get')->willReturnMap([[1, self::key(1, 'crm')], [2, null]]);
$this->assertSame([[
'id' => 1, 'description' => 'crm', 'access' => 'rw', 'api_key' => 'KEY-1', 'allow_from' => ['192.0.2.1'],
'skip_ip_check' => 0, 'service' => 0, 'active' => 1, 'created' => null, 'modified' => null,
]], $this->api->getKeys());
$this->assertSame('KEY-1', $this->api->getKey('1')['api_key']);
$this->assertSame([], $this->api->getKey('2'));
$this->assertSame([], $this->api->getKey('1.5'));
$this->assertSame([], $this->api->getKey('all'));
}
}
@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\ApiKey\Service;
use Mailcow\ApiKey\Model\ApiKey;
use Mailcow\ApiKey\Repository\ApiKeyRepository;
use Mailcow\ApiKey\Service\ApiKeyService;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\Tests\Support\FakeContext;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ApiKeyServiceTest extends TestCase
{
private const KEY_FORMAT = '/^([0-9A-F]{6}-){4}[0-9A-F]{6}$/';
private ApiKeyRepository&MockObject $repo;
protected function setUp(): void
{
$this->repo = $this->createMock(ApiKeyRepository::class);
}
private function service(?Context $context = null): ApiKeyService
{
return new ApiKeyService($this->repo, $context ?? FakeContext::admin());
}
private static function apiContext(string $access): Context
{
return new Context('admin', 'API', [], false, $access);
}
private static function stored(array $row = []): ApiKey
{
return ApiKey::fromRow($row + ['id' => 3, 'api_key' => 'AAAAAA-BBBBBB-CCCCCC-DDDDDD-EEEEEE', 'description' => 'crm',
'access' => 'ro', 'allow_from' => '192.0.2.1,2001:db8::/64', 'skip_ip_check' => 0, 'service' => 0, 'active' => 1]);
}
private function expectUserError(string|array $msg, callable $call): void
{
try {
$call();
$this->fail('UserError ' . json_encode($msg) . ' expected');
} catch (UserError $e) {
$this->assertSame($msg, $e->msg());
}
}
public static function deniedCallers(): array
{
return [
'domain admin' => [FakeContext::domainAdmin()],
'user' => [FakeContext::user()],
'system' => [Context::system()],
'read-only API key' => [self::apiContext('ro')],
];
}
#[DataProvider('deniedCallers')]
public function testOnlyAdminsMayReadOrWrite(Context $context): void
{
$this->repo->expects($this->never())->method('all');
$this->repo->expects($this->never())->method('insert');
$service = $this->service($context);
$this->assertSame([], $service->all());
$this->assertNull($service->get(3));
$this->expectUserError('access_denied', fn() => $service->create(['access' => 'ro', 'skip_ip_check' => '1']));
$this->expectUserError('access_denied', fn() => $service->delete(3));
}
public function testReadWriteApiKeyMayManageKeys(): void
{
$this->repo->method('all')->willReturn([self::stored()]);
$this->assertCount(1, $this->service(self::apiContext('rw'))->all());
}
public function testCreateGeneratesKeyAndNormalisesNetworks(): void
{
$this->repo->expects($this->once())->method('insert')->with($this->callback(function (ApiKey $key): bool {
$this->assertMatchesRegularExpression(self::KEY_FORMAT, $key->key);
$this->assertSame(['192.0.2.1', '2001:db8::/64', '10.0.0.0/8'], $key->allowFrom);
$this->assertSame('rw', $key->access);
$this->assertSame('crm', $key->description);
$this->assertSame(0, $key->service);
// Hidden "0" + checked checkbox
$this->assertSame(1, $key->active);
$this->assertSame(0, $key->skipIpCheck);
return true;
}))->willReturn(7);
$this->service()->create(['description' => ' crm ', 'access' => 'rw', 'active' => ['0', '1'], 'skip_ip_check' => '0',
'allow_from' => "192.0.2.1\n2001:db8::/64, 10.0.0.0/8;192.0.2.1"]);
}
public function testCreateAcceptsNetworkArray(): void
{
$this->repo->expects($this->once())->method('insert')
->with($this->callback(fn(ApiKey $key): bool => $key->allowFrom === ['192.0.2.1', '::1']));
$this->service()->create(['access' => 'ro', 'allow_from' => ['192.0.2.1', ' ::1 ']]);
}
public static function invalidInput(): array
{
return [
'access missing' => [['allow_from' => '192.0.2.1'], 'api_key_access_invalid'],
'access unknown' => [['access' => 'admin', 'allow_from' => '192.0.2.1'], 'api_key_access_invalid'],
'no network' => [['access' => 'ro'], 'ip_list_empty'],
'only separators' => [['access' => 'ro', 'allow_from' => " ,\n"], 'ip_list_empty'],
'hostname' => [['access' => 'ro', 'allow_from' => 'mail.example.org'], ['api_key_network_invalid', 'mail.example.org']],
'v4 prefix too long' => [['access' => 'ro', 'allow_from' => '192.0.2.0/33'], ['api_key_network_invalid', '192.0.2.0/33']],
'empty prefix' => [['access' => 'ro', 'allow_from' => '192.0.2.0/'], ['api_key_network_invalid', '192.0.2.0/']],
'escaped' => [['access' => 'ro', 'allow_from' => '<b>'], ['api_key_network_invalid', '&lt;b&gt;']],
'description too long' => [['access' => 'ro', 'skip_ip_check' => '1', 'description' => str_repeat('x', 256)], 'api_key_description_invalid'],
'description array' => [['access' => 'ro', 'skip_ip_check' => '1', 'description' => ['x']], 'api_key_description_invalid'],
];
}
#[DataProvider('invalidInput')]
public function testCreateRejectsInvalidInput(array $input, string|array $msg): void
{
$this->repo->expects($this->never())->method('insert');
$this->expectUserError($msg, fn() => $this->service()->create($input));
}
public function testSkipIpCheckNeedsNoNetwork(): void
{
$this->repo->expects($this->once())->method('insert')
->with($this->callback(fn(ApiKey $key): bool => $key->skipIpCheck === 1 && $key->allowFrom === []));
$this->service()->create(['access' => 'ro', 'skip_ip_check' => 'true']);
}
public function testUpdateKeepsMissingAttributesAndKeyValue(): void
{
$this->repo->method('find')->with(3)->willReturn(self::stored());
$this->repo->expects($this->once())->method('update')->with($this->callback(function (ApiKey $key): bool {
$this->assertSame('AAAAAA-BBBBBB-CCCCCC-DDDDDD-EEEEEE', $key->key);
$this->assertSame(['192.0.2.1', '2001:db8::/64'], $key->allowFrom);
$this->assertSame('ro', $key->access);
$this->assertSame('crm', $key->description);
$this->assertSame(0, $key->active);
return true;
}));
$this->service()->update(3, ['active' => '0']);
}
public function testUpdateRegeneratesKey(): void
{
$this->repo->method('find')->willReturn(self::stored());
$this->repo->expects($this->once())->method('update')->with($this->callback(fn(ApiKey $key): bool =>
preg_match(self::KEY_FORMAT, $key->key) === 1 && $key->key !== 'AAAAAA-BBBBBB-CCCCCC-DDDDDD-EEEEEE'));
$this->service()->update(3, ['regen_key' => '1']);
}
public function testDisablingSkipIpCheckNeedsNetworks(): void
{
$this->repo->method('find')->willReturn(self::stored(['allow_from' => '', 'skip_ip_check' => 1]));
$this->repo->expects($this->never())->method('update');
$this->expectUserError('ip_list_empty', fn() => $this->service()->update(3, ['skip_ip_check' => '0']));
}
public function testServiceKeysAreLocked(): void
{
$this->repo->method('find')->willReturn(self::stored(['service' => 1, 'description' => 'API_KEY']));
$this->repo->expects($this->never())->method('update');
$this->repo->expects($this->never())->method('delete');
$this->expectUserError(['api_key_service_locked', 'API_KEY'], fn() => $this->service()->update(3, ['regen_key' => '1']));
$this->expectUserError(['api_key_service_locked', 'API_KEY'], fn() => $this->service()->delete(3));
}
public function testMissingKeyIsAccessDenied(): void
{
$this->repo->method('find')->willReturn(null);
$this->expectUserError('access_denied', fn() => $this->service()->update(99, []));
$this->expectUserError('access_denied', fn() => $this->service()->delete(99));
}
public function testDeleteReturnsDeletedKey(): void
{
$this->repo->method('find')->willReturn(self::stored());
$this->repo->expects($this->once())->method('delete')->with(3);
$this->assertSame('crm', $this->service()->delete(3)->description);
}
public function testSyncMirrorsBothServiceKeys(): void
{
$this->repo->expects($this->once())->method('syncService')->with($this->callback(function (array $keys): bool {
$this->assertCount(2, $keys);
[$rw, $ro] = $keys;
$this->assertSame(['RW-KEY', 'API_KEY', 'rw', 1, ['192.0.2.1', '2001:db8::/64']],
[$rw->key, $rw->description, $rw->access, $rw->service, $rw->allowFrom]);
$this->assertSame(['RO-KEY', 'API_KEY_READ_ONLY', 'ro', 1, 1, 0],
[$ro->key, $ro->description, $ro->access, $ro->service, $ro->active, $ro->skipIpCheck]);
return true;
}));
$warnings = $this->service(Context::system())->syncServiceKeys([
'API_KEY' => 'RW-KEY', 'API_KEY_READ_ONLY' => ' RO-KEY ', 'API_ALLOW_FROM' => '192.0.2.1,2001:db8::/64,nonsense',
]);
$this->assertSame(['API_ALLOW_FROM: skipped invalid IP/network nonsense'], $warnings);
}
public static function unsetEnv(): array
{
return [
'compose defaults' => [['API_KEY' => 'invalid', 'API_KEY_READ_ONLY' => 'invalid', 'API_ALLOW_FROM' => 'invalid']],
'empty' => [['API_KEY' => '', 'API_ALLOW_FROM' => '192.0.2.1']],
'missing' => [[]],
];
}
#[DataProvider('unsetEnv')]
public function testSyncWithoutServiceKeysRemovesThem(array $env): void
{
$this->repo->expects($this->once())->method('syncService')->with([]);
$this->assertSame([], $this->service(Context::system())->syncServiceKeys($env));
}
public function testSyncSkipsInvalidKeys(): void
{
$this->repo->expects($this->once())->method('syncService')
->with($this->callback(fn(array $keys): bool => count($keys) === 1 && $keys[0]->description === 'API_KEY'));
$warnings = $this->service(Context::system())->syncServiceKeys([
'API_KEY' => 'SAME-KEY', 'API_KEY_READ_ONLY' => 'SAME-KEY', 'API_ALLOW_FROM' => '192.0.2.1',
]);
$this->assertSame(['API_KEY_READ_ONLY: same value as API_KEY, key ignored'], $warnings);
$this->repo = $this->createMock(ApiKeyRepository::class);
$this->repo->expects($this->once())->method('syncService')->with([]);
$warnings = $this->service(Context::system())->syncServiceKeys(['API_KEY' => 'bad"key', 'API_KEY_READ_ONLY' => 'RO-KEY',
'API_ALLOW_FROM' => 'invalid']);
$this->assertSame([
'API_KEY: invalid characters (allowed: a-z, A-Z, 0-9, -), key ignored',
'API_KEY_READ_ONLY: API_ALLOW_FROM contains no valid IP/network, key ignored',
], $warnings);
}
}
+16
View File
@@ -58,6 +58,22 @@ class ContextTest extends TestCase
$this->assertFalse(Context::fromSession(['acl' => 'not an array'])->hasAcl('syncjobs'));
}
public function testApiAccessComesFromTheApiKeySession(): void
{
$ro = Context::fromSession(['mailcow_cc_role' => 'admin', 'mailcow_cc_api' => true, 'mailcow_cc_api_access' => 'ro']);
$rw = Context::fromSession(['mailcow_cc_role' => 'admin', 'mailcow_cc_api' => true, 'mailcow_cc_api_access' => 'rw']);
$login = Context::fromSession(['mailcow_cc_role' => 'admin', 'mailcow_cc_api_access' => 'ro']);
$this->assertSame('ro', $ro->apiAccess);
$this->assertTrue($ro->isReadOnlyApi());
$this->assertSame('rw', $rw->apiAccess);
$this->assertFalse($rw->isReadOnlyApi());
$this->assertNull($login->apiAccess);
$this->assertFalse($login->isReadOnlyApi());
// Unknown access values are read-only
$this->assertTrue(Context::fromSession(['mailcow_cc_api' => true, 'mailcow_cc_api_access' => 'x'])->isReadOnlyApi());
}
public function testSystemContextIsNoAccount(): void
{
$context = Context::system();