96 lines
3.4 KiB
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);
|
|
}
|
|
}
|