%PDF- %PDF-
Direktori : /www/varak.net/nextcloud.varak.net/apps/app_api/lib/Service/ |
Current File : //www/varak.net/nextcloud.varak.net/apps/app_api/lib/Service/AppAPIService.php |
<?php declare(strict_types=1); namespace OCA\AppAPI\Service; use OCA\AppAPI\AppInfo\Application; use OCA\AppAPI\Db\DaemonConfig; use OCA\AppAPI\Db\ExApp; use OCA\AppAPI\DeployActions\DockerActions; use OCA\AppAPI\DeployActions\ManualActions; use OCP\AppFramework\Http; use OCP\DB\Exception; use OCP\Http\Client\IClient; use OCP\Http\Client\IClientService; use OCP\Http\Client\IPromise; use OCP\Http\Client\IResponse; use OCP\IConfig; use OCP\IRequest; use OCP\ISession; use OCP\IUserManager; use OCP\IUserSession; use OCP\L10N\IFactory; use OCP\Log\ILogFactory; use OCP\Security\Bruteforce\IThrottler; use Psr\Log\LoggerInterface; class AppAPIService { private IClient $client; public function __construct( private readonly LoggerInterface $logger, private readonly ILogFactory $logFactory, private readonly IThrottler $throttler, private readonly IConfig $config, IClientService $clientService, private readonly IUserSession $userSession, private readonly ISession $session, private readonly IUserManager $userManager, private readonly IFactory $l10nFactory, private readonly ExAppService $exAppService, private readonly DockerActions $dockerActions, private readonly ManualActions $manualActions, private readonly AppAPICommonService $commonService, private readonly DaemonConfigService $daemonConfigService, ) { $this->client = $clientService->newClient(); } /** * Request to ExApp with AppAPI auth headers */ public function requestToExApp( ExApp $exApp, string $route, ?string $userId = null, string $method = 'POST', array $params = [], array $options = [], ?IRequest $request = null, ): array|IResponse { $requestData = $this->prepareRequestToExApp($exApp, $route, $userId, $method, $params, $options, $request); return $this->requestToExAppInternal($exApp, $method, $requestData['url'], $requestData['options']); } /** * Request to ExApp with AppAPI auth headers with proper query/body params handling */ public function requestToExApp2( ExApp $exApp, string $route, ?string $userId = null, string $method = 'POST', array $queryParams = [], array $bodyParams = [], array $options = [], ?IRequest $request = null, ): array|IResponse { $requestData = $this->prepareRequestToExApp2($exApp, $route, $userId, $method, $queryParams, $bodyParams, $options, $request); return $this->requestToExAppInternal($exApp, $method, $requestData['url'], $requestData['options']); } private function requestToExAppInternal( ExApp $exApp, string $method, string $uri, #[\SensitiveParameter] array $options, ): array|IResponse { try { return match ($method) { 'GET' => $this->client->get($uri, $options), 'POST' => $this->client->post($uri, $options), 'PUT' => $this->client->put($uri, $options), 'DELETE' => $this->client->delete($uri, $options), default => ['error' => 'Bad HTTP method'], }; } catch (\Exception $e) { $this->logger->warning(sprintf('Error during request to ExApp %s: %s', $exApp->getAppid(), $e->getMessage()), ['exception' => $e]); return ['error' => $e->getMessage()]; } } /** * @throws \Exception */ public function requestToExAppAsync( ExApp $exApp, string $route, ?string $userId = null, string $method = 'POST', array $params = [], array $options = [], ?IRequest $request = null, ): IPromise { $requestData = $this->prepareRequestToExApp($exApp, $route, $userId, $method, $params, $options, $request); return $this->requestToExAppInternalAsync($exApp, $method, $requestData['url'], $requestData['options']); } /** * @throws \Exception if bad HTTP method */ private function requestToExAppInternalAsync( ExApp $exApp, string $method, string $uri, #[\SensitiveParameter] array $options, ): IPromise { $promise = match ($method) { 'GET' => $this->client->getAsync($uri, $options), 'POST' => $this->client->postAsync($uri, $options), 'PUT' => $this->client->putAsync($uri, $options), 'DELETE' => $this->client->deleteAsync($uri, $options), default => throw new \Exception('Bad HTTP method'), }; $promise->then(onRejected: function (\Exception $exception) use ($exApp) { $this->logger->warning(sprintf('Error during requestToExAppAsync %s: %s', $exApp->getAppid(), $exception->getMessage()), ['exception' => $exception]); }); return $promise; } private function prepareRequestToExApp( ExApp $exApp, string $route, ?string $userId, string $method, array $params, #[\SensitiveParameter] array $options, ?IRequest $request, ): array { $auth = []; $url = $this->getExAppUrl($exApp, $exApp->getPort(), $auth); if (str_starts_with($route, '/')) { $url = $url.$route; } else { $url = $url.'/'.$route; } if (isset($options['headers']) && is_array($options['headers'])) { $options['headers'] = [...$options['headers'], ...$this->commonService->buildAppAPIAuthHeaders($request, $userId, $exApp->getAppid(), $exApp->getVersion(), $exApp->getSecret())]; } else { $options['headers'] = $this->commonService->buildAppAPIAuthHeaders($request, $userId, $exApp->getAppid(), $exApp->getVersion(), $exApp->getSecret()); } $lang = $this->l10nFactory->findLanguage($exApp->getAppid()); if (!isset($options['headers']['Accept-Language'])) { $options['headers']['Accept-Language'] = $lang; } $options['nextcloud'] = [ 'allow_local_address' => true, // it's required as we are using ExApp appid as hostname (usually local) ]; $options['http_errors'] = false; // do not throw exceptions on 4xx and 5xx responses if (!empty($auth)) { $options['auth'] = $auth; $options['headers'] = $this->swapAuthorizationHeader($options['headers']); } if (!isset($options['timeout'])) { $options['timeout'] = 3; } if ((!array_key_exists('multipart', $options)) && (count($params)) > 0) { if ($method === 'GET') { $url .= '?' . http_build_query($params); } else { $options['json'] = $params; } } return ['url' => $url, 'options' => $options]; } private function prepareRequestToExApp2( ExApp $exApp, string $route, ?string $userId, string $method, array $queryParams, array $bodyParams, #[\SensitiveParameter] array $options, ?IRequest $request, ): array { $auth = []; $url = $this->getExAppUrl($exApp, $exApp->getPort(), $auth); if (str_starts_with($route, '/')) { $url = $url.$route; } else { $url = $url.'/'.$route; } if (isset($options['headers']) && is_array($options['headers'])) { $options['headers'] = [...$options['headers'], ...$this->commonService->buildAppAPIAuthHeaders($request, $userId, $exApp->getAppid(), $exApp->getVersion(), $exApp->getSecret())]; } else { $options['headers'] = $this->commonService->buildAppAPIAuthHeaders($request, $userId, $exApp->getAppid(), $exApp->getVersion(), $exApp->getSecret()); } $lang = $this->l10nFactory->findLanguage($exApp->getAppid()); if (!isset($options['headers']['Accept-Language'])) { $options['headers']['Accept-Language'] = $lang; } $options['nextcloud'] = [ 'allow_local_address' => true, // it's required as we are using ExApp appid as hostname (usually local) ]; $options['http_errors'] = false; // do not throw exceptions on 4xx and 5xx responses if (!empty($auth)) { $options['auth'] = $auth; $options['headers'] = $this->swapAuthorizationHeader($options['headers']); } if (!isset($options['timeout'])) { $options['timeout'] = 3; } if ((!array_key_exists('multipart', $options))) { if (count($queryParams) > 0) { $url .= '?' . http_build_query($queryParams); } if ($method !== 'GET' && count($bodyParams) > 0) { $options['json'] = $bodyParams; } } return ['url' => $url, 'options' => $options]; } /** * This is required for AppAPI Docker Socket Proxy, as the Basic Auth is already in use by HaProxy, * and the incoming request's Authorization is replaced with X-Original-Authorization header * after HaProxy authenticated. * * @since AppAPI 3.0.0 */ private function swapAuthorizationHeader(array $headers): array { foreach ($headers as $key => $value) { if (strtoupper($key) === 'AUTHORIZATION') { $headers['X-Original-Authorization'] = $value; break; } } return $headers; } /** * AppAPI authentication request validation for Nextcloud: * - checks if ExApp exists and is enabled * - checks if ExApp version changed and updates it in database * - checks if ExApp shared secret valid * * More info in docs: https://cloud-py-api.github.io/app_api/authentication.html */ public function validateExAppRequestToNC(IRequest $request, bool $isDav = false): bool { $delay = $this->throttler->sleepDelayOrThrowOnMax($request->getRemoteAddress(), Application::APP_ID); $exAppId = $request->getHeader('EX-APP-ID'); if (!$exAppId) { return false; } $exApp = $this->exAppService->getExApp($exAppId); if ($exApp === null) { $this->logger->error(sprintf('ExApp with appId %s not found.', $request->getHeader('EX-APP-ID'))); // Protection for guessing installed ExApps list $this->throttler->registerAttempt(Application::APP_ID, $request->getRemoteAddress(), [ 'appid' => $request->getHeader('EX-APP-ID'), 'userid' => explode(':', base64_decode($request->getHeader('AUTHORIZATION-APP-API')), 2)[0], ]); return false; } $authorization = base64_decode($request->getHeader('AUTHORIZATION-APP-API')); if ($authorization === false) { $this->logger->error('Failed to parse AUTHORIZATION-APP-API'); return false; } $userId = explode(':', $authorization, 2)[0]; $authorizationSecret = explode(':', $authorization, 2)[1]; $authValid = $authorizationSecret === $exApp->getSecret(); if ($authValid) { if (!$isDav) { try { $path = $request->getPathInfo(); } catch (\Exception $e) { $this->logger->error(sprintf('Error getting path info. Error: %s', $e->getMessage()), ['exception' => $e]); return false; } if (($this->sanitizeOcsRoute($path) !== '/apps/app_api/ex-app/state') && !$exApp->getEnabled()) { $this->logger->error(sprintf('ExApp with appId %s is disabled (%s)', $request->getHeader('EX-APP-ID'), $request->getRequestUri())); return false; } } if (!$this->handleExAppVersionChange($request, $exApp)) { return false; } return $this->finalizeRequestToNC($exApp, $userId, $request, $delay); } else { $this->logger->error(sprintf('Invalid signature for ExApp: %s and user: %s.', $exApp->getAppid(), $userId !== '' ? $userId : 'null')); $this->throttler->registerAttempt(Application::APP_ID, $request->getRemoteAddress(), [ 'appid' => $request->getHeader('EX-APP-ID'), 'userid' => $userId, ]); } $this->logger->error(sprintf('ExApp %s request to NC validation failed.', $exApp->getAppid())); return false; } /** * Final step of AppAPI authentication request validation for Nextcloud: * - sets active user (null if not a user context) * - updates ExApp last response time */ private function finalizeRequestToNC(ExApp $exApp, string $userId, IRequest $request, int $delay): bool { if ($userId !== '') { $activeUser = $this->userManager->get($userId); if ($activeUser === null) { $this->logger->error(sprintf('Requested user does not exists: %s', $userId)); return false; } $this->userSession->setUser($activeUser); $this->logImpersonatingRequest($exApp->getAppid()); } else { $this->userSession->setUser(null); } $this->session->set('app_api', true); $this->session->set('app_api_system', true); // TODO: Remove after drop support NC29 if ($delay) { $this->throttler->resetDelay($request->getRemoteAddress(), Application::APP_ID, [ 'appid' => $request->getHeader('EX-APP-ID'), 'userid' => $userId, ]); } return true; } /** * Check if the given route has ocs prefix and cut it off */ private function sanitizeOcsRoute(string $route): string { if (preg_match("/\/ocs\/v([12])\.php/", $route, $matches)) { return str_replace($matches[0], '', $route); } return $route; } private function getCustomLogger(string $name): LoggerInterface { $path = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') . '/' . $name; return $this->logFactory->getCustomPsrLogger($path); } private function logImpersonatingRequest(string $appId): void { $exAppsImpersonationLogger = $this->getCustomLogger('exapp_impersonation.log'); $exAppsImpersonationLogger->warning('impersonation request', [ 'app' => $appId, ]); } /** * Checks if the ExApp version changed and if it is higher, updates it in the database. */ public function handleExAppVersionChange(IRequest $request, ExApp $exApp): bool { $requestExAppVersion = $request->getHeader('EX-APP-VERSION'); if ($requestExAppVersion === '') { return false; } if (version_compare($requestExAppVersion, $exApp->getVersion(), '>')) { $exApp->setVersion($requestExAppVersion); if (!$this->exAppService->updateExApp($exApp, ['version'])) { return false; } } return true; } public function dispatchExAppInitInternal(ExApp $exApp): void { $auth = []; $initUrl = $this->getExAppUrl($exApp, $exApp->getPort(), $auth) . '/init'; $options = [ 'headers' => $this->commonService->buildAppAPIAuthHeaders(null, null, $exApp->getAppid(), $exApp->getVersion(), $exApp->getSecret()), 'nextcloud' => [ 'allow_local_address' => true, ], ]; if (!empty($auth)) { $options['auth'] = $auth; } $this->setAppInitProgress($exApp, 0); $this->exAppService->enableExAppInternal($exApp); try { $this->client->post($initUrl, $options); } catch (\Exception $e) { $statusCode = $e->getCode(); if (($statusCode === Http::STATUS_NOT_IMPLEMENTED) || ($statusCode === Http::STATUS_NOT_FOUND)) { $this->setAppInitProgress($exApp, 100); } else { $this->setAppInitProgress($exApp, 0, $e->getMessage()); } } } /** * Dispatch ExApp initialization step, that may take a long time to display the progress of initialization. */ public function runOccCommand(string $command): bool { $args = array_map(function ($arg) { return escapeshellarg($arg); }, explode(' ', $command)); $args[] = '--no-ansi --no-warnings'; return $this->runOccCommandInternal($args); } public function runOccCommandInternal(array $args): bool { $args = implode(' ', $args); $descriptors = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; $occDirectory = null; if (!file_exists("console.php")) { $occDirectory = dirname(__FILE__, 5); } $this->logger->info(sprintf('Calling occ(directory=%s): %s', $occDirectory ?? 'null', $args)); $process = proc_open('php console.php ' . $args, $descriptors, $pipes, $occDirectory); if (!is_resource($process)) { $this->logger->error(sprintf('Error calling occ(directory=%s): %s', $occDirectory ?? 'null', $args)); return false; } fclose($pipes[0]); fclose($pipes[1]); fclose($pipes[2]); return true; } public function heartbeatExApp( string $exAppUrl, #[\SensitiveParameter] array $auth, string $appId, ): bool { $heartbeatAttempts = 0; $delay = 1; if ($appId === Application::TEST_DEPLOY_APPID) { $maxHeartbeatAttempts = 60 * $delay; // 1 minute for test deploy app } else { $maxHeartbeatAttempts = 60 * 10 * $delay; // minutes for container initialization } $options = [ 'headers' => [ 'Accept' => 'application/json', 'Content-Type' => 'application/json', ], 'nextcloud' => [ 'allow_local_address' => true, ], ]; if (!empty($auth)) { $options['auth'] = $auth; } $this->logger->info(sprintf('Performing heartbeat on: %s', $exAppUrl . '/heartbeat')); $failedHeartbeatCount = 0; while ($heartbeatAttempts < $maxHeartbeatAttempts) { $heartbeatAttempts++; $errorMsg = ''; $statusCode = 0; $exApp = $this->exAppService->getExApp($appId); if ($exApp === null) { return false; } try { $heartbeatResult = $this->client->get($exAppUrl . '/heartbeat', $options); $statusCode = $heartbeatResult->getStatusCode(); if ($statusCode === 200) { $result = json_decode($heartbeatResult->getBody(), true); if (isset($result['status']) && $result['status'] === 'ok') { $this->logger->info(sprintf('Successful heartbeat on: %s', $exAppUrl . '/heartbeat')); return true; } } } catch (\Exception $e) { $errorMsg = $e->getMessage(); } $failedHeartbeatCount++; // Log every 10th failed heartbeat if ($failedHeartbeatCount % 10 == 0) { $this->logger->warning( sprintf('Failed heartbeat on %s for %d times. Most recent status=%d, error: %s', $exAppUrl, $failedHeartbeatCount, $statusCode, $errorMsg) ); $status = $exApp->getStatus(); if (isset($status['heartbeat_count'])) { $status['heartbeat_count'] += $failedHeartbeatCount; } else { $status['heartbeat_count'] = $failedHeartbeatCount; } $exApp->setStatus($status); $this->exAppService->updateExApp($exApp, ['status']); } sleep($delay); } return false; } public function getExAppUrl(ExApp $exApp, int $port, array &$auth): string { if ($exApp->getAcceptsDeployId() === $this->dockerActions->getAcceptsDeployId()) { return $this->dockerActions->resolveExAppUrl( $exApp->getAppid(), $exApp->getProtocol(), $exApp->getHost(), $exApp->getDeployConfig(), $port, $auth, ); } else { return $this->manualActions->resolveExAppUrl( $exApp->getAppid(), $exApp->getProtocol(), $exApp->getHost(), $exApp->getDeployConfig(), $port, $auth, ); } } public function getExAppDomain(ExApp $exApp): string { $auth = []; $appFullUrl = $this->getExAppUrl($exApp, 0, $auth); $urlComponents = parse_url($appFullUrl); return $urlComponents['host'] ?? ''; } /** * Enable ExApp. Sends request to ExApp to update enabled state. * If request fails, ExApp will be disabled. * Removes ExApp from cache. */ public function enableExApp(ExApp $exApp): bool { if ($this->exAppService->enableExAppInternal($exApp)) { if ($exApp->getAcceptsDeployId() === $this->dockerActions->getAcceptsDeployId()) { $daemonConfig = $this->daemonConfigService->getDaemonConfigByName($exApp->getDaemonConfigName()); $this->dockerActions->initGuzzleClient($daemonConfig); $containerName = $this->dockerActions->buildExAppContainerName($exApp->getAppid()); $this->dockerActions->startContainer($this->dockerActions->buildDockerUrl($daemonConfig), $containerName); if (!$this->dockerActions->waitTillContainerStart($containerName, $daemonConfig)) { $this->logger->error(sprintf('ExApp %s container startup failed.', $exApp->getAppid())); return false; } if (!$this->dockerActions->healthcheckContainer($containerName, $daemonConfig, true)) { $this->logger->error(sprintf('ExApp %s container healthcheck failed.', $exApp->getAppid())); return false; } } $auth = []; $exAppRootUrl = $this->getExAppUrl($exApp, $exApp->getPort(), $auth); if (!$this->heartbeatExApp($exAppRootUrl, $auth, $exApp->getAppid())) { $this->logger->error(sprintf('ExApp %s heartbeat failed.', $exApp->getAppid())); return false; } $exAppEnabled = $this->requestToExApp($exApp, '/enabled?enabled=1', null, 'PUT', options: ['timeout' => 60]); if ($exAppEnabled instanceof IResponse) { $response = json_decode($exAppEnabled->getBody(), true); if (!empty($response['error'])) { $this->logger->error(sprintf('Failed to enable ExApp %s. Error: %s', $exApp->getAppid(), $response['error'])); $this->exAppService->disableExAppInternal($exApp); return false; } } elseif (isset($exAppEnabled['error'])) { $this->logger->error(sprintf('Failed to enable ExApp %s. Error: %s', $exApp->getAppid(), $exAppEnabled['error'])); $this->exAppService->disableExAppInternal($exApp); return false; } } return true; } /** * Disable ExApp. Sends request to ExApp to update enabled state. * If request fails, disables ExApp in database, cache. */ public function disableExApp(ExApp $exApp): bool { $result = true; $exAppDisabled = $this->requestToExApp($exApp, '/enabled?enabled=0', null, 'PUT', options: ['timeout' => 60]); if ($exAppDisabled instanceof IResponse) { $response = json_decode($exAppDisabled->getBody(), true); if (isset($response['error']) && strlen($response['error']) !== 0) { $this->logger->error(sprintf('Failed to disable ExApp %s. Error: %s', $exApp->getAppid(), $response['error'])); $result = false; } } elseif (isset($exAppDisabled['error'])) { $this->logger->error(sprintf('Failed to disable ExApp %s. Error: %s', $exApp->getAppid(), $exAppDisabled['error'])); $result = false; } if ($exApp->getAcceptsDeployId() === $this->dockerActions->getAcceptsDeployId()) { $daemonConfig = $this->daemonConfigService->getDaemonConfigByName($exApp->getDaemonConfigName()); $this->dockerActions->initGuzzleClient($daemonConfig); $this->dockerActions->stopContainer($this->dockerActions->buildDockerUrl($daemonConfig), $this->dockerActions->buildExAppContainerName($exApp->getAppid())); } $this->exAppService->disableExAppInternal($exApp); return $result; } public function setAppInitProgress(ExApp $exApp, int $progress, string $error = ''): void { if ($progress < 0 || $progress > 100) { throw new \InvalidArgumentException('Invalid ExApp init status progress value'); } $status = $exApp->getStatus(); if ($progress !== 0 && isset($status['init']) && $status['init'] === 100) { return; } if ($error !== '') { $this->logger->error(sprintf('ExApp %s initialization failed. Error: %s', $exApp->getAppid(), $error)); $status['error'] = $error; } else { if ($progress === 0) { $status['action'] = 'init'; $status['init_start_time'] = time(); $status['error'] = ''; } $status['init'] = $progress; } if ($progress === 100) { $status['action'] = ''; $status['type'] = ''; } $exApp->setStatus($status); $this->exAppService->updateExApp($exApp, ['status']); if ($progress === 100) { $this->enableExApp($exApp); } } public function removeExAppsByDaemonConfigName(DaemonConfig $daemonConfig): void { try { $targetDaemonExApps = $this->exAppService->getExAppsByDaemonName($daemonConfig->getName()); if (count($targetDaemonExApps) === 0) { return; } foreach ($targetDaemonExApps as $exApp) { $this->disableExApp($exApp); if ($daemonConfig->getAcceptsDeployId() === 'docker-install') { $this->dockerActions->initGuzzleClient($daemonConfig); $this->dockerActions->removeContainer($this->dockerActions->buildDockerUrl($daemonConfig), $this->dockerActions->buildExAppContainerName($exApp->getAppid())); $this->dockerActions->removeVolume($this->dockerActions->buildDockerUrl($daemonConfig), $this->dockerActions->buildExAppVolumeName($exApp->getAppid())); } $this->exAppService->unregisterExApp($exApp->getAppid()); } } catch (Exception) { } } }