CluPilotCloud/app/Services/Proxy/CertificateInspector.php

96 lines
3.4 KiB
PHP

<?php
namespace App\Services\Proxy;
use Illuminate\Support\Carbon;
/**
* Was ein Besucher wirklich bekommt, wenn er diesen Namen aufruft.
*
* Gemessen, nicht aus der Konfiguration gelesen. Eine Zeile im Caddyfile sagt,
* was gewollt ist; ein TLS-Handschlag sagt, was passiert. Genau zwischen diesen
* beiden lag der Fall, der diese Klasse veranlasst hat: DNS richtig gesetzt,
* `FILES_HOST` richtig gesetzt, Proxy auf derselben Maschine — und trotzdem
* `tlsv1 alert internal error`, weil kein Zertifikat für den Namen existierte.
* Keine der drei Einstellungen hätte das verraten.
*
* Deshalb wird von außen nach innen gefragt und nicht umgekehrt.
*/
class CertificateInspector
{
/**
* @return array{ok: bool, expires_at: ?Carbon, issuer: ?string, error: ?string}
*/
public function inspect(string $hostname, int $timeoutSeconds = 8): array
{
// `verify_peer` bleibt AN. Ein Zertifikat, das die Prüfung nicht
// besteht, ist für diese Frage kein Zertifikat — der Browser des
// Kunden sieht es genauso, und eine Anzeige, die es trotzdem als
// gültig führte, wäre die Attrappe, vor der R19 warnt.
$context = stream_context_create([
'ssl' => [
'capture_peer_cert' => true,
'SNI_enabled' => true,
'peer_name' => $hostname,
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$client = @stream_socket_client(
'ssl://'.$hostname.':443',
$errorNumber,
$errorMessage,
$timeoutSeconds,
STREAM_CLIENT_CONNECT,
$context,
);
if ($client === false) {
return [
'ok' => false,
'expires_at' => null,
'issuer' => null,
// Die Meldung von PHP ist lang und trägt Dateipfade. Was zählt,
// ist die erste Zeile — sie unterscheidet „kein Zertifikat" von
// „Name löst nicht auf", und das sind zwei ganz verschiedene
// Aufgaben für den Betreiber.
'error' => $this->firstLine($errorMessage ?: 'kein Verbindungsaufbau'),
];
}
$params = stream_context_get_params($client);
fclose($client);
$certificate = $params['options']['ssl']['peer_certificate'] ?? null;
if ($certificate === null) {
return ['ok' => false, 'expires_at' => null, 'issuer' => null, 'error' => 'kein Zertifikat vorgelegt'];
}
$parsed = openssl_x509_parse($certificate);
if ($parsed === false || ! isset($parsed['validTo_time_t'])) {
return ['ok' => false, 'expires_at' => null, 'issuer' => null, 'error' => 'Zertifikat nicht lesbar'];
}
return [
'ok' => true,
'expires_at' => Carbon::createFromTimestampUTC($parsed['validTo_time_t']),
'issuer' => $parsed['issuer']['O'] ?? ($parsed['issuer']['CN'] ?? null),
'error' => null,
];
}
private function firstLine(string $message): string
{
$line = trim((string) preg_split('/\R/', $message)[0]);
// Der PHP-Vorspann („stream_socket_client(): ") sagt nichts über die
// Sache und frisst die halbe Spalte.
$line = (string) preg_replace('/^stream_socket_client\(\):\s*/', '', $line);
return mb_substr($line, 0, 190);
}
}