summaryrefslogtreecommitdiffstats
path: root/library/Director/RestApi
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:43:12 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:43:12 +0000
commitcd989f9c3aff968e19a3aeabc4eb9085787a6673 (patch)
treefbff2135e7013f196b891bbde54618eb050e4aaf /library/Director/RestApi
parentInitial commit. (diff)
downloadicingaweb2-module-director-upstream.tar.xz
icingaweb2-module-director-upstream.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.php196
-rw-r--r--library/Director/RestApi/IcingaObjectsHandler.php144
-rw-r--r--library/Director/RestApi/RequestHandler.php86
-rw-r--r--library/Director/RestApi/RestApiClient.php311
-rw-r--r--library/Director/RestApi/RestApiParams.php29
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);
+ }
+}