diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:43:12 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:43:12 +0000 |
commit | cd989f9c3aff968e19a3aeabc4eb9085787a6673 (patch) | |
tree | fbff2135e7013f196b891bbde54618eb050e4aaf /library/Director/RestApi | |
parent | Initial commit. (diff) | |
download | icingaweb2-module-director-cd989f9c3aff968e19a3aeabc4eb9085787a6673.tar.xz icingaweb2-module-director-cd989f9c3aff968e19a3aeabc4eb9085787a6673.zip |
Adding upstream version 1.10.2.upstream/1.10.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'library/Director/RestApi')
-rw-r--r-- | library/Director/RestApi/IcingaObjectHandler.php | 196 | ||||
-rw-r--r-- | library/Director/RestApi/IcingaObjectsHandler.php | 144 | ||||
-rw-r--r-- | library/Director/RestApi/RequestHandler.php | 86 | ||||
-rw-r--r-- | library/Director/RestApi/RestApiClient.php | 311 | ||||
-rw-r--r-- | library/Director/RestApi/RestApiParams.php | 29 |
5 files changed, 766 insertions, 0 deletions
diff --git a/library/Director/RestApi/IcingaObjectHandler.php b/library/Director/RestApi/IcingaObjectHandler.php new file mode 100644 index 0000000..7329be3 --- /dev/null +++ b/library/Director/RestApi/IcingaObjectHandler.php @@ -0,0 +1,196 @@ +<?php + +namespace Icinga\Module\Director\RestApi; + +use Exception; +use Icinga\Exception\IcingaException; +use Icinga\Exception\NotFoundError; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Core\CoreApi; +use Icinga\Module\Director\Data\Exporter; +use Icinga\Module\Director\DirectorObject\Lookup\ServiceFinder; +use Icinga\Module\Director\Exception\DuplicateKeyException; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Resolver\OverrideHelper; +use InvalidArgumentException; +use RuntimeException; + +class IcingaObjectHandler extends RequestHandler +{ + /** @var IcingaObject */ + protected $object; + + /** @var CoreApi */ + protected $api; + + public function setObject(IcingaObject $object) + { + $this->object = $object; + return $this; + } + + public function setApi(CoreApi $api) + { + $this->api = $api; + return $this; + } + + /** + * @return IcingaObject + * @throws ProgrammingError + */ + protected function requireObject() + { + if ($this->object === null) { + throw new ProgrammingError('Object is required'); + } + + return $this->object; + } + + /** + * @return IcingaObject + */ + protected function loadOptionalObject() + { + return $this->object; + } + + protected function requireJsonBody() + { + $data = json_decode($this->request->getRawBody()); + + if ($data === null) { + $this->response->setHttpResponseCode(400); + throw new IcingaException( + 'Invalid JSON: %s', + $this->getLastJsonError() + ); + } + + return $data; + } + + protected function getType() + { + return $this->request->getControllerName(); + } + + protected function processApiRequest() + { + try { + $this->handleApiRequest(); + } catch (NotFoundError $e) { + $this->sendJsonError($e, 404); + return; + } catch (DuplicateKeyException $e) { + $this->sendJsonError($e, 422); + return; + } catch (Exception $e) { + $this->sendJsonError($e); + } + + if ($this->request->getActionName() !== 'index') { + throw new NotFoundError('Not found'); + } + } + + protected function handleApiRequest() + { + $request = $this->request; + $db = $this->db; + + // TODO: I hate doing this: + if ($this->request->getActionName() === 'ticket') { + $host = $this->requireObject(); + + if ($host->getResolvedProperty('has_agent') !== 'y') { + throw new NotFoundError('The host "%s" is not an agent', $host->getObjectName()); + } + + $this->sendJson($this->api->getTicket($host->getObjectName())); + + // TODO: find a better way to shut down. Currently, this avoids + // "not found" errors: + exit; + } + + switch ($request->getMethod()) { + case 'DELETE': + $object = $this->requireObject(); + $object->delete(); + $this->sendJson($object->toPlainObject(false, true)); + break; + + case 'POST': + case 'PUT': + $data = (array) $this->requireJsonBody(); + $params = $this->request->getUrl()->getParams(); + $allowsOverrides = $params->get('allowOverrides'); + $type = $this->getType(); + if ($object = $this->loadOptionalObject()) { + if ($request->getMethod() === 'POST') { + $object->setProperties($data); + } else { + $data = array_merge([ + 'object_type' => $object->get('object_type'), + 'object_name' => $object->getObjectName() + ], $data); + $object->replaceWith(IcingaObject::createByType($type, $data, $db)); + } + $this->persistChanges($object); + $this->sendJson($object->toPlainObject(false, true)); + } elseif ($allowsOverrides && $type === 'service') { + if ($request->getMethod() === 'PUT') { + throw new InvalidArgumentException('Overrides are not (yet) available for HTTP PUT'); + } + $this->setServiceProperties($params->getRequired('host'), $params->getRequired('name'), $data); + } else { + $object = IcingaObject::createByType($type, $data, $db); + $this->persistChanges($object); + $this->sendJson($object->toPlainObject(false, true)); + } + break; + + case 'GET': + $object = $this->requireObject(); + $exporter = new Exporter($this->db); + RestApiParams::applyParamsToExporter($exporter, $this->request, $object->getShortTableName()); + $this->sendJson($exporter->export($object)); + break; + + default: + $request->getResponse()->setHttpResponseCode(400); + throw new IcingaException('Unsupported method ' . $request->getMethod()); + } + } + + protected function persistChanges(IcingaObject $object) + { + if ($object->hasBeenModified()) { + $status = $object->hasBeenLoadedFromDb() ? 200 : 201; + $object->store(); + $this->response->setHttpResponseCode($status); + } else { + $this->response->setHttpResponseCode(304); + } + } + + protected function setServiceProperties($hostname, $serviceName, $properties) + { + $host = IcingaHost::load($hostname, $this->db); + $service = ServiceFinder::find($host, $serviceName); + if ($service === false) { + throw new NotFoundError('Not found'); + } + if ($service->requiresOverrides()) { + unset($properties['host']); + OverrideHelper::applyOverriddenVars($host, $serviceName, $properties); + $this->persistChanges($host); + $this->sendJson($host->toPlainObject(false, true)); + } else { + throw new RuntimeException('Found a single service, which should have been found (and dealt with) before'); + } + } +} diff --git a/library/Director/RestApi/IcingaObjectsHandler.php b/library/Director/RestApi/IcingaObjectsHandler.php new file mode 100644 index 0000000..471987a --- /dev/null +++ b/library/Director/RestApi/IcingaObjectsHandler.php @@ -0,0 +1,144 @@ +<?php + +namespace Icinga\Module\Director\RestApi; + +use Exception; +use gipfl\Json\JsonString; +use Icinga\Application\Benchmark; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Data\Exporter; +use Icinga\Module\Director\Db\Cache\PrefetchCache; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Web\Table\ApplyRulesTable; +use Icinga\Module\Director\Web\Table\ObjectsTable; +use Zend_Db_Select as ZfSelect; + +class IcingaObjectsHandler extends RequestHandler +{ + /** @var ObjectsTable */ + protected $table; + + public function processApiRequest() + { + try { + $this->streamJsonResult(); + } catch (Exception $e) { + $this->sendJsonError($e); + } + } + + /** + * @param ObjectsTable|ApplyRulesTable $table + * @return $this + */ + public function setTable($table) + { + $this->table = $table; + return $this; + } + + /** + * @return ObjectsTable + * @throws ProgrammingError + */ + protected function getTable() + { + if ($this->table === null) { + throw new ProgrammingError('Table is required'); + } + + return $this->table; + } + + /** + * @throws ProgrammingError + * @throws \Zend_Db_Select_Exception + * @throws \Zend_Db_Statement_Exception + */ + protected function streamJsonResult() + { + $this->response->setHeader('Content-Type', 'application/json', true); + $this->response->sendHeaders(); + $connection = $this->db; + Benchmark::measure('Ready to stream JSON result'); + $db = $connection->getDbAdapter(); + $table = $this->getTable(); + $exporter = new Exporter($connection); + $type = $table->getType(); + RestApiParams::applyParamsToExporter($exporter, $this->request, $type); + $query = $table + ->getQuery() + ->reset(ZfSelect::COLUMNS) + ->columns('*') + ->reset(ZfSelect::LIMIT_COUNT) + ->reset(ZfSelect::LIMIT_OFFSET); + if ($type === 'service' && $table instanceof ApplyRulesTable) { + $exporter->showIds(); + } + echo '{ "objects": [ '; + $cnt = 0; + $objects = []; + + $dummy = IcingaObject::createByType($type, [], $connection); + $dummy->prefetchAllRelatedTypes(); + + Benchmark::measure('Pre-fetching related objects'); + PrefetchCache::initialize($this->db); + Benchmark::measure('Ready to query'); + $stmt = $db->query($query); + $this->response->sendHeaders(); + if (! ob_get_level()) { + ob_start(); + } + + $first = true; + $flushes = 0; + while ($row = $stmt->fetch()) { + /** @var IcingaObject $object */ + if ($first) { + Benchmark::measure('Fetching first row'); + } + $object = $dummy::fromDbRow($row, $connection); + $objects[] = JsonString::encode($exporter->export($object), JSON_PRETTY_PRINT); + if ($first) { + Benchmark::measure('Got first row'); + $first = false; + } + $cnt++; + if ($cnt === 100) { + if ($flushes > 0) { + echo ', '; + } + echo implode(', ', $objects); + $cnt = 0; + $objects = []; + $flushes++; + ob_end_flush(); + ob_start(); + } + } + + if ($cnt > 0) { + if ($flushes > 0) { + echo ', '; + } + echo implode(', ', $objects); + } + + if ($this->request->getUrl()->getParams()->get('benchmark')) { + echo "],\n"; + Benchmark::measure('All done'); + echo '"benchmark_string": ' . json_encode(Benchmark::renderToText()); + } else { + echo '] '; + } + + echo "}\n"; + if (ob_get_level()) { + ob_end_flush(); + } + + // TODO: can we improve this? + exit; + } +} diff --git a/library/Director/RestApi/RequestHandler.php b/library/Director/RestApi/RequestHandler.php new file mode 100644 index 0000000..6f66889 --- /dev/null +++ b/library/Director/RestApi/RequestHandler.php @@ -0,0 +1,86 @@ +<?php + +namespace Icinga\Module\Director\RestApi; + +use Exception; +use gipfl\Json\JsonString; +use Icinga\Module\Director\Db; +use Icinga\Web\Request; +use Icinga\Web\Response; + +abstract class RequestHandler +{ + /** @var Request */ + protected $request; + + /** @var Response */ + protected $response; + + /** @var Db */ + protected $db; + + public function __construct(Request $request, Response $response, Db $db) + { + $this->request = $request; + $this->response = $response; + $this->db = $db; + } + + abstract protected function processApiRequest(); + + public function dispatch() + { + $this->processApiRequest(); + } + + public function sendJson($object) + { + $this->response->setHeader('Content-Type', 'application/json', true); + $this->response->sendHeaders(); + echo JsonString::encode($object, JSON_PRETTY_PRINT) . "\n"; + } + + public function sendJsonError($error, $code = null) + { + $response = $this->response; + if ($code === null) { + if ($response->getHttpResponseCode() === 200) { + $response->setHttpResponseCode(500); + } + } else { + $response->setHttpResponseCode((int) $code); + } + + if ($error instanceof Exception) { + $message = $error->getMessage(); + } else { + $message = $error; + } + + $response->sendHeaders(); + $result = ['error' => $message]; + if ($this->request->getUrl()->getParam('showStacktrace')) { + $result['trace'] = $error->getTraceAsString(); + } + $this->sendJson((object) $result); + } + + // TODO: just return json_last_error_msg() for PHP >= 5.5.0 + protected function getLastJsonError() + { + switch (json_last_error()) { + case JSON_ERROR_DEPTH: + return 'The maximum stack depth has been exceeded'; + case JSON_ERROR_CTRL_CHAR: + return 'Control character error, possibly incorrectly encoded'; + case JSON_ERROR_STATE_MISMATCH: + return 'Invalid or malformed JSON'; + case JSON_ERROR_SYNTAX: + return 'Syntax error'; + case JSON_ERROR_UTF8: + return 'Malformed UTF-8 characters, possibly incorrectly encoded'; + default: + return 'An error occured when parsing a JSON string'; + } + } +} diff --git a/library/Director/RestApi/RestApiClient.php b/library/Director/RestApi/RestApiClient.php new file mode 100644 index 0000000..2ebc4d4 --- /dev/null +++ b/library/Director/RestApi/RestApiClient.php @@ -0,0 +1,311 @@ +<?php + +namespace Icinga\Module\Director\RestApi; + +use Icinga\Module\Director\Core\Json; +use InvalidArgumentException; +use RuntimeException; + +class RestApiClient +{ + /** @var resource */ + private $curl; + + /** @var string HTTP or HTTPS */ + private $scheme; + + /** @var string */ + private $host; + + /** @var int */ + private $port; + + /** @var string */ + private $user; + + /** @var string */ + private $pass; + + /** @var bool */ + private $verifySslPeer = true; + + /** @var bool */ + private $verifySslHost = true; + + /** @var string */ + private $proxy; + + /** @var string */ + private $proxyType; + + /** @var string */ + private $proxyUser; + + /** @var string */ + private $proxyPass; + + /** @var array */ + private $proxyTypes = [ + 'HTTP' => CURLPROXY_HTTP, + 'SOCKS5' => CURLPROXY_SOCKS5, + ]; + + /** + * RestApiClient constructor. + * + * Please note that only the host is required, user and pass are optional + * + * @param string $host + * @param string|null $user + * @param string|null $pass + */ + public function __construct($host, $user = null, $pass = null) + { + $this->host = $host; + $this->user = $user; + $this->pass = $pass; + } + + /** + * Use a proxy + * + * @param $url + * @param string $type Either HTTP or SOCKS5 + * @return $this + */ + public function setProxy($url, $type = 'HTTP') + { + $this->proxy = $url; + if (\is_int($type)) { + $this->proxyType = $type; + } else { + $this->proxyType = $this->proxyTypes[$type]; + } + return $this; + } + + /** + * @param string $user + * @param string $pass + * @return $this + */ + public function setProxyAuth($user, $pass) + { + $this->proxyUser = $user; + $this->proxyPass = $pass; + return $this; + } + + /** + * @return string + */ + public function getScheme() + { + if ($this->scheme === null) { + return 'HTTPS'; + } else { + return $this->scheme; + } + } + + public function setScheme($scheme) + { + $scheme = \strtoupper($scheme); + if (! \in_array($scheme, ['HTTP', 'HTTPS'])) { + throw new InvalidArgumentException("Got invalid scheme: $scheme"); + } + + $this->scheme = $scheme; + return $this; + } + + /** + * @return string + */ + public function getPort() + { + if ($this->port === null) { + return $this->getScheme() === 'HTTPS' ? 443 : 80; + } else { + return $this->port; + } + } + + /** + * @param int|string|null $port + * @return $this + */ + public function setPort($port) + { + if ($port === null) { + $this->port = null; + return $this; + } + $port = (int) ($port); + if ($port < 1 || $port > 65535) { + throw new InvalidArgumentException("Got invalid port: $port"); + } + + $this->port = $port; + return $this; + } + + /** + * @return bool + */ + public function isDefaultPort() + { + return $this->port === null + || $this->getScheme() === 'HTTPS' && $this->getPort() === 443 + || $this->getScheme() === 'HTTP' && $this->getPort() === 80; + } + + /** + * @param bool $disable + * @return $this + */ + public function disableSslPeerVerification($disable = true) + { + $this->verifySslPeer = ! $disable; + return $this; + } + + /** + * @param bool $disable + * @return $this + */ + public function disableSslHostVerification($disable = true) + { + $this->verifySslHost = ! $disable; + return $this; + } + + /** + * @param string $url + * @return string + */ + public function url($url) + { + return \sprintf( + '%s://%s%s/%s', + \strtolower($this->getScheme()), + $this->host, + $this->isDefaultPort() ? '' : ':' . $this->getPort(), + ltrim($url, '/') + ); + } + + /** + * @param string $url + * @param mixed $body + * @param array $headers + * @return mixed + */ + public function get($url, $body = null, $headers = []) + { + return $this->request('get', $url, $body, $headers); + } + + /** + * @param $url + * @param null $body + * @param array $headers + * @return mixed + */ + public function post($url, $body = null, $headers = []) + { + return $this->request('post', $url, Json::encode($body), $headers); + } + + /** + * @param $method + * @param $url + * @param null $body + * @param array $headers + * @return mixed + */ + protected function request($method, $url, $body = null, $headers = []) + { + $sendHeaders = ['Host: ' . $this->host]; + foreach ($headers as $key => $val) { + $sendHeaders[] = "$key: $val"; + } + + if (! \in_array('Accept', $headers)) { + $sendHeaders[] = 'Accept: application/json'; + } + + $url = $this->url($url); + $opts = [ + CURLOPT_URL => $url, + CURLOPT_HTTPHEADER => $sendHeaders, + CURLOPT_CUSTOMREQUEST => \strtoupper($method), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + ]; + + if ($this->getScheme() === 'HTTPS') { + $opts[CURLOPT_SSL_VERIFYPEER] = $this->verifySslPeer; + $opts[CURLOPT_SSL_VERIFYHOST] = $this->verifySslHost ? 2 : 0; + } + + if ($this->user !== null) { + $opts[CURLOPT_USERPWD] = \sprintf('%s:%s', $this->user, $this->pass); + } + + if ($this->proxy) { + $opts[CURLOPT_PROXY] = $this->proxy; + $opts[CURLOPT_PROXYTYPE] = $this->proxyType; + + if ($this->proxyUser) { + $opts['CURLOPT_PROXYUSERPWD'] = \sprintf( + '%s:%s', + $this->proxyUser, + $this->proxyPass + ); + } + } + + if ($body !== null) { + $opts[CURLOPT_POSTFIELDS] = $body; + } + + $curl = $this->curl(); + \curl_setopt_array($curl, $opts); + + $res = \curl_exec($curl); + if ($res === false) { + throw new RuntimeException('CURL ERROR: ' . \curl_error($curl)); + } + + $statusCode = \curl_getinfo($curl, CURLINFO_HTTP_CODE); + if ($statusCode === 401) { + throw new RuntimeException( + 'Unable to authenticate, please check your REST API credentials' + ); + } + + if ($statusCode >= 400) { + throw new RuntimeException( + "Got $statusCode: " . \var_export($res, 1) + ); + } + + return Json::decode($res); + } + + /** + * @return resource + */ + protected function curl() + { + if ($this->curl === null) { + $this->curl = \curl_init(\sprintf('https://%s:%d', $this->host, $this->port)); + if (! $this->curl) { + throw new RuntimeException('CURL INIT ERROR: ' . \curl_error($this->curl)); + } + } + + return $this->curl; + } +} diff --git a/library/Director/RestApi/RestApiParams.php b/library/Director/RestApi/RestApiParams.php new file mode 100644 index 0000000..c237ac5 --- /dev/null +++ b/library/Director/RestApi/RestApiParams.php @@ -0,0 +1,29 @@ +<?php + +namespace Icinga\Module\Director\RestApi; + +use Icinga\Module\Director\Data\Exporter; +use Icinga\Web\Request; +use InvalidArgumentException; + +class RestApiParams +{ + public static function applyParamsToExporter(Exporter $exporter, Request $request, $shortObjectType = null) + { + $params = $request->getUrl()->getParams(); + $resolved = (bool) $params->get('resolved', false); + $withNull = $params->shift('withNull'); + if ($params->get('withServices')) { + if ($shortObjectType !== 'host') { + throw new InvalidArgumentException('withServices is available for Hosts only'); + } + $exporter->enableHostServices(); + } + $properties = $params->shift('properties'); + if ($properties !== null && strlen($properties)) { + $exporter->filterProperties(preg_split('/\s*,\s*/', $properties, -1, PREG_SPLIT_NO_EMPTY)); + } + $exporter->resolveObjects($resolved); + $exporter->showDefaults($withNull); + } +} |