mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-23 19:18:54 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bc8fbdd2c | ||
|
|
f63042bbaf | ||
|
|
4828f0cf64 | ||
|
|
40488b2120 | ||
|
|
02a9f59530 | ||
|
|
0dadf49934 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])) {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"])) {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) е добавен",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) が追加されました",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ă",
|
||||
|
||||
@@ -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) добавлен",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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á",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -276,7 +276,6 @@
|
||||
"validate_license_now": "Перевірити ліцензію на основі GUID із сервера ліцензій",
|
||||
"verify": "Перевірити",
|
||||
"yes": "✓",
|
||||
"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) додано",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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) 已新增",
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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', '<crm>'],
|
||||
]], $_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', '<b>']],
|
||||
'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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user