app/Services/Probe.php
Rama main · réplica de lectura del repositorio original.
<?php
namespace App\Services;
use App\Enums\CheckType;
use App\Models\Check;
use GuzzleHttp\TransferStats;
use Illuminate\Support\Carbon;
/**
* Performs one check. Kept free of database writes so it can be tested and
* swapped out (an external probe network would implement the same contract).
*
* Targets go through SafeHttp like every other URL an operator types: a check
* may watch the LAN it belongs to, never this machine or the cloud metadata
* address, and it follows no redirect — the response code is the result.
*/
class Probe
{
public function __construct(protected ?SafeHttp $safe = null)
{
$this->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');
}
}