diff options
author | Côme Chilliet <come.chilliet@nextcloud.com> | 2024-02-06 17:24:47 +0100 |
---|---|---|
committer | Côme Chilliet <91878298+come-nc@users.noreply.github.com> | 2024-03-07 14:06:08 +0100 |
commit | 1ea88a73f6343a9b39b30736086484d0c072ae46 (patch) | |
tree | 04bf34eb7888cfeb28a28db25ce6cd6ea3a17afd /apps | |
parent | 01d5af66beed08810861e8547fd6cd0cfee6a52a (diff) | |
download | nextcloud-server-1ea88a73f6343a9b39b30736086484d0c072ae46.tar.gz nextcloud-server-1ea88a73f6343a9b39b30736086484d0c072ae46.zip |
feat(settings): Migrate .well-known URL setup check to new API
Co-authored-by: Côme Chilliet <come.chilliet@nextcloud.com>
Co-authored-by: Ferdinand Thiessen <opensource@fthiessen.de>
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Diffstat (limited to 'apps')
-rw-r--r-- | apps/settings/composer/composer/autoload_classmap.php | 1 | ||||
-rw-r--r-- | apps/settings/composer/composer/autoload_static.php | 1 | ||||
-rw-r--r-- | apps/settings/lib/AppInfo/Application.php | 2 | ||||
-rw-r--r-- | apps/settings/lib/SetupChecks/CheckServerResponseTrait.php | 35 | ||||
-rw-r--r-- | apps/settings/lib/SetupChecks/WellKnownUrls.php | 111 | ||||
-rw-r--r-- | apps/settings/tests/SetupChecks/WellKnownUrlsTest.php | 233 |
6 files changed, 377 insertions, 6 deletions
diff --git a/apps/settings/composer/composer/autoload_classmap.php b/apps/settings/composer/composer/autoload_classmap.php index 4b068267b31..c3cf0763ed9 100644 --- a/apps/settings/composer/composer/autoload_classmap.php +++ b/apps/settings/composer/composer/autoload_classmap.php @@ -119,6 +119,7 @@ return array( 'OCA\\Settings\\SetupChecks\\SystemIs64bit' => $baseDir . '/../lib/SetupChecks/SystemIs64bit.php', 'OCA\\Settings\\SetupChecks\\TempSpaceAvailable' => $baseDir . '/../lib/SetupChecks/TempSpaceAvailable.php', 'OCA\\Settings\\SetupChecks\\TransactionIsolation' => $baseDir . '/../lib/SetupChecks/TransactionIsolation.php', + 'OCA\\Settings\\SetupChecks\\WellKnownUrls' => $baseDir . '/../lib/SetupChecks/WellKnownUrls.php', 'OCA\\Settings\\SetupChecks\\Woff2Loading' => $baseDir . '/../lib/SetupChecks/Woff2Loading.php', 'OCA\\Settings\\UserMigration\\AccountMigrator' => $baseDir . '/../lib/UserMigration/AccountMigrator.php', 'OCA\\Settings\\UserMigration\\AccountMigratorException' => $baseDir . '/../lib/UserMigration/AccountMigratorException.php', diff --git a/apps/settings/composer/composer/autoload_static.php b/apps/settings/composer/composer/autoload_static.php index 7800a764df7..88f71b026f5 100644 --- a/apps/settings/composer/composer/autoload_static.php +++ b/apps/settings/composer/composer/autoload_static.php @@ -134,6 +134,7 @@ class ComposerStaticInitSettings 'OCA\\Settings\\SetupChecks\\SystemIs64bit' => __DIR__ . '/..' . '/../lib/SetupChecks/SystemIs64bit.php', 'OCA\\Settings\\SetupChecks\\TempSpaceAvailable' => __DIR__ . '/..' . '/../lib/SetupChecks/TempSpaceAvailable.php', 'OCA\\Settings\\SetupChecks\\TransactionIsolation' => __DIR__ . '/..' . '/../lib/SetupChecks/TransactionIsolation.php', + 'OCA\\Settings\\SetupChecks\\WellKnownUrls' => __DIR__ . '/..' . '/../lib/SetupChecks/WellKnownUrls.php', 'OCA\\Settings\\SetupChecks\\Woff2Loading' => __DIR__ . '/..' . '/../lib/SetupChecks/Woff2Loading.php', 'OCA\\Settings\\UserMigration\\AccountMigrator' => __DIR__ . '/..' . '/../lib/UserMigration/AccountMigrator.php', 'OCA\\Settings\\UserMigration\\AccountMigratorException' => __DIR__ . '/..' . '/../lib/UserMigration/AccountMigratorException.php', diff --git a/apps/settings/lib/AppInfo/Application.php b/apps/settings/lib/AppInfo/Application.php index 12cd21cf17c..0977da398b0 100644 --- a/apps/settings/lib/AppInfo/Application.php +++ b/apps/settings/lib/AppInfo/Application.php @@ -90,6 +90,7 @@ use OCA\Settings\SetupChecks\SupportedDatabase; use OCA\Settings\SetupChecks\SystemIs64bit; use OCA\Settings\SetupChecks\TempSpaceAvailable; use OCA\Settings\SetupChecks\TransactionIsolation; +use OCA\Settings\SetupChecks\WellKnownUrls; use OCA\Settings\SetupChecks\Woff2Loading; use OCA\Settings\UserMigration\AccountMigrator; use OCA\Settings\WellKnown\ChangePasswordHandler; @@ -218,6 +219,7 @@ class Application extends App implements IBootstrap { $context->registerSetupCheck(TempSpaceAvailable::class); $context->registerSetupCheck(TransactionIsolation::class); $context->registerSetupCheck(PushService::class); + $context->registerSetupCheck(WellKnownUrls::class); $context->registerSetupCheck(Woff2Loading::class); $context->registerUserMigrator(AccountMigrator::class); diff --git a/apps/settings/lib/SetupChecks/CheckServerResponseTrait.php b/apps/settings/lib/SetupChecks/CheckServerResponseTrait.php index bc757d27e48..aafade1526a 100644 --- a/apps/settings/lib/SetupChecks/CheckServerResponseTrait.php +++ b/apps/settings/lib/SetupChecks/CheckServerResponseTrait.php @@ -31,6 +31,7 @@ use OCP\Http\Client\IResponse; use OCP\IConfig; use OCP\IL10N; use OCP\IURLGenerator; +use Psr\Log\LoggerInterface; /** * Common trait for setup checks that need to use requests to the same server and check the response @@ -40,6 +41,7 @@ trait CheckServerResponseTrait { protected IURLGenerator $urlGenerator; protected IClientService $clientService; protected IL10N $l10n; + protected LoggerInterface $logger; /** * Common helper string in case a check could not fetch any results @@ -71,25 +73,46 @@ trait CheckServerResponseTrait { } /** - * Run a HEAD request to check header + * Run a HTTP request to check header * @param string $url The relative URL to check - * @param bool $ignoreSSL Ignore SSL certificates - * @param bool $httpErrors Ignore requests with HTTP errors (will not yield if request has a 4xx or 5xx response) + * @param string $method The HTTP method to use + * @param array{ignoreSSL?: bool, httpErrors?: bool, options?: array} $options Additional options, like + * [ + * // Ignore invalid SSL certificates (e.g. self signed) + * 'ignoreSSL' => true, + * // Ignore requests with HTTP errors (will not yield if request has a 4xx or 5xx response) + * 'httpErrors' => true, + * ] + * * @return Generator<int, IResponse> */ - protected function runHEAD(string $url, bool $ignoreSSL = true, bool $httpErrors = true): Generator { + protected function runRequest(string $url, string $method, array $options = []): Generator { + $options = array_merge(['ignoreSSL' => true, 'httpErrors' => true], $options); + $client = $this->clientService->newClient(); - $requestOptions = $this->getRequestOptions($ignoreSSL, $httpErrors); + $requestOptions = $this->getRequestOptions($options['ignoreSSL'], $options['httpErrors']); + $requestOptions = array_merge($requestOptions, $options['options'] ?? []); foreach ($this->getTestUrls($url) as $testURL) { try { - yield $client->head($testURL, $requestOptions); + yield $client->request($testURL, $method, $requestOptions); } catch (\Throwable $e) { $this->logger->debug('Can not connect to local server for running setup checks', ['exception' => $e, 'url' => $testURL]); } } } + /** + * Run a HEAD request to check header + * @param string $url The relative URL to check + * @param bool $ignoreSSL Ignore SSL certificates + * @param bool $httpErrors Ignore requests with HTTP errors (will not yield if request has a 4xx or 5xx response) + * @return Generator<int, IResponse> + */ + protected function runHEAD(string $url, bool $ignoreSSL = true, bool $httpErrors = true): Generator { + return $this->runRequest($url, 'HEAD', ['ignoreSSL' => $ignoreSSL, 'httpErrors' => $httpErrors]); + } + protected function getRequestOptions(bool $ignoreSSL, bool $httpErrors): array { $requestOptions = [ 'connect_timeout' => 10, diff --git a/apps/settings/lib/SetupChecks/WellKnownUrls.php b/apps/settings/lib/SetupChecks/WellKnownUrls.php new file mode 100644 index 00000000000..933f82b614f --- /dev/null +++ b/apps/settings/lib/SetupChecks/WellKnownUrls.php @@ -0,0 +1,111 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2024 Côme Chilliet <come.chilliet@nextcloud.com> + * + * @author Côme Chilliet <come.chilliet@nextcloud.com> + * @author Ferdinand Thiessen <opensource@fthiessen.de> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\Settings\SetupChecks; + +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; +use Psr\Log\LoggerInterface; + +class WellKnownUrls implements ISetupCheck { + + use CheckServerResponseTrait; + + public function __construct( + protected IL10N $l10n, + protected IConfig $config, + protected IURLGenerator $urlGenerator, + protected IClientService $clientService, + protected LoggerInterface $logger, + ) { + } + + public function getCategory(): string { + return 'network'; + } + + public function getName(): string { + return $this->l10n->t('.well-known URLs'); + } + + public function run(): SetupResult { + if (!$this->config->getSystemValueBool('check_for_working_wellknown_setup', true)) { + return SetupResult::info($this->l10n->t('`check_for_working_wellknown_setup` is set to false in your configuration, so this check was skipped.')); + } + + $urls = [ + ['get', '/.well-known/webfinger', [200, 404], true], + ['get', '/.well-known/nodeinfo', [200, 404], true], + ['propfind', '/.well-known/caldav', [207], false], + ['propfind', '/.well-known/carddav', [207], false], + ]; + + foreach ($urls as [$verb,$url,$validStatuses,$checkCustomHeader]) { + $works = null; + foreach ($this->runRequest($url, $verb, ['httpErrors' => false, 'options' => ['allow_redirects' => ['track_redirects' => true]]]) as $response) { + // Check that the response status matches + $works = in_array($response->getStatusCode(), $validStatuses); + // and (if needed) the custom Nextcloud header is set + if ($checkCustomHeader) { + $works = $works && !empty($response->getHeader('X-NEXTCLOUD-WELL-KNOWN')); + } else { + // For default DAV endpoints we lack authorization, but we still can check that the redirect works as expected + if (!$works && $response->getStatusCode() === 401) { + $redirectHops = explode(',', $response->getHeader('X-Guzzle-Redirect-History')); + $effectiveUri = end($redirectHops); + $works = str_ends_with($effectiveUri, '/remote.php/dav/'); + } + } + // Skip the other requests if one works + if ($works === true) { + break; + } + } + // If 'works' is null then we could not connect to the server + if ($works === null) { + return SetupResult::info( + $this->l10n->t('Could not check that your web server serves `.well-known` correctly. Please check manually.') . "\n" . $this->serverConfigHelp(), + $this->urlGenerator->linkToDocs('admin-setup-well-known-URL'), + ); + } + // Otherwise if we fail we can abort here + if ($works === false) { + return SetupResult::warning( + $this->l10n->t("Your web server is not properly set up to resolve `.well-known` URLs, failed on:\n`%s`", [$url]), + $this->urlGenerator->linkToDocs('admin-setup-well-known-URL'), + ); + } + } + return SetupResult::success( + $this->l10n->t('Your server is correctly configured to serve `.well-known` URLs.') + ); + } +} diff --git a/apps/settings/tests/SetupChecks/WellKnownUrlsTest.php b/apps/settings/tests/SetupChecks/WellKnownUrlsTest.php new file mode 100644 index 00000000000..7d685db039c --- /dev/null +++ b/apps/settings/tests/SetupChecks/WellKnownUrlsTest.php @@ -0,0 +1,233 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de> + * + * @author Ferdinand Thiessen <opensource@fthiessen.de> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +namespace OCA\Settings\Tests; + +use OCA\Settings\SetupChecks\WellKnownUrls; +use OCP\Http\Client\IClientService; +use OCP\Http\Client\IResponse; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\SetupResult; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; +use Test\TestCase; + +class WellKnownUrlsTest extends TestCase { + private IL10N|MockObject $l10n; + private IConfig|MockObject $config; + private IURLGenerator|MockObject $urlGenerator; + private IClientService|MockObject $clientService; + private LoggerInterface|MockObject $logger; + private WellKnownUrls|MockObject $setupcheck; + + protected function setUp(): void { + parent::setUp(); + + /** @var IL10N|MockObject */ + $this->l10n = $this->getMockBuilder(IL10N::class) + ->disableOriginalConstructor()->getMock(); + $this->l10n->expects($this->any()) + ->method('t') + ->willReturnCallback(function ($message, array $replace) { + return vsprintf($message, $replace); + }); + + $this->config = $this->createMock(IConfig::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->clientService = $this->createMock(IClientService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->setupcheck = $this->getMockBuilder(WellKnownUrls::class) + ->onlyMethods(['runRequest']) + ->setConstructorArgs([ + $this->l10n, + $this->config, + $this->urlGenerator, + $this->clientService, + $this->logger, + ]) + ->getMock(); + } + + /** + * Test that the SetupCheck is skipped if the system config is set + */ + public function testDisabled(): void { + $this->config + ->expects($this->once()) + ->method('getSystemValueBool') + ->with('check_for_working_wellknown_setup') + ->willReturn(false); + + $this->setupcheck + ->expects($this->never()) + ->method('runRequest'); + + $result = $this->setupcheck->run(); + $this->assertEquals(SetupResult::INFO, $result->getSeverity()); + $this->assertMatchesRegularExpression('/check was skipped/', $result->getDescription()); + } + + /** + * Test what happens if the local server could not be reached (no response from the requests) + */ + public function testNoResponse(): void { + $this->config + ->expects($this->once()) + ->method('getSystemValueBool') + ->with('check_for_working_wellknown_setup') + ->willReturn(true); + + $this->setupcheck + ->expects($this->once()) + ->method('runRequest') + ->will($this->generate([])); + + $result = $this->setupcheck->run(); + $this->assertEquals(SetupResult::INFO, $result->getSeverity()); + $this->assertMatchesRegularExpression('/^Could not check/', $result->getDescription()); + } + + /** + * Test responses + * @dataProvider dataTestResponses + */ + public function testResponses($responses, string $expectedSeverity): void { + $this->config + ->expects($this->once()) + ->method('getSystemValueBool') + ->with('check_for_working_wellknown_setup') + ->willReturn(true); + + $this->setupcheck + ->expects($this->atLeastOnce()) + ->method('runRequest') + ->willReturnOnConsecutiveCalls(...$responses); + + $result = $this->setupcheck->run(); + $this->assertEquals($expectedSeverity, $result->getSeverity()); + } + + public function dataTestResponses(): array { + $createResponse = function (int $statuscode, array $header = []): IResponse|MockObject { + $response = $this->createMock(IResponse::class); + $response->expects($this->any()) + ->method('getStatusCode') + ->willReturn($statuscode); + $response->expects($this->any()) + ->method('getHeader') + ->willReturnCallback(fn ($name) => $header[$name] ?? ''); + return $response; + }; + + $wellKnownHeader = ['X-NEXTCLOUD-WELL-KNOWN' => 'yes']; + + return [ + 'expected codes' => [ + [ + $this->generate([$createResponse(200, $wellKnownHeader)]), + $this->generate([$createResponse(200, $wellKnownHeader)]), + $this->generate([$createResponse(207)]), + $this->generate([$createResponse(207)]), + ], + SetupResult::SUCCESS, + ], + 'late response with expected codes' => [ + [ + $this->generate([$createResponse(404), $createResponse(200, $wellKnownHeader)]), + $this->generate([$createResponse(404), $createResponse(200, $wellKnownHeader)]), + $this->generate([$createResponse(404), $createResponse(207)]), + $this->generate([$createResponse(404), $createResponse(207)]), + ], + SetupResult::SUCCESS, + ], + 'working but disabled webfinger' => [ + [ + $this->generate([$createResponse(404, $wellKnownHeader)]), + $this->generate([$createResponse(404, $wellKnownHeader)]), + $this->generate([$createResponse(207)]), + $this->generate([$createResponse(207)]), + ], + SetupResult::SUCCESS, + ], + 'unauthorized webdav but with correct configured redirect' => [ + [ + $this->generate([$createResponse(404, $wellKnownHeader)]), + $this->generate([$createResponse(404, $wellKnownHeader)]), + $this->generate([$createResponse(401, ['X-Guzzle-Redirect-History' => 'https://example.com,https://example.com/remote.php/dav/'])]), + $this->generate([$createResponse(401, ['X-Guzzle-Redirect-History' => 'https://example.com/remote.php/dav/'])]), + ], + SetupResult::SUCCESS, + ], + 'not configured path' => [ + [ + $this->generate([$createResponse(404)]), + $this->generate([$createResponse(404)]), + $this->generate([$createResponse(404)]), + $this->generate([$createResponse(404)]), + ], + SetupResult::WARNING, + ], + 'Invalid webfinger' => [ + [ + $this->generate([$createResponse(404)]), + $this->generate([$createResponse(404, $wellKnownHeader)]), + $this->generate([$createResponse(207)]), + $this->generate([$createResponse(207)]), + ], + SetupResult::WARNING, + ], + 'Invalid nodeinfo' => [ + [ + $this->generate([$createResponse(404, $wellKnownHeader)]), + $this->generate([$createResponse(404)]), + $this->generate([$createResponse(207)]), + $this->generate([$createResponse(207)]), + ], + SetupResult::WARNING, + ], + 'Invalid caldav' => [ + [ + $this->generate([$createResponse(404, $wellKnownHeader)]), + $this->generate([$createResponse(404, $wellKnownHeader)]), + $this->generate([$createResponse(404)]), + $this->generate([$createResponse(207)]), + ], + SetupResult::WARNING, + ], + ]; + } + + /** + * Helper function creates a nicer interface for mocking Generator behavior + */ + protected function generate(array $yield_values) { + return $this->returnCallback(function () use ($yield_values) { + yield from $yield_values; + }); + } +} |