Skip to content

Commit

Permalink
Use HttpClient to gather certificates in certmonitor too
Browse files Browse the repository at this point in the history
  • Loading branch information
spaze committed Sep 25, 2023
1 parent 6914360 commit bf3d6df
Show file tree
Hide file tree
Showing 12 changed files with 237 additions and 80 deletions.
79 changes: 51 additions & 28 deletions site/app/Http/Client/HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,38 @@

namespace MichalSpacekCz\Http\Client;

use MichalSpacekCz\Http\Exceptions\HttpClientGetException;
use MichalSpacekCz\Http\Exceptions\HttpClientRequestException;
use MichalSpacekCz\Http\Exceptions\HttpStreamException;

class HttpClient
{

/**
* @param array<string, string|int> $httpOptions
* @param array<int, string> $httpHeaders
* @param array<string, string|bool> $sslOptions
* @param array<string, string|bool> $tlsOptions
* @return resource
*/
public function createStreamContext(?string $userAgent = null, array $httpOptions = [], array $httpHeaders = [], array $sslOptions = [])
private function createStreamContext(HttpClientRequest $request, array $httpOptions = [], array $tlsOptions = [])
{
$httpOptions += [
$httpOptions = [
'ignore_errors' => true, // To suppress PHP Warning: [...] HTTP/1.0 500 Internal Server Error
'header' => $httpHeaders,
];
if ($userAgent) {
$httpOptions += [
'user_agent' => str_replace('\\', '/', $userAgent),
];
'header' => $request->getHeaders(),
] + $httpOptions;
if ($request->getUserAgent() !== null) {
$httpOptions = ['user_agent' => str_replace('\\', '/', $request->getUserAgent())] + $httpOptions;
}
if ($request->getFollowLocation() !== null) {
$httpOptions = ['follow_location' => (int)$request->getFollowLocation()] + $httpOptions;
}
if ($request->getTlsCaptureCertificate() !== null) {
$tlsOptions = ['capture_peer_cert' => $request->getTlsCaptureCertificate()] + $tlsOptions;
}
if ($request->getTlsServerName() !== null) {
$tlsOptions = ['peer_name' => $request->getTlsServerName()] + $tlsOptions;
}
return stream_context_create(
[
'ssl' => $sslOptions,
'ssl' => $tlsOptions,
'http' => $httpOptions,
],
[
Expand All @@ -43,48 +49,65 @@ public function createStreamContext(?string $userAgent = null, array $httpOption


/**
* @throws HttpClientGetException
* @throws HttpClientRequestException
*/
public function get(HttpClientRequest $request): HttpClientResponse
{
$context = $this->createStreamContext($request);
return $this->request($request, $context);
}


/**
* @throws HttpClientRequestException
*/
public function get(HttpClientRequest $request): string
public function head(HttpClientRequest $request): HttpClientResponse
{
$context = $this->createStreamContext(userAgent: $request->getUserAgent(), httpHeaders: $request->getHeaders());
$context = $this->createStreamContext(
$request,
['method' => 'HEAD'],
);
return $this->request($request, $context);
}


/**
* @param array<string, string> $formData
* @throws HttpClientGetException
* @throws HttpClientRequestException
*/
public function postForm(HttpClientRequest $request, array $formData = []): string
public function postForm(HttpClientRequest $request, array $formData = []): HttpClientResponse
{
$request->addHeader('Content-Type', 'application/x-www-form-urlencoded');
$context = $this->createStreamContext(
$request->getUserAgent(),
$request,
['method' => 'POST', 'content' => http_build_query($formData)],
['Content-Type: application/x-www-form-urlencoded'] + $request->getHeaders(),
);
return $this->request($request, $context);
}


/**
* @param HttpClientRequest $request
* @param resource $context
* @return string
* @throws HttpClientGetException
* @throws HttpClientRequestException
* @noinspection PhpRedundantCatchClauseInspection A notification callback created by self::createStreamContext() may throw HttpStreamException
*/
private function request(HttpClientRequest $request, $context): string
private function request(HttpClientRequest $request, $context): HttpClientResponse
{
try {
$result = file_get_contents($request->getUrl(), false, $context);
$fp = fopen($request->getUrl(), 'r', context: $context);
if (!$fp) {
throw new HttpClientRequestException($request->getUrl());
}
$result = stream_get_contents($fp);
$options = stream_context_get_options($fp);
fclose($fp);
} catch (HttpStreamException $e) {
throw new HttpClientGetException($request->getUrl(), $e->getCode(), $e);
throw new HttpClientRequestException($request->getUrl(), $e->getCode(), $e);
}
if (!$result) {
throw new HttpClientGetException($request->getUrl());
if ($result === false) {
throw new HttpClientRequestException($request->getUrl());
}
return $result;
return new HttpClientResponse($request, $result, $options['ssl']['peer_certificate'] ?? null);
}

}
45 changes: 45 additions & 0 deletions site/app/Http/Client/HttpClientRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ final class HttpClientRequest
/** @var list<string> */
private array $headers = [];

private ?bool $followLocation = null;

private ?string $tlsServerName = null;

private ?bool $tlsCaptureCertificate = null;


public function __construct(
private readonly string $url,
Expand Down Expand Up @@ -52,4 +58,43 @@ public function getHeaders(): array
return $this->headers;
}


public function getFollowLocation(): ?bool
{
return $this->followLocation;
}


public function setFollowLocation(bool $followLocation): self
{
$this->followLocation = $followLocation;
return $this;
}


public function getTlsServerName(): ?string
{
return $this->tlsServerName;
}


public function setTlsServerName(string $tlsServerName): self
{
$this->tlsServerName = $tlsServerName;
return $this;
}


public function getTlsCaptureCertificate(): ?bool
{
return $this->tlsCaptureCertificate;
}


public function setTlsCaptureCertificate(bool $tlsCaptureCertificate): self
{
$this->tlsCaptureCertificate = $tlsCaptureCertificate;
return $this;
}

}
43 changes: 43 additions & 0 deletions site/app/Http/Client/HttpClientResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php
declare(strict_types = 1);

namespace MichalSpacekCz\Http\Client;

use MichalSpacekCz\Http\Exceptions\HttpClientTlsCertificateNotAvailableException;
use MichalSpacekCz\Http\Exceptions\HttpClientTlsCertificateNotCapturedException;
use OpenSSLCertificate;

class HttpClientResponse
{

public function __construct(
private readonly HttpClientRequest $request,
private readonly string $body,
private readonly ?OpenSSLCertificate $tlsCertificate,
) {
}


public function getBody(): string
{
return $this->body;
}


/**
* @throws HttpClientTlsCertificateNotAvailableException
* @throws HttpClientTlsCertificateNotCapturedException
*/
public function getTlsCertificate(): OpenSSLCertificate
{
$scheme = parse_url($this->request->getUrl(), PHP_URL_SCHEME);
if (!is_string($scheme) || strtolower($scheme) !== 'https') {
throw new HttpClientTlsCertificateNotAvailableException($this->request->getUrl());
}
if (!$this->request->getTlsCaptureCertificate() || !$this->tlsCertificate) {
throw new HttpClientTlsCertificateNotCapturedException();
}
return $this->tlsCertificate;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use Exception;
use Throwable;

class HttpClientGetException extends Exception
class HttpClientRequestException extends Exception
{

public function __construct(string $url, int $code = 0, ?Throwable $previous = null)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php
declare(strict_types = 1);

namespace MichalSpacekCz\Http\Exceptions;

use Exception;
use Throwable;

class HttpClientTlsCertificateNotAvailableException extends Exception
{

public function __construct(string $url, ?Throwable $previous = null)
{
parent::__construct("Can't get TLS certificate because the request is not HTTPS: {$url}", previous: $previous);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php
declare(strict_types = 1);

namespace MichalSpacekCz\Http\Exceptions;

use Exception;
use Throwable;

class HttpClientTlsCertificateNotCapturedException extends Exception
{

public function __construct(?Throwable $previous = null)
{
parent::__construct("TLS certificate wasn't captured, HttpClientRequest::setTlsCaptureCertificate(true) not called", previous: $previous);
}

}
11 changes: 6 additions & 5 deletions site/app/Test/Http/Client/HttpClientMock.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,23 @@

use MichalSpacekCz\Http\Client\HttpClient;
use MichalSpacekCz\Http\Client\HttpClientRequest;
use MichalSpacekCz\Http\Client\HttpClientResponse;

class HttpClientMock extends HttpClient
{

private string $getResult = '';
private string $response = '';


public function setGetResult(string $getResult): void
public function setResponse(string $response): void
{
$this->getResult = $getResult;
$this->response = $response;
}


public function get(HttpClientRequest $request): string
public function get(HttpClientRequest $request): HttpClientResponse
{
return $this->getResult;
return new HttpClientResponse($request, $this->response, null);
}

}
38 changes: 17 additions & 21 deletions site/app/Tls/CertificateGatherer.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@

use MichalSpacekCz\DateTime\Exceptions\CannotParseDateTimeException;
use MichalSpacekCz\Http\Client\HttpClient;
use MichalSpacekCz\Http\Client\HttpClientRequest;
use MichalSpacekCz\Http\Exceptions\HttpClientRequestException;
use MichalSpacekCz\Http\Exceptions\HttpClientTlsCertificateNotAvailableException;
use MichalSpacekCz\Http\Exceptions\HttpClientTlsCertificateNotCapturedException;
use MichalSpacekCz\Net\DnsResolver;
use MichalSpacekCz\Net\Exceptions\DnsGetRecordException;
use MichalSpacekCz\Tls\Exceptions\CertificateException;
Expand All @@ -31,6 +35,9 @@ public function __construct(
* @throws OpenSslException
* @throws DnsGetRecordException
* @throws OpenSslX509ParseException
* @throws HttpClientRequestException
* @throws HttpClientTlsCertificateNotAvailableException
* @throws HttpClientTlsCertificateNotCapturedException
*/
public function fetchCertificates(string $hostname, bool $includeIpv6): array
{
Expand All @@ -56,30 +63,19 @@ public function fetchCertificates(string $hostname, bool $includeIpv6): array
* @throws CertificateException
* @throws CannotParseDateTimeException
* @throws OpenSslX509ParseException
* @throws HttpClientRequestException
* @throws HttpClientTlsCertificateNotAvailableException
* @throws HttpClientTlsCertificateNotCapturedException
*/
private function fetchCertificate(string $hostname, string $ipAddress): Certificate
{
$url = "https://{$ipAddress}/";
$fp = fopen($url, 'r', context: $this->httpClient->createStreamContext(
__METHOD__,
[
'method' => 'HEAD',
'follow_location' => 0,
],
[
"Host: {$hostname}",
],
[
'capture_peer_cert' => true,
'peer_name' => $hostname,
],
));
if (!$fp) {
throw new CertificateException("Unable to open {$url}");
}
$options = stream_context_get_options($fp);
fclose($fp);
return $this->certificateFactory->fromObject($options['ssl']['peer_certificate']);
$request = new HttpClientRequest("https://{$ipAddress}/");
$request->setUserAgent(__METHOD__);
$request->setFollowLocation(false);
$request->addHeader('Host', $hostname);
$request->setTlsCaptureCertificate(true);
$request->setTlsServerName($hostname);
return $this->certificateFactory->fromObject($this->httpClient->head($request)->getTlsCertificate());
}

}
6 changes: 3 additions & 3 deletions site/app/Tls/CertificatesApiClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use MichalSpacekCz\DateTime\Exceptions\CannotParseDateTimeException;
use MichalSpacekCz\Http\Client\HttpClient;
use MichalSpacekCz\Http\Client\HttpClientRequest;
use MichalSpacekCz\Http\Exceptions\HttpClientGetException;
use MichalSpacekCz\Http\Exceptions\HttpClientRequestException;
use MichalSpacekCz\Tls\Exceptions\CertificatesApiException;
use Nette\Application\LinkGenerator;
use Nette\Application\UI\InvalidLinkException;
Expand Down Expand Up @@ -46,8 +46,8 @@ public function getLoggedCertificates(): array
$json = $this->httpClient->postForm($request, [
'user' => ServerEnv::tryGetString('CERTMONITOR_USER') ?? '',
'key' => ServerEnv::tryGetString('CERTMONITOR_KEY') ?? '',
]);
} catch (HttpClientGetException $e) {
])->getBody();
} catch (HttpClientRequestException $e) {
throw new CertificatesApiException(sprintf('Failure getting data from %s: %s', $request->getUrl(), Helpers::getLastError()), previous: $e);
}
$certificates = [];
Expand Down
Loading

0 comments on commit bf3d6df

Please sign in to comment.