Files
itflow/functions/domain.php
johnnyq ade9d5aac3 Services modernization: new services list with Needs attention, Review due, High importance, No review set, Starred and Archived views, dependency chips, in-place importance, bulk importance/category/review schedule, mark reviewed, star, archive/restore/delete and CSV/PDF export; new service page with service facts, backup and recovery, who to call (vendor and client contact details), auto-saving notes and tabs for the assets it runs on, logins, domains and certificates, documents, tickets and history; compact quick look; shared add/edit form with importance, category suggestions, a review schedule and dependency pickers on add; review modal with checklist and history note; attention flags for expiring or expired domains and certificates, certificate check problems, infrastructure out of warranty or out of action, and archived records still linked; Report a problem opens a prefilled ticket linked to the service, plus a service_tickets table, ticket page services list and link/unlink from both sides; services, domains and certificates added to the generic link system, legacy service link actions routed through it with same-client checks; services shown on the asset, contact and vendor pages, the asset/domain/certificate/credential quick looks, the document Linked card, the client overview (card, attention items, upcoming reviews), the dashboard, calendar, global search, client PDF pack and nightly review notifications; archived services left out of nav counts and lookups; services read endpoint in the API; demo data review schedules; database update 2.8.7 (archive and review columns, wider category and backup, link table dedupe with primary keys, service_domains foreign keys, service_tickets)
2026-10-15 17:32:09 -04:00

953 lines
38 KiB
PHP

