diff --git a/site/app/Http/Client/HttpClient.php b/site/app/Http/Client/HttpClient.php index b6bdd98ad..0e74587bf 100644 --- a/site/app/Http/Client/HttpClient.php +++ b/site/app/Http/Client/HttpClient.php @@ -3,7 +3,7 @@ namespace MichalSpacekCz\Http\Client; -use MichalSpacekCz\Http\Exceptions\HttpClientGetException; +use MichalSpacekCz\Http\Exceptions\HttpClientRequestException; use MichalSpacekCz\Http\Exceptions\HttpStreamException; class HttpClient @@ -11,24 +11,30 @@ class HttpClient /** * @param array $httpOptions - * @param array $httpHeaders - * @param array $sslOptions + * @param array $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, ], [ @@ -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 $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); } } diff --git a/site/app/Http/Client/HttpClientRequest.php b/site/app/Http/Client/HttpClientRequest.php index ad9b6f160..68ec4b49b 100644 --- a/site/app/Http/Client/HttpClientRequest.php +++ b/site/app/Http/Client/HttpClientRequest.php @@ -11,6 +11,12 @@ final class HttpClientRequest /** @var list */ private array $headers = []; + private ?bool $followLocation = null; + + private ?string $tlsServerName = null; + + private ?bool $tlsCaptureCertificate = null; + public function __construct( private readonly string $url, @@ -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; + } + } diff --git a/site/app/Http/Client/HttpClientResponse.php b/site/app/Http/Client/HttpClientResponse.php new file mode 100644 index 000000000..89955696a --- /dev/null +++ b/site/app/Http/Client/HttpClientResponse.php @@ -0,0 +1,43 @@ +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; + } + +} diff --git a/site/app/Http/Exceptions/HttpClientGetException.php b/site/app/Http/Exceptions/HttpClientRequestException.php similarity index 87% rename from site/app/Http/Exceptions/HttpClientGetException.php rename to site/app/Http/Exceptions/HttpClientRequestException.php index a2efe3a64..eff409340 100644 --- a/site/app/Http/Exceptions/HttpClientGetException.php +++ b/site/app/Http/Exceptions/HttpClientRequestException.php @@ -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) diff --git a/site/app/Http/Exceptions/HttpClientTlsCertificateNotAvailableException.php b/site/app/Http/Exceptions/HttpClientTlsCertificateNotAvailableException.php new file mode 100644 index 000000000..ba11777c4 --- /dev/null +++ b/site/app/Http/Exceptions/HttpClientTlsCertificateNotAvailableException.php @@ -0,0 +1,17 @@ +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); } } diff --git a/site/app/Tls/CertificateGatherer.php b/site/app/Tls/CertificateGatherer.php index 75fa7eaa6..0eac8e2b4 100644 --- a/site/app/Tls/CertificateGatherer.php +++ b/site/app/Tls/CertificateGatherer.php @@ -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; @@ -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 { @@ -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()); } } diff --git a/site/app/Tls/CertificatesApiClient.php b/site/app/Tls/CertificatesApiClient.php index 1fb13dc4d..98d52d49c 100644 --- a/site/app/Tls/CertificatesApiClient.php +++ b/site/app/Tls/CertificatesApiClient.php @@ -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; @@ -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 = []; diff --git a/site/app/UpcKeys/Technicolor.php b/site/app/UpcKeys/Technicolor.php index 1cc4df7a5..7d616fab0 100644 --- a/site/app/UpcKeys/Technicolor.php +++ b/site/app/UpcKeys/Technicolor.php @@ -6,7 +6,7 @@ use DateTime; use MichalSpacekCz\Http\Client\HttpClient; use MichalSpacekCz\Http\Client\HttpClientRequest; -use MichalSpacekCz\Http\Exceptions\HttpClientGetException; +use MichalSpacekCz\Http\Exceptions\HttpClientRequestException; use MichalSpacekCz\UpcKeys\Exceptions\UpcKeysApiException; use MichalSpacekCz\UpcKeys\Exceptions\UpcKeysApiIncorrectTokensException; use MichalSpacekCz\UpcKeys\Exceptions\UpcKeysApiResponseInvalidException; @@ -45,7 +45,7 @@ public function getModelWithPrefixes(): array * If the keys are not already in the database, store them. * * @return array - * @throws HttpClientGetException + * @throws HttpClientRequestException */ public function getKeys(string $ssid): array { @@ -71,13 +71,13 @@ public function getKeys(string $ssid): array * @throws UpcKeysApiIncorrectTokensException * @throws UpcKeysApiResponseInvalidException * @throws UpcKeysApiUnknownPrefixException - * @throws HttpClientGetException + * @throws HttpClientRequestException */ private function generateKeys(string $ssid): array { $request = new HttpClientRequest(sprintf($this->apiUrl, $ssid, implode(',', self::PREFIXES))); $request->addHeader('X-API-Key', $this->apiKey); - $json = $this->httpClient->get($request); + $json = $this->httpClient->get($request)->getBody(); try { $data = Json::decode($json); } catch (JsonException $e) { diff --git a/site/tests/Http/HttpClientTest.phpt b/site/tests/Http/HttpClientTest.phpt index 179c0c78c..d3187940f 100644 --- a/site/tests/Http/HttpClientTest.phpt +++ b/site/tests/Http/HttpClientTest.phpt @@ -5,8 +5,10 @@ declare(strict_types = 1); namespace MichalSpacekCz\Http; use MichalSpacekCz\Http\Client\HttpClient; +use MichalSpacekCz\Http\Client\HttpClientRequest; use MichalSpacekCz\Http\Exceptions\HttpStreamException; use MichalSpacekCz\Test\TestCaseRunner; +use ReflectionMethod; use Tester\Assert; use Tester\TestCase; @@ -17,42 +19,55 @@ class HttpClientTest extends TestCase { public function __construct( - private readonly HttpClient $httpStreamContext, + private readonly HttpClient $httpClient, ) { } - public function testCreateStreamContext(): void + public function testCreateStreamContextNotification(): void { $hostname = 'example.com'; - $context = $this->httpStreamContext->createStreamContext( - 'Foo\Bar', + $request = new HttpClientRequest("https://{$hostname}/"); + $request->setUserAgent('Foo\Bar'); + $request->setFollowLocation(false); + $request->addHeader('Host', $hostname); + $request->setTlsCaptureCertificate(true); + $request->setTlsServerName($hostname); + + $method = new ReflectionMethod($this->httpClient, 'createStreamContext'); + $context = $method->invoke( + $this->httpClient, + $request, [ + 'ignore_errors' => false, 'method' => 'HEAD', - 'follow_location' => 0, + 'follow_location' => 1, + 'user_agent' => 'overwritten-anyway/1.0', ], [ - "Host: {$hostname}", - ], - [ - 'capture_peer_cert' => true, - 'peer_name' => $hostname, + 'capture_peer_cert' => false, + 'peer_name' => 'will.be.overwritten.anyway', ], ); + if (!is_resource($context)) { + Assert::fail('Context is of a wrong type ' . get_debug_type($context)); + return; + } + $params = stream_context_get_params($context); $expected = [ 'ssl' => [ - 'capture_peer_cert' => true, 'peer_name' => $hostname, + 'capture_peer_cert' => true, ], 'http' => [ - 'method' => 'HEAD', 'follow_location' => 0, + 'user_agent' => 'Foo/Bar', 'ignore_errors' => true, 'header' => [ "Host: {$hostname}", ], - 'user_agent' => 'Foo/Bar', + 'method' => 'HEAD', ], ]; Assert::same($expected, $params['options']); diff --git a/site/tests/UpcKeys/TechnicolorTest.phpt b/site/tests/UpcKeys/TechnicolorTest.phpt index 50cc48d8e..e75ad75f8 100644 --- a/site/tests/UpcKeys/TechnicolorTest.phpt +++ b/site/tests/UpcKeys/TechnicolorTest.phpt @@ -95,12 +95,12 @@ class TechnicolorTest extends TestCase public function testGetKeysGenerateAndStore(): void { $ssid = 'UPC1234567'; - $this->httpClient->setGetResult(Json::encode('')); + $this->httpClient->setResponse(Json::encode('')); Assert::same([], $this->technicolor->getKeys($ssid)); Assert::same([], $this->logger->getLogged()); Assert::count(0, $this->database->getParamsArrayForQuery('INSERT INTO ssids')); - $this->httpClient->setGetResult(Json::encode("SBAP303,foo,1\n\nSBAP808,bar,2")); + $this->httpClient->setResponse(Json::encode("SBAP303,foo,1\n\nSBAP808,bar,2")); $keys = $this->technicolor->getKeys($ssid); $expected = [ new WiFiKey('SBAP303', 'SBAP', null, null, 'foo', WiFiBand::Band24GHz), @@ -119,7 +119,7 @@ class TechnicolorTest extends TestCase public function testGetKeysGenerateAndStoreNoJson(): void { - $this->httpClient->setGetResult(''); + $this->httpClient->setResponse(''); $keys = $this->technicolor->getKeys('UPC1234568'); Assert::same([], $keys); $exception = $this->logger->getLogged()[0]; @@ -135,7 +135,7 @@ class TechnicolorTest extends TestCase public function testGetKeysGenerateAndStoreBadJson(): void { $json = Json::encode(['303', 808]); - $this->httpClient->setGetResult($json); + $this->httpClient->setResponse($json); $keys = $this->technicolor->getKeys('UPC1234568'); Assert::same([], $keys); $exception = $this->logger->getLogged()[0]; @@ -152,7 +152,7 @@ class TechnicolorTest extends TestCase { $line = "SBAP303,foo,not-a-number"; $json = Json::encode($line); - $this->httpClient->setGetResult($json); + $this->httpClient->setResponse($json); $keys = $this->technicolor->getKeys('UPC1234568'); Assert::same([], $keys); $exception = $this->logger->getLogged()[0];