safe ??= new SafeHttp; } public function run(Check $check): ProbeResult { return match ($check->type) { CheckType::Http => $this->http($check), CheckType::Tcp => $this->tcp($check), CheckType::Dns => $this->dns($check), CheckType::Heartbeat => $this->heartbeat($check), }; } protected function http(Check $check): ProbeResult { try { $request = $this->safe->toOwn($check->target); } catch (\RuntimeException $e) { return new ProbeResult(false, null, str_contains($e->getMessage(), 'never allowed') ? 'Target is never allowed' : 'Target cannot be resolved or is invalid'); } $start = microtime(true); try { $expiry = null; $realTransfer = false; $response = $request->timeout($check->timeout_seconds) ->withOptions(TransferLimits::options(1048576, $check->timeout_seconds)) ->withOptions([ 'curl' => [CURLOPT_CERTINFO => true], 'decode_content' => false, 'on_stats' => function (TransferStats $stats) use (&$expiry, &$realTransfer) { $realTransfer = $stats->getHandlerStats() !== []; $cert = $stats->getHandlerStats()['certinfo'][0]['Cert'] ?? null; if ($cert && ($parsed = openssl_x509_parse($cert)) && isset($parsed['validTo_time_t'])) { $expiry = Carbon::createFromTimestampUTC($parsed['validTo_time_t']); } }, ]) ->withHeaders(['User-Agent' => 'Pharos/1.0 (status monitor)', 'Accept-Encoding' => 'identity']) ->get($check->target); $code = $response->status(); if (strtolower((string) parse_url($check->target, PHP_URL_SCHEME)) === 'https' && $realTransfer && ! $expiry) { return new ProbeResult(false, null, 'TLS certificate details unavailable'); } if (strlen($response->body()) > 1048576) { return new ProbeResult(false, null, 'Response exceeds 1 MiB'); } } catch (\Throwable $e) { return new ProbeResult(false, (int) round((microtime(true) - $start) * 1000), $this->reason($e)); } $ms = (int) round((microtime(true) - $start) * 1000); $ok = $code >= 200 && $code < 400; if ($ok && $check->expected_keyword !== null && $check->expected_keyword !== '' && ! str_contains($response->body(), $check->expected_keyword)) { return new ProbeResult(false, $ms, 'Expected text is missing', $expiry); } $soon = $expiry && $expiry <= now()->addDays(14); return new ProbeResult($ok, $ms, $soon ? 'TLS certificate expires '.$expiry->toDateString() : "HTTP $code", $expiry, $ok && $expiry && $expiry <= now()->addDays(3)); } protected function dns(Check $check): ProbeResult { $start = microtime(true); try { $dns = app(DnsResolver::class); $records = $dns->records($check->target, $check->dns_type ?? 'A'); $ok = $dns->matches($check->dns_type ?? 'A', $check->dns_expected ?? '', $records); return new ProbeResult($ok, (int) round((microtime(true) - $start) * 1000), $ok ? 'DNS record matches' : 'Expected DNS record is missing'); } catch (\Throwable $e) { return new ProbeResult(false, null, 'DNS lookup failed'); } } protected function tcp(Check $check): ProbeResult { // host:port, with brackets around an IPv6 literal as in a URL. if (! preg_match('/^(\[[^\]]+\]|[^:]+):(\d{1,5})$/', $check->target, $m)) { return new ProbeResult(false, null, 'Target must be host:port'); } $host = trim($m[1], '[]'); $port = (int) $m[2]; try { $connectTo = $this->safe->resolveOwn($host); } catch (\RuntimeException $e) { return new ProbeResult(false, null, str_contains($e->getMessage(), 'never allowed') ? 'Target is never allowed' : 'Target cannot be resolved or is invalid'); } $connectTo = str_contains($connectTo, ':') ? "[{$connectTo}]" : $connectTo; $start = microtime(true); $sock = @fsockopen($connectTo, $port, $errno, $errstr, $check->timeout_seconds); $ms = (int) round((microtime(true) - $start) * 1000); if ($sock === false) { return new ProbeResult(false, $ms, $errstr ?: 'Connection refused'); } fclose($sock); return new ProbeResult(true, $ms, 'Connected'); } /** One line a person understands, without the curl error number. */ protected function reason(\Throwable $e): string { $message = $e->getMessage(); return match (true) { str_contains($message, 'timed out') => 'Timed out', str_contains($message, 'Could not resolve') => 'Name does not resolve', str_contains($message, 'certificate') => 'TLS certificate problem', $message === '' => 'No response', default => 'No response', }; } /** * Inverted check: something out there is expected to call in. Stale means down. * ponytail: grace is one full interval; make it configurable if flapping shows up. */ protected function heartbeat(Check $check): ProbeResult { if ($check->last_run_at === null) { return new ProbeResult(false, null, 'Never reported in'); } $late = $check->last_run_at->addSeconds($check->interval_seconds * 2)->isPast(); return $late ? new ProbeResult(false, null, 'No heartbeat since '.$check->last_run_at->diffForHumans()) : new ProbeResult(true, null, 'Heartbeat received'); } }