['label' => 'OK', 'tone' => 'success', 'icon' => 'fa-check', 'text' => 'The host serves it, valid and trusted'], 'mismatch' => ['label' => 'Name mismatch', 'tone' => 'warning', 'icon' => 'fa-not-equal', 'text' => "What the host serves doesn't cover the host name - browsers will warn"], 'untrusted' => ['label' => 'Untrusted', 'tone' => 'warning', 'icon' => 'fa-exclamation-triangle', 'text' => 'Self-signed, or its chain is incomplete - browsers will warn'], 'expired' => ['label' => 'Expired', 'tone' => 'danger', 'icon' => 'fa-calendar-times', 'text' => 'The host serves an expired certificate'], 'unreachable' => ['label' => 'Unreachable', 'tone' => 'secondary', 'icon' => 'fa-plug', 'text' => 'Nothing answered on that host and port'], ]; /** What each tracked column is called in a certificate's history. */ const CERTIFICATE_HISTORY_LABELS = [ 'certificate_name' => 'Name', 'certificate_description' => 'Description', 'certificate_domain' => 'Host', 'domain_name' => 'Domain', 'certificate_issued_by' => 'Issuer', 'certificate_issued_at' => 'Issued', 'certificate_expire' => 'Expiry', 'certificate_names' => 'Names covered', 'certificate_fingerprint' => 'Fingerprint', 'certificate_notes' => 'Notes', ]; /** May this agent do this with certificates? 1 is to see them, 2 to change them, 3 to delete them. */ function certificateCan($level) { return lookupUserPermission('module_support') >= $level; } /** The columns and joins for a certificate with its client and the domain it is linked to (the client's own). */ function certificateSelectSql() { return ['certificates.*, client_name, domains.domain_name AS domain_name, domains.domain_archived_at AS domain_archived_at', 'LEFT JOIN clients ON client_id = certificate_client_id LEFT JOIN domains ON domains.domain_id = certificate_domain_id AND domains.domain_client_id = certificate_client_id']; } /** One certificate by id, with its client and domain - raw, or null. */ function certificateRow($certificate_id) { global $mysqli; $certificate_id = intval($certificate_id); if (!$certificate_id) { return null; } [$columns, $joins] = certificateSelectSql(); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT $columns FROM certificates $joins WHERE certificate_id = $certificate_id LIMIT 1")); return $row ?: null; } /** * Where a certificate is served, as it is kept - a host name or IP address, * with the port when it isn't 443 - from whatever was pasted * ("https://Portal.Example.com:8443/login"). ['host', 'port', 'display'], or * null when it is not a host. Internal names (fw01, nas.local) are fine. */ function certificateHost($input) { $value = strtolower(trim(html_entity_decode((string) $input, ENT_QUOTES, 'UTF-8'))); $value = preg_replace('#^[a-z][a-z0-9+.\-]*://#', '', $value); $value = preg_replace('#^[^@/]*@#', '', $value); $value = preg_split('#[/?\#]#', $value)[0]; if ($value === '' || preg_match('/\s/', $value)) { return null; } $port = 443; if (preg_match('/^\[([0-9a-f:.]+)\](?::(\d+))?$/', $value, $match)) { // [IPv6]:port $host = $match[1]; $port = isset($match[2]) ? intval($match[2]) : 443; } elseif (substr_count($value, ':') === 1) { [$host, $port_part] = explode(':', $value); if (!ctype_digit($port_part)) { return null; } $port = intval($port_part); } else { $host = $value; } $host = trim($host, '.'); if ($port < 1 || $port > 65535) { return null; } $is_ip = filter_var($host, FILTER_VALIDATE_IP) !== false; if (!$is_ip && ($host === '' || strlen($host) > 200 || !filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME))) { return null; } $shown = strpos($host, ':') !== false ? "[$host]" : $host; return ['host' => $host, 'port' => $port, 'display' => $port === 443 ? $shown : "$shown:$port", 'ip' => $is_ip]; } /** * What a certificate says about itself, from its PEM - the issuer, the dates, * the names it covers, its SHA-256 fingerprint, whether it signed itself. * The PEM given may be a whole chain or carry a key; only the first * certificate is kept. Null when there is no certificate in it. */ function certificateParse($pem) { if (!preg_match('/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/s', (string) $pem, $match)) { return null; } $pem = $match[0]; $parsed = @openssl_x509_parse($pem); if (!$parsed || empty($parsed['validTo_time_t'])) { return null; } $field = function ($value) { return is_array($value) ? (string) end($value) : (string) $value; }; $names = []; foreach (explode(',', (string) ($parsed['extensions']['subjectAltName'] ?? '')) as $alt_name) { $alt_name = trim($alt_name); if (stripos($alt_name, 'DNS:') === 0) { $names[] = strtolower(substr($alt_name, 4)); } elseif (stripos($alt_name, 'IP Address:') === 0) { $names[] = strtolower(trim(substr($alt_name, 11))); } } $subject_cn = $field($parsed['subject']['CN'] ?? ''); if (!$names && $subject_cn !== '') { $names[] = strtolower($subject_cn); } $issuer = $field($parsed['issuer']['O'] ?? '') ?: $field($parsed['issuer']['CN'] ?? ''); $fingerprint = @openssl_x509_fingerprint($pem, 'sha256'); $valid_from = intval($parsed['validFrom_time_t']); $valid_to = intval($parsed['validTo_time_t']); return [ 'pem' => $pem, 'issuer' => mb_substr(strip_tags($issuer), 0, 200), 'subject' => $subject_cn, 'issued_at' => date('Y-m-d', $valid_from), 'expire' => date('Y-m-d', $valid_to), 'expired' => $valid_to < time(), 'names' => array_values(array_unique($names)), 'fingerprint' => $fingerprint ? strtoupper(implode(':', str_split($fingerprint, 2))) : '', 'self_signed' => ($parsed['issuer'] ?? null) == ($parsed['subject'] ?? null), 'days' => intval(round(($valid_to - $valid_from) / 86400)), ]; } /** Does a certificate covering these names cover this host? Exact, or one label under a wildcard. */ function certificateCovers($host, array $names) { $host = strtolower(trim((string) $host, '.')); foreach ($names as $name) { $name = strtolower(trim((string) $name, '.')); if ($name === $host) { return true; } if (strpos($name, '*.') === 0) { $suffix = substr($name, 1); $label = substr($host, 0, -strlen($suffix)); if (substr($host, -strlen($suffix)) === $suffix && $label !== '' && strpos($label, '.') === false) { return true; } } } return false; } /** * What a check finds, from the certificate a host served: expired first, then * one for another name, then one nothing trusts (self-signed or a broken * chain), otherwise OK. */ function certificateStatus(array $parsed, $host, $trusted) { if ($parsed['expired']) { return 'expired'; } if (!certificateCovers($host, $parsed['names'])) { return 'mismatch'; } return $trusted ? 'ok' : 'untrusted'; } /** Open a TLS connection to a host - [stream or false]. Verification is the caller's choice; SNI is always sent. */ function certificateConnect($host, $port, $verify) { $options = ['ssl' => [ 'capture_peer_cert' => true, 'verify_peer' => $verify, 'verify_peer_name' => false, 'allow_self_signed' => false, 'SNI_enabled' => true, 'peer_name' => $host, ]]; $target = strpos($host, ':') !== false ? "[$host]" : $host; return @stream_socket_client("ssl://$target:$port", $errno, $errstr, 5, STREAM_CLIENT_CONNECT, stream_context_create($options)); } /** * Check a host - connect, take the certificate it serves, and say what it is. * ['reachable' => false] when nothing answered; otherwise what * certificateParse() gives plus 'status' (see CERTIFICATE_CHECK_STATES). */ function certificateCheck($host_input) { $host = is_array($host_input) ? $host_input : certificateHost($host_input); if (!$host) { return ['reachable' => false]; } $stream = certificateConnect($host['host'], $host['port'], false); if (!$stream) { return ['reachable' => false]; } $params = stream_context_get_params($stream); fclose($stream); $pem = ''; if (!empty($params['options']['ssl']['peer_certificate'])) { @openssl_x509_export($params['options']['ssl']['peer_certificate'], $pem); } $parsed = certificateParse($pem); if (!$parsed) { return ['reachable' => false]; } // Trusted - a second handshake that checks the chain against the system's CAs $trusted = false; if (!$parsed['expired'] && !$parsed['self_signed']) { $verified = certificateConnect($host['host'], $host['port'], true); if ($verified) { $trusted = true; fclose($verified); } } $parsed['reachable'] = true; $parsed['status'] = certificateStatus($parsed, $host['host'], $trusted); return $parsed; } /** Save what a pasted PEM or a check says the certificate is. */ function certificateApplyParsed($certificate_id, array $parsed) { global $mysqli; $certificate_id = intval($certificate_id); mysqli_query($mysqli, "UPDATE certificates SET certificate_public_key = '" . escapeSql($parsed['pem']) . "', certificate_issued_by = '" . escapeSql($parsed['issuer']) . "', certificate_issued_at = '" . escapeSql($parsed['issued_at']) . "', certificate_expire = '" . escapeSql($parsed['expire']) . "', certificate_names = '" . escapeSql(implode("\n", $parsed['names'])) . "', certificate_fingerprint = '" . escapeSql($parsed['fingerprint']) . "' WHERE certificate_id = $certificate_id"); } /** * Save what a check found - the certificate and the result when the host * answered, only the result (unreachable) when it didn't. True when it answered. */ function certificateApplyCheck($certificate_id, array $check) { global $mysqli; $certificate_id = intval($certificate_id); if (empty($check['reachable'])) { mysqli_query($mysqli, "UPDATE certificates SET certificate_check_status = 'unreachable', certificate_checked_at = NOW() WHERE certificate_id = $certificate_id"); return false; } certificateApplyParsed($certificate_id, $check); mysqli_query($mysqli, "UPDATE certificates SET certificate_check_status = '" . escapeSql($check['status']) . "', certificate_checked_at = NOW() WHERE certificate_id = $certificate_id"); return true; } /** * Check the host a certificate is served on and save what it serves, with * its history (not for one just added - there is nothing before it). With * $keep_pem, a certificate pasted in the form that differs from what the * host serves is kept - only the result of the check is saved. The check, * or null without a host. */ function certificateCheckAndSave($certificate_id, $keep_pem = '', $with_history = true) { global $mysqli; $certificate_id = intval($certificate_id); $row = certificateRow($certificate_id); $host = $row ? certificateHost($row['certificate_domain']) : null; if (!$host) { return null; } $check = certificateCheck($host); $before = certificateHistorySnapshot($certificate_id); $kept_parsed = $keep_pem !== '' && !empty($check['reachable']) ? certificateParse($keep_pem) : null; if ($kept_parsed && $kept_parsed['fingerprint'] !== $check['fingerprint']) { mysqli_query($mysqli, "UPDATE certificates SET certificate_check_status = '" . escapeSql($check['status']) . "', certificate_checked_at = NOW() WHERE certificate_id = $certificate_id"); } else { certificateApplyCheck($certificate_id, $check); } if ($with_history) { certificateWriteHistory($certificate_id, $before, certificateHistorySnapshot($certificate_id)); } return $check; } /** The client's domain a host belongs to - the domain itself, or the longest one it is under - or 0. */ function certificateDomainFor($host, $client_id) { global $mysqli; $host = strtolower((string) $host); $client_id = intval($client_id); if ($host === '' || filter_var($host, FILTER_VALIDATE_IP)) { return 0; } $host_sql = escapeSql($host); $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT domain_id FROM domains WHERE domain_client_id = $client_id AND domain_archived_at IS NULL AND ('$host_sql' = domain_name OR '$host_sql' LIKE CONCAT('%.', domain_name)) ORDER BY LENGTH(domain_name) DESC LIMIT 1")); return $row ? intval($row[0]) : 0; } /** A domain id a certificate may link to - one of its client's - else 0. */ function certificateDomainId($domain_id, $client_id) { global $mysqli; $domain_id = intval($domain_id); $client_id = intval($client_id); if (!$domain_id) { return 0; } $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT domain_id FROM domains WHERE domain_id = $domain_id AND domain_client_id = $client_id LIMIT 1")); return $row ? intval($row[0]) : 0; } /** * The fields a New / Edit form posts, as a SQL SET list. A pasted PEM is the * last word on the issuer, dates, names and fingerprint; with none, the * issuer and expiry typed by hand are all there is. With no name the * host is the name; with no domain picked, the client's domain the host is * under. Null with $error set when it cannot be saved. Also returns the * host and what the PEM said, if there was one. */ function certificateFormSet($post, $client_id, &$error = '') { $error = ''; $client_id = intval($client_id); $host_input = trim((string) ($post['domain'] ?? '')); $host = $host_input === '' ? null : certificateHost($host_input); if ($host_input !== '' && !$host) { $error = "That isn't a host - something like www.example.com, or portal.example.com:8443."; return null; } $pem_input = trim((string) ($post['public_key'] ?? '')); $parsed = $pem_input === '' ? null : certificateParse($pem_input); if ($pem_input !== '' && !$parsed) { $error = "That isn't a certificate - paste the PEM, from -----BEGIN CERTIFICATE----- to -----END CERTIFICATE-----."; return null; } $name = trim((string) ($post['name'] ?? '')) ?: ($host ? $host['display'] : ($parsed['subject'] ?? '')); if ($name === '') { $error = 'A certificate needs a name, or the host it is served on.'; return null; } $expire = trim((string) ($post['expire'] ?? '')); if ($expire !== '' && (!($date = DateTime::createFromFormat('!Y-m-d', $expire)) || $date->format('Y-m-d') !== $expire)) { $error = 'The expiry date is not a date.'; return null; } $domain_id = certificateDomainId($post['domain_id'] ?? 0, $client_id) ?: ($host ? certificateDomainFor($host['host'], $client_id) : 0); $text = function ($value, $length) { return "'" . escapeSql(mb_substr(trim((string) $value), 0, $length)) . "'"; }; $fields = [ 'certificate_name' => $text($name, 200), 'certificate_description' => $text($post['description'] ?? '', 500), 'certificate_domain' => $text($host ? $host['display'] : '', 200), 'certificate_notes' => "'" . escapeSql(trim((string) ($post['notes'] ?? ''))) . "'", 'certificate_favorite' => intval($post['favorite'] ?? 0) ? 1 : 0, 'certificate_domain_id' => $domain_id, ]; if ($parsed) { $fields['certificate_public_key'] = "'" . escapeSql($parsed['pem']) . "'"; $fields['certificate_issued_by'] = $text($parsed['issuer'], 200); $fields['certificate_issued_at'] = "'" . $parsed['issued_at'] . "'"; $fields['certificate_expire'] = "'" . $parsed['expire'] . "'"; $fields['certificate_names'] = "'" . escapeSql(implode("\n", $parsed['names'])) . "'"; $fields['certificate_fingerprint'] = "'" . escapeSql($parsed['fingerprint']) . "'"; } else { // No certificate - what was typed by hand, and nothing a certificate would have said $fields['certificate_public_key'] = "''"; $fields['certificate_issued_by'] = $text($post['issued_by'] ?? '', 200); $fields['certificate_issued_at'] = 'NULL'; $fields['certificate_expire'] = $expire === '' ? 'NULL' : "'$expire'"; $fields['certificate_names'] = "''"; $fields['certificate_fingerprint'] = "''"; } $set = []; foreach ($fields as $column => $value) { $set[] = "$column = $value"; } return ['set' => implode(', ', $set), 'name' => $name, 'host' => $host, 'parsed' => $parsed]; } /** The names a certificate covers, as a list. */ function certificateNames($row) { return array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', (string) ($row['certificate_names'] ?? ''))), 'strlen')); } /** Is it short-lived - an ACME certificate that renews itself? From its own dates, when they are known. */ function certificateShortLived($row) { $issued = strtotime((string) ($row['certificate_issued_at'] ?? '')); $expire = strtotime((string) ($row['certificate_expire'] ?? '')); return $issued && $expire && ($expire - $issued) / 86400 <= CERTIFICATE_SHORT_LIVED_DAYS; } /** * When it expires: 'expired', 'soon' or 'active', with a tone and a line to * show. A short-lived certificate is only "soon" inside 20 days - by then it * should already have renewed itself, so it is a warning rather than routine. */ function certificateExpiry($row) { $short_lived = certificateShortLived($row); $renewal = softwareRenewal($row['certificate_expire'] ?? '', $short_lived ? 20 : CERTIFICATE_SOON_DAYS); if (!$renewal) { return null; } $days = $renewal['days']; if ($renewal['state'] === 'expired') { $renewal['short'] = 'Expired'; } elseif ($renewal['state'] === 'soon') { $when = $days === 0 ? 'today' : ($days === 1 ? 'tomorrow' : "in $days days"); $renewal['text'] = 'Expires ' . $when . ($short_lived ? ' - it should have renewed by now' : ''); $renewal['short'] = $days === 0 ? 'Today' : ($days === 1 ? 'Tomorrow' : "$days days"); } else { $renewal['text'] = ($short_lived ? 'Renews itself, expires ' : 'Expires ') . timeAgo($renewal['date']); $renewal['short'] = timeAgo($renewal['date']); } $renewal['short_lived'] = $short_lived; return $renewal; } /** The expiry as a soft pill - red expired, amber soon, green otherwise (with a renew icon when it renews itself). */ function certificateExpiryPill($expiry, $use_text = false) { if (!$expiry) { return '' . 'Unknown'; } $tone = $expiry['tone']; $icon = $expiry['state'] === 'expired' ? 'fa-calendar-times' : ($expiry['state'] === 'soon' ? 'fa-hourglass-half' : ($expiry['short_lived'] ? 'fa-sync-alt' : 'fa-calendar-check')); return '' . '' . escapeHtml($use_text ? $expiry['text'] : $expiry['short']) . ''; } /** What the last check of the host found, as a pill - or "Not checked" without a host or a check. */ function certificateStatusPill($row, $use_text = false) { $status = (string) ($row['certificate_check_status'] ?? ''); if (!isset(CERTIFICATE_CHECK_STATES[$status])) { $label = trim((string) ($row['certificate_domain'] ?? '')) === '' ? 'No host to check' : 'Not checked'; return '' . $label . ''; } $state = CERTIFICATE_CHECK_STATES[$status]; $when = !empty($row['certificate_checked_at']) ? ' - checked ' . timeAgo($row['certificate_checked_at']) : ''; return '' . '' . escapeHtml($use_text ? $state['text'] : $state['label']) . ''; } /** A certificate's tile - a padlock, in the same rounded square as the other tiles. $size 'xs' | 'sm' | '' | 'lg'. */ function certificateTile($size = '') { $size_class = in_array($size, ['xs', 'sm', 'lg'], true) ? " itflow-asset-icon-$size" . ($size === 'xs' ? ' itflow-vendor-tile-xs' : '') : ''; return ''; } /** A certificate wherever a page mentions one: its tile and its name, opening its quick look. 'archived' strikes it through. */ function certificateLink($certificate_id, $name, $options = []) { $certificate_id = intval($certificate_id); $label = escapeHtml(html_entity_decode((string) $name, ENT_QUOTES, 'UTF-8')); $archived = !empty($options['archived']); $class = trim('text-truncate ' . ($options['class'] ?? '') . ($archived ? ' text-decoration-line-through text-body-secondary' : '')); return '' . certificateTile('xs') . "$label"; } /** A certificate as an option in a picker (itflow_select.js) - the padlock, and the host and when it expires underneath. */ function selectOptionCertificate($row) { $expiry = certificateExpiry($row); $host = trim((string) ($row['certificate_domain'] ?? '')); $description = implode(' - ', array_filter([$host !== '' && $host !== $row['certificate_name'] ? $host : '', $expiry ? $expiry['text'] : ''])); return selectOptionData(['icon' => 'fas fa-lock', 'description' => $description, 'keywords' => $host]); } /** The certificate as a .crt file to download - a data: link, '' without a PEM. */ function certificateDownloadHref($pem) { $pem = trim((string) $pem); return $pem === '' ? '' : 'data:application/x-x509-ca-cert;base64,' . base64_encode($pem . "\n"); } /** What a certificate's history tracks, as it stands now. */ function certificateHistorySnapshot($certificate_id) { $row = certificateRow($certificate_id); if (!$row) { return []; } $snapshot = []; foreach (array_keys(CERTIFICATE_HISTORY_LABELS) as $column) { $snapshot[$column] = (string) ($row[$column] ?? ''); } return $snapshot; } /** Record what changed between two snapshots in the certificate's history. */ function certificateWriteHistory($certificate_id, array $before, array $after) { global $mysqli; $certificate_id = intval($certificate_id); foreach ($after as $column => $new_value) { $old_value = $before[$column] ?? ''; if ((string) $old_value === (string) $new_value) { continue; } mysqli_query($mysqli, "INSERT INTO certificate_history SET certificate_history_column = '" . escapeSql($column) . "', certificate_history_old_value = '" . escapeSql($old_value) . "', certificate_history_new_value = '" . escapeSql($new_value) . "', certificate_history_certificate_id = $certificate_id"); } } /** A certificate's history, newest first - each change with its label. */ function certificateHistory($certificate_id, $limit = 25) { global $mysqli; $certificate_id = intval($certificate_id); $limit = max(1, intval($limit)); $sql = mysqli_query($mysqli, "SELECT certificate_history_column, certificate_history_old_value, certificate_history_new_value, certificate_history_modified_at FROM certificate_history WHERE certificate_history_certificate_id = $certificate_id ORDER BY certificate_history_modified_at DESC, certificate_history_id DESC LIMIT $limit"); $rows = []; while ($row = mysqli_fetch_assoc($sql)) { $row['label'] = CERTIFICATE_HISTORY_LABELS[$row['certificate_history_column']] ?? ucfirst(str_replace('_', ' ', preg_replace('/^certificate_/', '', $row['certificate_history_column']))); $rows[] = $row; } return $rows; } /** The SQL for "expiring soon" - inside CERTIFICATE_SOON_DAYS, or 20 days for a short-lived one (see certificateExpiry()). */ function certificateSoonSql($alias = 'certificates') { return "$alias.certificate_expire BETWEEN CURDATE() AND CURDATE() + INTERVAL IF($alias.certificate_issued_at IS NOT NULL AND DATEDIFF($alias.certificate_expire, $alias.certificate_issued_at) <= " . CERTIFICATE_SHORT_LIVED_DAYS . ", 20, " . CERTIFICATE_SOON_DAYS . ") DAY"; } /** The SQL for "the last check found a problem" - the Problems filter. */ function certificateProblemSql($alias = 'certificates') { return "$alias.certificate_check_status IN ('mismatch', 'untrusted', 'expired', 'unreachable')"; }