diff --git a/data/Dockerfiles/dovecot/imapsync_runner.py b/data/Dockerfiles/dovecot/imapsync_runner.py index 53dca5ea6..1d581ac58 100755 --- a/data/Dockerfiles/dovecot/imapsync_runner.py +++ b/data/Dockerfiles/dovecot/imapsync_runner.py @@ -36,7 +36,7 @@ token_sync_interval = 60 next_sql = """SELECT i.id, i.user1, i.user2, i.password1, i.exclude, i.delete2duplicates, i.maxage, i.subfolder2, i.delete1, i.delete2, i.automap, i.skipcrossduplicates, i.maxbytespersecond, i.custom_params, i.subscribeall, i.timeout1, i.timeout2, i.dry, i.source_id, - s.host1, s.port1, s.enc1, s.auth_type, s.oauth_flow + s.host1, s.port1, s.enc1, s.verify_cert, s.auth_type, s.oauth_flow FROM imapsync i JOIN imapsync_source s ON i.source_id = s.id WHERE i.active = 1 AND s.active = 1 @@ -131,6 +131,14 @@ def internal_host(host): return True return False +# SNI takes host names only +def ip_literal(host): + try: + ipaddress.ip_address(host) + return True + except ValueError: + return False + def oauth_token(j): if j['oauth_flow'] == 'authorization_code': return query(user_token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {} @@ -239,8 +247,13 @@ def prepare_job(j, global_bw, files): cmd.append(opt) enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['enc1']) if enc1: - # Check the certificate of the source (imapsync does not by default), custom sslargs1 can turn it off - cmd += [enc1, '--sslargs1', 'SSL_verify_mode=1'] + # Mail::IMAPClient starts TLS without a host name: check the certificate against host1, not its address + cmd += [enc1, '--sslargs1', 'SSL_verifycn_scheme=imap', '--sslargs1', 'SSL_verifycn_name=' + j['host1']] + if not ip_literal(j['host1']): + cmd += ['--sslargs1', 'SSL_hostname=' + j['host1']] + # imapsync does not check certificates by default; custom sslargs1 of password jobs can still change it + if j['verify_cert'] == 1: + cmd += ['--sslargs1', 'SSL_verify_mode=1'] cmd += ['--host1', j['host1'], '--user1', j['user1']] + auth_args + ['--port1', str(j['port1'])] cmd += ['--host2', 'localhost', '--user2', j['user2'] + '*' + master_user, '--passfile2', tmpfile(master_pass + '\n')] if j['dry'] == 1: diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index 7e3578a71..b08747722 100644 --- a/data/web/api/openapi.yaml +++ b/data/web/api/openapi.yaml @@ -1936,6 +1936,14 @@ paths: - TLS - SSL - PLAIN + verify_cert: + description: >- + 1 = check the server certificate (SSL and STARTTLS, default), 0 = do not check + it (self-signed, expired or not matching the host). + type: integer + enum: + - 0 + - 1 auth_type: description: >- authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job @@ -5080,6 +5088,15 @@ paths: - TLS - SSL - PLAIN + verify_cert: + description: >- + 1 = check the server certificate (SSL and STARTTLS, default), 0 = do not check + it (self-signed, expired or not matching the host). Turning the check off + needs access to all sync jobs of the source, like changing the server. + type: integer + enum: + - 0 + - 1 auth_type: description: >- authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job @@ -7313,6 +7330,7 @@ paths: host1: imap.example.org port1: 993 enc1: SSL + verify_cert: 1 auth_type: XOAUTH2 oauth_flow: authorization_code oauth_token_endpoint: https://login.example.org/token diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index d0d62dbfa..ff95a0b0a 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -1098,6 +1098,7 @@ function init_db_schema() "host1" => "VARCHAR(255) NOT NULL", "port1" => "SMALLINT UNSIGNED NOT NULL", "enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'", + "verify_cert" => "TINYINT(1) NOT NULL DEFAULT '1'", "auth_type" => "ENUM('PLAIN','LOGIN','CRAM-MD5','XOAUTH2') DEFAULT 'PLAIN'", "oauth_flow" => "ENUM('client_credentials','authorization_code') NOT NULL DEFAULT 'client_credentials'", "oauth_token_endpoint" => "VARCHAR(500) DEFAULT NULL", @@ -1679,9 +1680,15 @@ function init_db_schema() // Converted JSON may not fit into the old VARCHAR(512) $pdo->exec("ALTER TABLE `imapsync` MODIFY COLUMN `custom_params` TEXT"); $imapsync_allow = \Mailcow\Imapsync\Model\CustomParams::allowlist($GLOBALS['IMAPSYNC_OPTIONS'] ?? [], $GLOBALS['IMAPSYNC_OPTIONS_DEFAULT'] ?? []); - $source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` WHERE `created_by` = '' AND `name` = :name"); - $source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `auth_type`, `active`) - VALUES (:name, '', '', 'domain', :host, :port, :enc, :auth, 1)"); + // A legacy source is found by its connection and its only target domain (none for jobs without domain) + $source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` s WHERE `created_by` = '' AND `host1` = :host AND `port1` = :port + AND `enc1` = :enc AND `auth_type` = :auth + AND (SELECT GROUP_CONCAT(`domain`) FROM `imapsync_source_domain` d WHERE d.`source_id` = s.`id`) <=> NULLIF(:domain, '') + ORDER BY `id` LIMIT 1"); + $source_name_sel = $pdo->prepare("SELECT COUNT(*) FROM `imapsync_source` WHERE `created_by` = '' AND `name` = :name"); + // The old runner never checked certificates, legacy sources keep that + $source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `verify_cert`, `auth_type`, `active`) + VALUES (:name, 'Migrated sync jobs', '', 'domain', :host, :port, :enc, 0, :auth, 1)"); $source_domain_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)"); $job_upd = $pdo->prepare("UPDATE `imapsync` SET `source_id` = :source_id, `custom_params` = :cp, `custom_params_legacy` = :cp_legacy, `dry` = GREATEST(`dry`, :dry) WHERE `id` = :id AND `source_id` IS NULL"); @@ -1741,16 +1748,20 @@ function init_db_schema() $enc = in_array($row['enc1'], array('TLS', 'SSL', 'PLAIN'), true) ? $row['enc1'] : 'TLS'; $at = strrchr((string)$row['user2'], '@'); $domain = ($at === false) ? '' : strtolower(substr($at, 1)); - // One source per (host, port, enc, auth, domain), name always unique and <= 100 chars - $name = 'legacy-' . mb_substr($host, 0, 60) . '-' . $port . '-' . substr(sha1($host . '|' . $port . '|' . $enc . '|' . $auth . '|' . $domain), 0, 8); - $source_sel->execute(array(':name' => $name)); + // One source per (host, port, enc, auth, domain), named "host (domain)" plus " 2", " 3"... when taken (<= 100 chars) + $conn = array(':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth); + $source_sel->execute($conn + array(':domain' => $domain)); if (($source_id = $source_sel->fetchColumn()) === false) { - $source_ins->execute(array(':name' => $name, ':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth)); - $source_sel->execute(array(':name' => $name)); - if (($source_id = $source_sel->fetchColumn()) === false) continue; - } - if ($domain !== '') { - $source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain)); + $base = mb_substr($host . ($domain !== '' ? ' (' . $domain . ')' : ''), 0, 94); + for ($i = 1, $name = $base; $i < 1000; $name = $base . ' ' . ++$i) { + $source_name_sel->execute(array(':name' => $name)); + if ($source_name_sel->fetchColumn() == 0) break; + } + $source_ins->execute($conn + array(':name' => $name)); + if (($source_id = $pdo->lastInsertId()) == 0) continue; + if ($domain !== '') { + $source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain)); + } } $job_upd->execute(array( ':source_id' => $source_id, diff --git a/data/web/inc/lib/mailcow/Imapsync/Model/Source.php b/data/web/inc/lib/mailcow/Imapsync/Model/Source.php index 5ea55d054..7d237dc80 100644 --- a/data/web/inc/lib/mailcow/Imapsync/Model/Source.php +++ b/data/web/inc/lib/mailcow/Imapsync/Model/Source.php @@ -29,6 +29,7 @@ class Source public string $host = ''; public int $port = 0; public ?string $enc = 'TLS'; + public int $verifyCert = 1; public ?string $authType = 'PLAIN'; public string $oauthFlow = self::FLOW_CLIENT_CREDENTIALS; public ?string $tokenEndpoint = null; @@ -65,6 +66,7 @@ class Source $source->scope = (string)($row['scope'] ?? 'user'); $source->host = (string)($row['host1'] ?? ''); $source->port = (int)($row['port1'] ?? 0); + $source->verifyCert = (int)($row['verify_cert'] ?? 1); $source->oauthFlow = (string)($row['oauth_flow'] ?? self::FLOW_CLIENT_CREDENTIALS); $source->tokenExpires = isset($row['oauth_token_expires']) ? (int)$row['oauth_token_expires'] : null; $source->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null; @@ -112,6 +114,7 @@ class Source 'host1' => $this->host, 'port1' => $this->port, 'enc1' => $this->enc, + 'verify_cert' => $this->verifyCert, 'auth_type' => $this->authType, 'oauth_flow' => $this->oauthFlow, 'oauth_token_endpoint' => $this->tokenEndpoint, diff --git a/data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php b/data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php index 6e6b37dfe..a789f9074 100644 --- a/data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php +++ b/data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php @@ -206,6 +206,7 @@ class SourceRepository extends Repository 'host1' => $source->host, 'port1' => $source->port, 'enc1' => $source->enc, + 'verify_cert' => $source->verifyCert, 'auth_type' => $source->authType, 'oauth_flow' => $source->oauthFlow, 'oauth_token_endpoint' => $source->tokenEndpoint, diff --git a/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php b/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php index 2aa01b633..8438dc747 100644 --- a/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php +++ b/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php @@ -122,7 +122,7 @@ class SourceService $this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []); } $connectionChanged = self::connection($current) !== self::connection($source); - if ($connectionChanged) { + if ($connectionChanged || ($current->verifyCert && !$source->verifyCert)) { $this->requireAllJobsAccessible($source); } $this->repo->update($source, $writeTargets, $connectionChanged); @@ -296,6 +296,7 @@ class SourceService $source->host = $host; $source->port = $port; $source->enc = $enc; + $source->verifyCert = isset($input['verify_cert']) ? (int)!empty($input['verify_cert']) : $source->verifyCert; $source->authType = $authType; $source->active = isset($input['active']) ? (int)$input['active'] : $source->active; } diff --git a/data/web/js/site/imapsync.js b/data/web/js/site/imapsync.js index 70284b392..e0dfd9963 100644 --- a/data/web/js/site/imapsync.js +++ b/data/web/js/site/imapsync.js @@ -184,7 +184,11 @@ var mailcowImapsync = (function($) { dataSrc: function(json) { if (!Array.isArray(json)) return []; $.each(json, function(i, item) { - item.endpoint = wrapCell(esc((item.host || item.host1) + ':' + (item.port || item.port1) + ' (' + (item.enc || item.enc1) + ')')); + item.endpoint = esc((item.host || item.host1) + ':' + (item.port || item.port1) + ' (' + (item.enc || item.enc1) + ')'); + if (item.verify_cert == 0 && (item.enc || item.enc1) !== 'PLAIN') { + item.endpoint += ' '; + } + item.endpoint = wrapCell(item.endpoint); item.name_display = esc(item.name); if (item.description) item.name_display += '