<?php
// The certificate a host serves - kept for the callers that only want its
// expiry, issuer and PEM. certificateCheck() (functions/certificate.php) does
// the work, and also says whether it is trusted and covers the name.
function getSslCertificate($full_name)
{
$check = certificateCheck($full_name);
if (empty($check['reachable'])) {
return ['success' => false, 'expire' => '', 'issued_by' => '', 'public_key' => ''];
}
return ['success' => true, 'expire' => $check['expire'], 'issued_by' => $check['issuer'], 'public_key' => $check['pem']];
}
// ============================================================
// RDAP + native-DNS domain lookups (no shell_exec)
// Restored from commit d1e1609b; lost during the functions.php split.
// ============================================================
// --- RDAP helpers ---------------------------------------------------------
function rdapHttpGet($url, &$http_code = null) {
$ch = curl_init($url);
curl_setopt_array($ch, array(
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_USERAGENT => 'ITFlow-Domain-Check',
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_FOLLOWLOCATION => true, // RDAP bootstrap/redirectors use 30x
CURLOPT_MAXREDIRS => 5,
CURLOPT_HTTPHEADER => array('Accept: application/rdap+json'),
));
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ($response === false) ? '' : $response;
}
function getRdapBaseUrl($tld) {
static $bootstrap = null;
if ($bootstrap === null) {
$cache_file = sys_get_temp_dir() . '/itflow_rdap_bootstrap.json';
if (is_readable($cache_file) && (time() - filemtime($cache_file)) < 604800) {
$bootstrap = json_decode(file_get_contents($cache_file), true);
}
if (empty($bootstrap['services'])) {
$raw = rdapHttpGet('https://data.iana.org/rdap/dns.json', $code);
if ($code === 200 && !empty($raw)) {
$decoded = json_decode($raw, true);
if (!empty($decoded['services'])) {
$bootstrap = $decoded;
@file_put_contents($cache_file, $raw);
}
}
}
if (empty($bootstrap['services'])) {
$bootstrap = array('services' => array()); // don't retry every call this run
}
}
foreach ($bootstrap['services'] as $service) {
// [0] = list of TLDs, [1] = list of base URLs
if (in_array($tld, $service[0])) {
return rtrim($service[1][0], '/') . '/';
}
}
return '';
}
function getDomainRdap($domain) {
static $cache = array();
if (array_key_exists($domain, $cache)) {
return $cache[$domain];
}
$cache[$domain] = null;
$tld = substr(strrchr($domain, '.'), 1);
if (empty($tld)) {
return null;
}
// Primary: IANA bootstrap -> registry RDAP server directly
$base = getRdapBaseUrl($tld);
if (!empty($base)) {
$raw = rdapHttpGet($base . 'domain/' . rawurlencode($domain), $code);
if ($code === 200 && !empty($raw)) {
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
$cache[$domain] = $decoded;
return $decoded;
}
}
if ($code === 404) {
return null; // Domain genuinely not found - don't bother the fallback
}
}
// Fallback: rdap.org redirector (covers gaps and bootstrap fetch failures)
$raw = rdapHttpGet('https://rdap.org/domain/' . rawurlencode($domain), $code);
if ($code === 200 && !empty($raw)) {
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
$cache[$domain] = $decoded;
}
}
return $cache[$domain];
}
function getRdapEventDate($rdap, $action) {
if (empty($rdap['events']) || !is_array($rdap['events'])) {
return '';
}
foreach ($rdap['events'] as $event) {
if (isset($event['eventAction'], $event['eventDate']) && $event['eventAction'] === $action) {
return $event['eventDate'];
}
}
return '';
}
function getRdapRegistrar($rdap) {
if (empty($rdap['entities']) || !is_array($rdap['entities'])) {
return '';
}
foreach ($rdap['entities'] as $entity) {
if (empty($entity['roles']) || !in_array('registrar', $entity['roles'])) {
continue;
}
if (!empty($entity['vcardArray'][1]) && is_array($entity['vcardArray'][1])) {
foreach ($entity['vcardArray'][1] as $field) {
if (isset($field[0], $field[3]) && $field[0] === 'fn' && is_string($field[3])) {
return $field[3];
}
}
}
}
return '';
}
function getRdapSummary($rdap) {
$lines = array();
$registrar = getRdapRegistrar($rdap);
if (!empty($registrar)) {
$lines[] = "Registrar: $registrar";
}
$registered = getRdapEventDate($rdap, 'registration');
if (!empty($registered)) {
$lines[] = 'Registered: ' . substr($registered, 0, 10);
}
$expires = getRdapEventDate($rdap, 'expiration');
if (!empty($expires)) {
$lines[] = 'Expires: ' . substr($expires, 0, 10);
}
if (!empty($rdap['status']) && is_array($rdap['status'])) {
$lines[] = 'Status: ' . implode(', ', array_slice($rdap['status'], 0, 3));
}
if (!empty($rdap['nameservers']) && is_array($rdap['nameservers'])) {
$ns = array();
foreach ($rdap['nameservers'] as $nameserver) {
if (!empty($nameserver['ldhName'])) {
$ns[] = strtolower($nameserver['ldhName']);
}
}
if (!empty($ns)) {
sort($ns);
$lines[] = 'Nameservers: ' . implode(', ', $ns);
}
}
return implode("\n", $lines);
}
// --- Legacy whois fallback (port-43 socket, for TLDs without RDAP) --------
function whoisSocketQuery($server, $query) {
$response = '';
$fp = @fsockopen($server, 43, $errno, $errstr, 5);
if (!$fp) {
return '';
}
stream_set_timeout($fp, 5);
fwrite($fp, $query . "\r\n");
while (!feof($fp)) {
$line = fgets($fp, 1024);
if ($line === false) {
break;
}
$response .= $line;
// Sanity cap - expiry/registrar fields always appear well before this
if (strlen($response) > 32768) {
break;
}
}
fclose($fp);
return $response;
}
function getDomainWhois($domain) {
$tld = substr(strrchr($domain, '.'), 1);
if (empty($tld)) {
return '';
}
// Ask IANA which whois server handles this TLD
$server = '';
$iana_response = whoisSocketQuery('whois.iana.org', $tld);
if (preg_match('/^whois:\s*(\S+)/mi', $iana_response, $matches)) {
$server = $matches[1];
}
if (empty($server)) {
return '';
}
// Verisign registries match nameservers too unless you use exact-match syntax
$query = in_array($tld, array('com', 'net')) ? "=$domain" : $domain;
$result = whoisSocketQuery($server, $query);
// Thin registries (.com/.net) refer to the registrar's whois - follow it once
if (preg_match('/Registrar WHOIS Server:\s*(\S+)/i', $result, $matches)) {
$referral = rtrim(trim($matches[1]), '/');
$referral = preg_replace('#^r?whois://#i', '', $referral);
if (!empty($referral) && strcasecmp($referral, $server) !== 0) {
$referred_result = whoisSocketQuery($referral, $domain);
if (trim($referred_result) !== '') {
$result = $referred_result;
}
}
}
return $result;
}
// --- Public API (names match current callers) ----------------------------
// getDnsRecords: native dns_get_record() for A/NS/MX/TXT + RDAP whois summary
function getDnsRecords($name) {
$records = array(
'a' => '',
'ns' => '',
'mx' => '',
'txt' => '',
'dmarc' => '',
'dkim' => '',
'whois' => '',
'expire' => ''
);
// Only run if we think the domain is valid
if (!filter_var($name, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) || !checkdnsrr($name, 'SOA')) {
return $records;
}
// Anchored so we don't mangle domains that merely start with "www"
$domain = preg_replace('/^www\./i', '', strtolower(trim($name)));
// A records
$a = @dns_get_record($domain, DNS_A);
if (is_array($a) && !empty($a)) {
$a_records = array_column($a, 'ip');
sort($a_records);
$records['a'] = implode("\n", $a_records);
}
// NS records
$ns = @dns_get_record($domain, DNS_NS);
if (is_array($ns) && !empty($ns)) {
$ns_records = array_column($ns, 'target');
sort($ns_records);
$records['ns'] = implode("\n", $ns_records);
}
// MX records - mimic dig +short output format ("10 mail.example.com")
$mx = @dns_get_record($domain, DNS_MX);
if (is_array($mx) && !empty($mx)) {
$mx_records = array();
foreach ($mx as $record) {
$mx_records[] = $record['pri'] . ' ' . $record['target'];
}
sort($mx_records, SORT_NATURAL);
$records['mx'] = implode("\n", $mx_records);
}
// TXT records
$txt = @dns_get_record($domain, DNS_TXT);
if (is_array($txt) && !empty($txt)) {
$txt_records = array_column($txt, 'txt');
sort($txt_records);
$records['txt'] = implode("\n", $txt_records);
}
// DMARC - the policy record at _dmarc
$dmarc = @dns_get_record("_dmarc.$domain", DNS_TXT);
if (is_array($dmarc)) {
foreach ($dmarc as $record) {
if (isset($record['txt']) && stripos($record['txt'], 'v=DMARC1') === 0) {
$records['dmarc'] = $record['txt'];
break;
}
}
}
// DKIM - there is no list of a domain's selectors, so look at the ones the
// big senders use (Microsoft 365, Google Workspace, Mailchimp and the usual defaults)
$selectors = array();
foreach (DOMAIN_DKIM_SELECTORS as $selector) {
$dkim = @dns_get_record("$selector._domainkey.$domain", DNS_TXT);
$cname = $dkim ? array() : @dns_get_record("$selector._domainkey.$domain", DNS_CNAME);
if (!empty($dkim) || !empty($cname)) {
$selectors[] = $selector;
}
}
$records['dkim'] = implode("\n", $selectors);
// Registration data - RDAP first, legacy whois only if the TLD has no RDAP
$rdap = getDomainRdap($domain);
if ($rdap !== null) {
$records['whois'] = substr(getRdapSummary($rdap), 0, 4000);
$expires = getRdapEventDate($rdap, 'expiration');
if (!empty($expires)) {
$parsed = date_create($expires);
if ($parsed) {
$records['expire'] = $parsed->format('Y-m-d');
}
}
} else {
$whois_raw = getDomainWhois($domain);
if (!empty($whois_raw) && stripos($whois_raw, 'rate limit') === false) {
// Approximate the old `head -30 | sed 's/ //g'`
$lines = array_slice(explode("\n", $whois_raw), 0, 30);
$lines = array_map(function ($line) {
return preg_replace('/ +/', ' ', rtrim($line));
}, $lines);
$records['whois'] = substr(trim(strip_tags(implode("\n", $lines))), 0, 4000);
}
}
return $records;
}
function getDomainExpirationDate($domain) {
if (!filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) || !checkdnsrr($domain, 'SOA')) {
return null;
}
$domain = preg_replace('/^www\./i', '', strtolower(trim($domain)));
// RDAP: expiration is a structured field - no regex, no date-format guessing
$rdap = getDomainRdap($domain);
if ($rdap !== null) {
$expires = getRdapEventDate($rdap, 'expiration');
if (!empty($expires)) {
$parsed = date_create($expires);
if ($parsed) {
return $parsed->format('Y-m-d');
}
}
return null; // RDAP answered but had no expiry (rare) - trust it, don't re-query
}
// Fallback for TLDs without RDAP: legacy whois parsing
$result = getDomainWhois($domain);
if (empty($result) || stripos($result, 'rate limit') !== false) {
return null;
}
// Every expiry label seen in the wild, longest/most-specific first
$labels = array(
'Registrar Registration Expiration Date',
'Registry Expiry Date',
'Expiration Date',
'Expiration Time',
'\[Expires on\]',
'Expires On',
'Expiry Date',
'Expire Date',
'expire-date',
'renewal date',
'Valid Until',
'paid-till',
'validity',
'renewal',
'Expires',
'expiry',
'expire',
);
if (!preg_match('/(?:' . implode('|', $labels) . ')\s*:?\s+(.+)/i', $result, $matches)) {
return null;
}
$expireDate = trim($matches[1]);
// Known date formats (roundtrip-checked to avoid d-m-Y vs Y-m-d ambiguity)
$knownFormats = array(
'Y-m-d',
'Y.m.d',
'Y/m/d',
'Ymd',
'Y. m. d.',
'Y-M-d.',
'd-M-Y',
'd-F-Y',
'd-m-Y',
'd.m.Y',
'd/m/Y',
'Y/m/d H:i:s',
'Ymd H:i:s',
'Y.m.d H:i:s',
'Y-m-d H:i:s',
'd-M-Y H:i:s',
'd.m.Y H:i:s',
'd/m/Y H:i:s',
'd M Y H:i:s',
'd/m/Y H:i:s T',
'D M d H:i:s T Y',
'D M d Y',
);
foreach ($knownFormats as $format) {
$parsedDate = DateTime::createFromFormat($format, $expireDate);
if ($parsedDate && $parsedDate->format($format) === $expireDate) {
return $parsedDate->format('Y-m-d');
}
}
// Fallback - handles ISO 8601 (2026-07-05T04:00:00Z) and most everything else
$parsedDate = date_create($expireDate);
if ($parsedDate) {
$year = (int) $parsedDate->format('Y');
// Reject obviously bogus parses
if ($year >= 1995 && $year <= ((int) date('Y') + 100)) {
return $parsedDate->format('Y-m-d');
}
}
return null;
}
// ============================================================
// Domains in the app - what the list, the quick look, the forms
// and the refresher share
// ============================================================
/** Expiring within this many days is "soon" - the same 45 days the first expiry alert goes out at. */
const DOMAIN_SOON_DAYS = 45;
/** DKIM selectors looked for - Microsoft 365 (selector1, selector2), Google Workspace, the usual defaults and Mailchimp. */
const DOMAIN_DKIM_SELECTORS = ['selector1', 'selector2', 'google', 'default', 'k1', 's1'];
/** Who does what for a domain: each role, its column and how it is shown. */
const DOMAIN_ROLES = [
'registrar' => ['column' => 'domain_registrar', 'label' => 'Registrar', 'icon' => 'fa-id-card'],
'dnshost' => ['column' => 'domain_dnshost', 'label' => 'DNS', 'icon' => 'fa-sitemap'],
'webhost' => ['column' => 'domain_webhost', 'label' => 'Website', 'icon' => 'fa-server'],
'mailhost' => ['column' => 'domain_mailhost', 'label' => 'Email', 'icon' => 'fa-envelope'],
];
/** What each tracked column is called in a domain's history. */
const DOMAIN_HISTORY_LABELS = [
'domain_name' => 'Name', 'domain_description' => 'Description', 'domain_expire' => 'Expiry', 'domain_auto_renew' => 'Auto-renew',
'registrar_name' => 'Registrar', 'dnshost_name' => 'DNS host', 'webhost_name' => 'Web host', 'mailhost_name' => 'Mail host',
'domain_ip' => 'A records', 'domain_name_servers' => 'Name servers', 'domain_mail_servers' => 'MX records', 'domain_txt' => 'TXT records',
'domain_dmarc' => 'DMARC', 'domain_dkim' => 'DKIM', 'domain_raw_whois' => 'WHOIS', 'domain_notes' => 'Notes',
];
/** May this agent do this with domains? 1 is to see them, 2 to change them, 3 to delete them. */
function domainCan($level) {
return lookupUserPermission('module_support') >= $level;
}
/** The columns and joins for a domain with its client and the vendor behind each role (registrar_name, dnshost_name...). */
function domainSelectSql() {
$columns = 'domains.*, client_name';
$joins = 'LEFT JOIN clients ON client_id = domain_client_id';
foreach (DOMAIN_ROLES as $role => $info) {
$columns .= ", $role.vendor_name AS {$role}_name, $role.vendor_client_id AS {$role}_client_id, $role.vendor_archived_at AS {$role}_archived_at";
$joins .= " LEFT JOIN vendors AS $role ON $role.vendor_id = domains.{$info['column']}";
}
return [$columns, $joins];
}
/** One domain by id with its client and vendors - raw, or null. */
function domainRow($domain_id) {
global $mysqli;
$domain_id = intval($domain_id);
if (!$domain_id) {
return null;
}
[$columns, $joins] = domainSelectSql();
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT $columns FROM domains $joins WHERE domain_id = $domain_id LIMIT 1"));
return $row ?: null;
}
/**
* A domain name as it is kept - lowercase, without a scheme, a path, a port,
* a leading www. or a trailing dot, and international names in their ASCII
* form - from whatever was pasted ("https://www.Example.com/contact"). '' when
* it is not a domain name.
*/
function domainNormalizeName($input) {
$name = strtolower(trim(html_entity_decode((string) $input, ENT_QUOTES, 'UTF-8')));
$name = preg_replace('#^[a-z][a-z0-9+.\-]*://#', '', $name);
$name = preg_replace('#^[^@/]*@#', '', $name);
$name = preg_split('#[/?\#\s]#', $name)[0];
$name = preg_replace('#:\d+$#', '', $name);
$name = trim($name, '.');
$name = preg_replace('#^www\.#', '', $name);
if ($name !== '' && preg_match('/[^\x20-\x7e]/', $name) && function_exists('idn_to_ascii')) {
$ascii = idn_to_ascii($name, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
$name = $ascii ?: $name;
}
if ($name === '' || strlen($name) > 200 || strpos($name, '.') === false
|| !filter_var($name, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)
|| !preg_match('/\.[a-z][a-z0-9\-]*[a-z0-9]$|\.xn--[a-z0-9\-]+$/', $name)) {
return '';
}
return $name;
}
/** A vendor id a domain may use - one of the client's own, or one of ours (the MSP hosting DNS or mail) - else 0. */
function domainVendorId($vendor_id, $client_id) {
global $mysqli;
$vendor_id = intval($vendor_id);
$client_id = intval($client_id);
if (!$vendor_id) {
return 0;
}
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT vendor_id FROM vendors WHERE vendor_id = $vendor_id AND vendor_client_id IN ($client_id, 0) LIMIT 1"));
return $row ? intval($row[0]) : 0;
}
/** The vendors a domain's pickers offer - the client's, then ours - plus any already picked, archived or not. */
function domainVendorChoices($client_id, array $keep_ids = []) {
global $mysqli;
$client_id = intval($client_id);
$keep = implode(',', array_filter(array_map('intval', $keep_ids))) ?: '0';
$sql = mysqli_query($mysqli, "SELECT vendor_id, vendor_name, vendor_description, vendor_client_id FROM vendors
WHERE (vendor_client_id IN ($client_id, 0) AND vendor_archived_at IS NULL) OR vendor_id IN ($keep)
ORDER BY vendor_client_id = 0, vendor_favorite DESC, vendor_name ASC");
$rows = [];
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}
/**
* The fields a New / Edit form posts, as a SQL SET list, with the tidied name.
* Null with $error set when it cannot be saved: not a domain name, already on
* this client, or an expiry that is not a date. The vendors must be the
* client's or ours.
*/
function domainFormSet($post, $client_id, $domain_id = 0, &$error = '') {
global $mysqli;
$error = '';
$client_id = intval($client_id);
$domain_id = intval($domain_id);
$name = domainNormalizeName($post['name'] ?? '');
if ($name === '') {
$error = "That isn't a domain name - something like example.com.";
return null;
}
$name_sql = escapeSql($name);
if (mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM domains WHERE domain_name = '$name_sql' AND domain_client_id = $client_id AND domain_id <> $domain_id LIMIT 1"))) {
$error = "$name is already on this client.";
return null;
}
$expire = trim((string) ($post['expire'] ?? ''));
if ($expire !== '') {
$date = DateTime::createFromFormat('!Y-m-d', $expire);
if (!$date || $date->format('Y-m-d') !== $expire) {
$error = 'The expiry date is not a date.';
return null;
}
}
$fields = [
'domain_name' => "'$name_sql'",
'domain_description' => "'" . escapeSql(mb_substr(trim((string) ($post['description'] ?? '')), 0, 500)) . "'",
'domain_expire' => $expire === '' ? 'NULL' : "'$expire'",
'domain_auto_renew' => intval($post['auto_renew'] ?? 0) ? 1 : 0,
'domain_favorite' => intval($post['favorite'] ?? 0) ? 1 : 0,
'domain_notes' => "'" . escapeSql(trim((string) ($post['notes'] ?? ''))) . "'",
];
foreach (DOMAIN_ROLES as $role => $info) {
$fields[$info['column']] = domainVendorId($post[$role] ?? 0, $client_id);
}
$set = [];
foreach ($fields as $column => $value) {
$set[] = "$column = $value";
}
return ['set' => implode(', ', $set), 'name' => $name, 'expire' => $expire];
}
/** Replace a domain's tags with these - domain tags (type 7) only. */
function domainSetTags($domain_id, $tag_ids) {
global $mysqli;
$domain_id = intval($domain_id);
mysqli_query($mysqli, "DELETE FROM domain_tags WHERE domain_id = $domain_id");
$tag_ids = array_values(array_unique(array_filter(array_map('intval', (array) $tag_ids))));
if (!$tag_ids) {
return;
}
mysqli_query($mysqli, "INSERT IGNORE INTO domain_tags (domain_id, tag_id)
SELECT $domain_id, tag_id FROM tags WHERE tag_type = 7 AND tag_id IN (" . implode(',', $tag_ids) . ")");
}
/**
* Look a domain up - DNS (A, NS, MX, TXT, DMARC, DKIM), registration data
* and the expiry date. Null when nothing answers for it (a typo, a lapsed
* domain, or no network), so callers keep what they had rather than wiping it.
*/
function domainLookup($name) {
$name = domainNormalizeName($name);
if ($name === '' || !@checkdnsrr($name, 'SOA')) {
return null;
}
$records = getDnsRecords($name);
$expire = getDomainExpirationDate($name);
$records['expire'] = $expire ?: (string) $records['expire'];
return $records;
}
/**
* Save what a lookup found. The expiry is the registry's when it gave one,
* otherwise the date already there stays (some registries don't publish it);
* the WHOIS summary stays when this lookup got none. False when there was
* nothing to save.
*/
function domainApplyLookup($domain_id, $lookup) {
global $mysqli;
$domain_id = intval($domain_id);
if (!$lookup || !$domain_id) {
return false;
}
$set = [
"domain_ip = '" . escapeSql($lookup['a']) . "'",
"domain_name_servers = '" . escapeSql(mb_substr($lookup['ns'], 0, 255)) . "'",
"domain_mail_servers = '" . escapeSql(mb_substr($lookup['mx'], 0, 255)) . "'",
"domain_txt = '" . escapeSql($lookup['txt']) . "'",
"domain_dmarc = '" . escapeSql($lookup['dmarc'] ?? '') . "'",
"domain_dkim = '" . escapeSql(mb_substr($lookup['dkim'] ?? '', 0, 255)) . "'",
'domain_refreshed_at = NOW()',
];
if (trim((string) $lookup['whois']) !== '') {
$set[] = "domain_raw_whois = '" . escapeSql($lookup['whois']) . "'";
}
if (!empty($lookup['expire']) && strtotime($lookup['expire'])) {
$set[] = "domain_expire = '" . escapeSql($lookup['expire']) . "'";
}
mysqli_query($mysqli, "UPDATE domains SET " . implode(', ', $set) . " WHERE domain_id = $domain_id");
return true;
}
/** What a domain's history tracks, as it stands now - compared before and after a change by domainWriteHistory(). */
function domainHistorySnapshot($domain_id) {
$row = domainRow($domain_id);
if (!$row) {
return [];
}
$snapshot = [];
foreach (array_keys(DOMAIN_HISTORY_LABELS) as $column) {
$snapshot[$column] = (string) ($row[$column] ?? '');
}
return $snapshot;
}
/** Record what changed between two snapshots in the domain's history. */
function domainWriteHistory($domain_id, array $before, array $after) {
global $mysqli;
$domain_id = intval($domain_id);
foreach ($after as $column => $new_value) {
$old_value = $before[$column] ?? '';
if ((string) $old_value === (string) $new_value) {
continue;
}
mysqli_query($mysqli, "INSERT INTO domain_history SET domain_history_column = '" . escapeSql($column) . "',
domain_history_old_value = '" . escapeSql($old_value) . "', domain_history_new_value = '" . escapeSql($new_value) . "',
domain_history_domain_id = $domain_id");
}
}
/** A domain's history, newest first - each change with its label. */
function domainHistory($domain_id, $limit = 25) {
global $mysqli;
$domain_id = intval($domain_id);
$limit = max(1, intval($limit));
$sql = mysqli_query($mysqli, "SELECT domain_history_column, domain_history_old_value, domain_history_new_value, domain_history_modified_at
FROM domain_history WHERE domain_history_domain_id = $domain_id
ORDER BY domain_history_modified_at DESC, domain_history_id DESC LIMIT $limit");
$rows = [];
while ($row = mysqli_fetch_assoc($sql)) {
$row['label'] = DOMAIN_HISTORY_LABELS[$row['domain_history_column']] ?? ucfirst(str_replace('_', ' ', preg_replace('/^domain_/', '', $row['domain_history_column'])));
$rows[] = $row;
}
return $rows;
}
/**
* When it expires: 'expired', 'soon' (within 45 days) or 'active', with a
* tone, a line to show and a short form for the list. A domain set to
* auto-renew is blue rather than amber when it is close. Null without a date.
*/
function domainExpiry($expire, $auto_renew = 0) {
$renewal = softwareRenewal($expire, DOMAIN_SOON_DAYS);
if (!$renewal) {
return null;
}
$days = $renewal['days'];
$auto = intval($auto_renew) === 1;
if ($renewal['state'] === 'expired') {
$renewal['short'] = 'Expired';
} elseif ($renewal['state'] === 'soon') {
$when = $days === 0 ? 'today' : ($days === 1 ? 'tomorrow' : "in $days days");
$renewal['text'] = ($auto ? 'Auto-renews ' : 'Expires ') . $when;
$renewal['short'] = $days === 0 ? 'Today' : ($days === 1 ? 'Tomorrow' : "$days days");
$renewal['tone'] = $auto ? 'info' : ($days <= 7 ? 'danger' : 'warning');
} else {
$renewal['text'] = ($auto ? 'Auto-renews ' : 'Expires ') . timeAgo($renewal['date']);
$renewal['short'] = timeAgo($renewal['date']);
}
$renewal['auto'] = $auto;
return $renewal;
}
/** The expiry as a soft pill - red expired, amber soon, blue soon but auto-renewing, otherwise green. */
function domainExpiryPill($expiry, $use_text = false) {
if (!$expiry) {
return '<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle fw-normal" title="No expiry date - look it up or set it">'
. '<i class="fas fa-question me-1"></i>Unknown</span>';
}
$tone = $expiry['tone'];
$icon = $expiry['state'] === 'expired' ? 'fa-calendar-times' : ($expiry['auto'] ? 'fa-sync-alt' : ($expiry['state'] === 'soon' ? 'fa-hourglass-half' : 'fa-calendar-check'));
return '<span class="badge rounded-pill bg-' . $tone . '-subtle text-' . $tone . '-emphasis border border-' . $tone . '-subtle fw-normal" title="' . escapeHtml($expiry['date'] . ($expiry['auto'] ? ' - set to auto-renew' : '')) . '">'
. '<i class="fas ' . $icon . ' me-1"></i>' . escapeHtml($use_text ? $expiry['text'] : $expiry['short']) . '</span>';
}
/** A domain's tile - a globe, in the same rounded square as the other tiles. $size 'xs' | 'sm' | '' | 'lg'. */
function domainTile($size = '') {
$size_class = in_array($size, ['xs', 'sm', 'lg'], true) ? " itflow-asset-icon-$size" . ($size === 'xs' ? ' itflow-vendor-tile-xs' : '') : '';
return '<span class="itflow-asset-icon' . $size_class . ' itflow-asset-tone-network" aria-hidden="true"><i class="fas fa-fw fa-globe"></i></span>';
}
/** A domain wherever a page mentions one: its tile and its name, opening its quick look. 'archived' strikes it through. */
function domainLink($domain_id, $name, $options = []) {
$domain_id = intval($domain_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 '<span class="itflow-contact-ref">' . domainTile('xs')
. "<a class=\"ajax-modal $class\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/domain/domain.php?id=$domain_id\">$label</a></span>";
}
/** Record lines kept one per line (or comma-separated in older data) as a list. */
function domainLines($text) {
return array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', (string) $text)), 'strlen'));
}
/**
* Who does what - each vendor with the roles it holds for this domain, so a
* registrar that also runs DNS and the website shows once: [vendor_id =>
* ['name', 'client_id', 'archived', 'roles' => [labels]]].
*/
function domainVendors($row) {
$vendors = [];
foreach (DOMAIN_ROLES as $role => $info) {
$vendor_id = intval($row[$info['column']] ?? 0);
if (!$vendor_id || ($row["{$role}_name"] ?? null) === null) {
continue;
}
if (!isset($vendors[$vendor_id])) {
$vendors[$vendor_id] = ['name' => $row["{$role}_name"], 'client_id' => intval($row["{$role}_client_id"]), 'archived' => !empty($row["{$role}_archived_at"]), 'roles' => []];
}
$vendors[$vendor_id]['roles'][] = $info['label'];
}
return $vendors;
}
/** The vendors as small links, each with the roles it holds. */
function domainVendorsHtml($row) {
$html = '';
foreach (domainVendors($row) as $vendor_id => $vendor) {
$html .= '<div class="d-flex align-items-center gap-2 min-w-0">' . vendorLink($vendor_id, $vendor['name'], ['archived' => $vendor['archived']])
. '<span class="small text-body-secondary text-nowrap">' . escapeHtml(implode(' · ', $vendor['roles'])) . '</span></div>';
}
return $html;
}
/**
* The email checks MSPs get asked about - is there mail (MX), SPF and how
* strict it is, the DMARC policy, DKIM at the common selectors. Each is
* [label, state ok|warn|bad|off|unknown, text]. DMARC and DKIM are unknown
* until the domain has been looked up since those checks existed.
*/
function domainMailChecks($row) {
$looked_up = !empty($row['domain_refreshed_at']);
$has_mail = domainLines($row['domain_mail_servers'] ?? '') !== [];
$spf = array_values(array_filter(domainLines(str_replace(',', ' ', (string) ($row['domain_txt'] ?? ''))), function ($line) {
return stripos(trim($line, '"'), 'v=spf1') === 0;
}));
$checks = [];
$checks['mx'] = $has_mail
? ['label' => 'MX', 'state' => 'ok', 'text' => 'Takes mail at ' . implode(', ', array_map(function ($mx) { return preg_replace('/^\d+\s+/', '', $mx); }, array_slice(domainLines($row['domain_mail_servers']), 0, 2)))]
: ['label' => 'MX', 'state' => 'off', 'text' => 'No mail servers - it takes no email'];
if (count($spf) > 1) {
$checks['spf'] = ['label' => 'SPF', 'state' => 'bad', 'text' => count($spf) . ' SPF records - receivers treat that as no SPF at all. Merge them into one.'];
} elseif (!$spf) {
$checks['spf'] = ['label' => 'SPF', 'state' => $has_mail ? 'bad' : 'warn', 'text' => $has_mail ? 'No SPF record - anyone can send as this domain' : 'No SPF record - add "v=spf1 -all" so nobody can send as it'];
} else {
$record = trim($spf[0], '"');
if (preg_match('/[\-~]all\b/i', $record) || stripos($record, 'redirect=') !== false) {
$checks['spf'] = ['label' => 'SPF', 'state' => 'ok', 'text' => (stripos($record, '-all') !== false ? 'SPF, hard fail (-all)' : (stripos($record, '~all') !== false ? 'SPF, soft fail (~all)' : 'SPF, redirected')) . ': ' . $record];
} else {
$checks['spf'] = ['label' => 'SPF', 'state' => 'warn', 'text' => 'SPF that lets anyone through (no -all or ~all): ' . $record];
}
}
$dmarc = trim((string) ($row['domain_dmarc'] ?? ''));
if (!$looked_up && $dmarc === '') {
$checks['dmarc'] = ['label' => 'DMARC', 'state' => 'unknown', 'text' => 'Not checked yet - look the domain up'];
} elseif ($dmarc === '') {
$checks['dmarc'] = ['label' => 'DMARC', 'state' => $has_mail ? 'bad' : 'warn', 'text' => 'No DMARC record'];
} else {
$policy = preg_match('/\bp\s*=\s*(none|quarantine|reject)/i', $dmarc, $match) ? strtolower($match[1]) : 'none';
$checks['dmarc'] = $policy === 'none'
? ['label' => 'DMARC', 'state' => 'warn', 'text' => 'DMARC p=none - reports only, spoofed mail still delivered: ' . $dmarc]
: ['label' => 'DMARC', 'state' => 'ok', 'text' => "DMARC p=$policy: $dmarc"];
}
$dkim = domainLines($row['domain_dkim'] ?? '');
if (!$looked_up && !$dkim) {
$checks['dkim'] = ['label' => 'DKIM', 'state' => 'unknown', 'text' => 'Not checked yet - look the domain up'];
} elseif ($dkim) {
$checks['dkim'] = ['label' => 'DKIM', 'state' => 'ok', 'text' => 'DKIM at ' . implode(', ', $dkim)];
} else {
$checks['dkim'] = ['label' => 'DKIM', 'state' => $has_mail ? 'warn' : 'off', 'text' => 'No DKIM at the usual selectors (' . implode(', ', DOMAIN_DKIM_SELECTORS) . ') - it may use another'];
}
return $checks;
}
/** The email checks as small pills - green, amber, red, grey. */
function domainMailPillsHtml(array $checks) {
$tones = ['ok' => 'success', 'warn' => 'warning', 'bad' => 'danger', 'off' => 'secondary', 'unknown' => 'secondary'];
$icons = ['ok' => 'fa-check', 'warn' => 'fa-exclamation', 'bad' => 'fa-times', 'off' => 'fa-minus', 'unknown' => 'fa-question'];
$html = '';
foreach ($checks as $check) {
$tone = $tones[$check['state']];
$html .= '<span class="badge rounded-pill bg-' . $tone . '-subtle text-' . $tone . '-emphasis border border-' . $tone . '-subtle fw-normal" title="' . escapeHtml($check['text']) . '">'
. '<i class="fas ' . $icons[$check['state']] . ' me-1"></i>' . escapeHtml($check['label']) . '</span>';
}
return '<span class="d-inline-flex flex-wrap gap-1">' . $html . '</span>';
}
/** The SQL for "takes mail but SPF is missing or DMARC is missing or only p=none" - the Email gaps filter. */
function domainMailGapSql($alias = 'domains') {
return "(COALESCE($alias.domain_mail_servers, '') <> '' AND (COALESCE($alias.domain_txt, '') NOT LIKE '%v=spf1%'
OR ($alias.domain_refreshed_at IS NOT NULL AND (COALESCE($alias.domain_dmarc, '') = '' OR $alias.domain_dmarc LIKE '%p=none%'))))";
}
/** The registration facts in the WHOIS summary - registrar, registered, status - when it has them one per line. */
function domainRegistrationFacts($whois) {
$facts = [];
foreach (['registrar' => 'Registrar', 'registered' => 'Registered', 'status' => 'Status'] as $key => $label) {
if (preg_match('/^' . $label . ':\s*(.+)$/mi', (string) $whois, $match) && strpos((string) $whois, "\n") !== false) {
$facts[$key] = trim($match[1]);
}
}
return $facts;
}
/** The certificates for a domain - linked to it, or for it or one of its subdomains on the same client. */
function domainCertificates($row) {
global $mysqli;
$domain_id = intval($row['domain_id']);
$client_id = intval($row['domain_client_id']);
$name = escapeSql($row['domain_name']);
$name_like = escapeSql(str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $row['domain_name']));
$sql = mysqli_query($mysqli, "SELECT certificate_id, certificate_name, certificate_domain, certificate_issued_by, certificate_issued_at, certificate_expire,
certificate_check_status, certificate_checked_at FROM certificates
WHERE certificate_archived_at IS NULL AND certificate_client_id = $client_id
AND (certificate_domain_id = $domain_id OR certificate_domain = '$name' OR certificate_domain LIKE '%.$name_like')
ORDER BY certificate_expire IS NULL, certificate_expire ASC");
$rows = [];
while ($cert = mysqli_fetch_assoc($sql)) {
$rows[] = $cert;
}
return $rows;
}
/** The client's logins for the domain's registrar and hosts - linked to those vendors - for those who may see credentials. */
function domainLogins($row) {
$vendor_ids = array_keys(domainVendors($row));
if (!$vendor_ids || lookupUserPermission('module_credential') < 1) {
return [];
}
$client_id = intval($row['domain_client_id']);
return credentialRows("credential_client_id = $client_id AND credential_id IN (SELECT credential_id FROM vendor_credentials WHERE vendor_id IN (" . implode(',', array_map('intval', $vendor_ids)) . "))");
}
/** Deleting a domain for good - services stop listing it and certificates stop pointing at it. History and tags cascade. */
function domainDeleteForGood($domain_id) {
global $mysqli;
$domain_id = intval($domain_id);
mysqli_query($mysqli, "DELETE FROM service_domains WHERE domain_id = $domain_id");
mysqli_query($mysqli, "UPDATE certificates SET certificate_domain_id = 0 WHERE certificate_domain_id = $domain_id");
mysqli_query($mysqli, "DELETE FROM domains WHERE domain_id = $domain_id");
}
/**
* Bring an SSL certificate for a new domain in, when its website has one and
* the client has none for that name yet. True when one was added.
*/
function domainAddCertificate($domain_id, $name, $client_id) {
global $mysqli;
$domain_id = intval($domain_id);
$client_id = intval($client_id);
$name_sql = escapeSql($name);
if (mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM certificates WHERE certificate_client_id = $client_id AND certificate_domain IN ('$name_sql', 'www.$name_sql') LIMIT 1"))) {
return false;
}
$check = certificateCheck($name);
if (empty($check['reachable'])) {
return false;
}
mysqli_query($mysqli, "INSERT INTO certificates SET certificate_name = '$name_sql', certificate_domain = '$name_sql', certificate_issued_by = '',
certificate_domain_id = $domain_id, certificate_client_id = $client_id");
certificateApplyCheck(mysqli_insert_id($mysqli), $check);
return true;
}