summaryrefslogtreecommitdiffstats
path: root/library/Director/Core
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--library/Director/Core/CoreApi.php940
-rw-r--r--library/Director/Core/DeploymentApiInterface.php75
-rw-r--r--library/Director/Core/Json.php34
-rw-r--r--library/Director/Core/LegacyDeploymentApi.php466
-rw-r--r--library/Director/Core/RestApiClient.php276
-rw-r--r--library/Director/Core/RestApiResponse.php149
-rw-r--r--library/Director/CoreBeta/ApiStream.php57
-rw-r--r--library/Director/CoreBeta/Stream.php17
-rw-r--r--library/Director/CoreBeta/StreamContext.php89
-rw-r--r--library/Director/CoreBeta/StreamContextSslOptions.php52
10 files changed, 2155 insertions, 0 deletions
diff --git a/library/Director/Core/CoreApi.php b/library/Director/Core/CoreApi.php
new file mode 100644
index 0000000..ea10916
--- /dev/null
+++ b/library/Director/Core/CoreApi.php
@@ -0,0 +1,940 @@
+<?php
+
+namespace Icinga\Module\Director\Core;
+
+use Exception;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Hook\DeploymentHook;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use Icinga\Module\Director\Objects\DirectorDeploymentLog;
+use Icinga\Module\Director\Objects\IcingaZone;
+use Icinga\Web\Hook;
+use RuntimeException;
+
+class CoreApi implements DeploymentApiInterface
+{
+ protected $client;
+
+ protected $initialized = false;
+
+ /** @var Db */
+ protected $db;
+
+ public function __construct(RestApiClient $client)
+ {
+ $this->client = $client;
+ }
+
+ // Todo: type
+ public function setDb(Db $db)
+ {
+ $this->db = $db;
+ return $this;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getVersion()
+ {
+ return $this->parseVersion($this->getRawVersion());
+ }
+
+ public function enableWorkaroundForConnectionIssues()
+ {
+ $version = $this->getVersion();
+
+ if ($version === null ||
+ ((version_compare($version, '2.8.2', '>=') && version_compare($version, '2.10.2', '<')))
+ ) {
+ $this->client->disconnect();
+ $this->client->setKeepAlive(false);
+ }
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getRawVersion()
+ {
+ try {
+ return $this->client()->get('')->getRaw('version');
+ } catch (Exception $exception) {
+ return null;
+ }
+ }
+
+ /**
+ * @param $version
+ * @return string|null
+ */
+ protected function parseVersion($version)
+ {
+ if ($version === null) {
+ return null;
+ }
+
+ if (preg_match('/^[rv]?(\d\.\d+\.\d+)/', $version, $match)) {
+ return $match[1];
+ } else {
+ return null;
+ }
+ }
+
+ public function getObjects($pluralType, $attrs = array(), $ignorePackage = null)
+ {
+ $params = (object) [];
+ if ($ignorePackage) {
+ $params->filter = 'obj.package!="' . $ignorePackage . '"';
+ }
+
+ if (! empty($attrs)) {
+ $params->attrs = $attrs;
+ }
+
+ return $this->client()->get(
+ 'objects/' . urlencode(strtolower($pluralType)),
+ $params
+ )->getResult('name');
+ }
+
+ public function onEvent($callback, $raw = false)
+ {
+ $this->client->onEvent($callback, $raw);
+
+ return $this;
+ }
+
+ public function getObject($name, $pluraltype, $attrs = array())
+ {
+ $params = (object) array(
+ );
+
+ if (! empty($attrs)) {
+ $params->attrs = $attrs;
+ }
+ $url = 'objects/' . urlencode(strtolower($pluraltype)) . '/' . rawurlencode($name) . '?all_joins=1';
+ $res = $this->client()->get($url, $params)->getResult('name');
+
+ // TODO: check key, throw
+ return $res[$name];
+ }
+
+ /**
+ * Get a PKI ticket for CSR auto-signing
+ *
+ * @param string $cn The host’s common name for which the ticket should be generated
+ *
+ * @return string|null
+ */
+ public function getTicket($cn)
+ {
+ $r = $this->client()->post(
+ 'actions/generate-ticket',
+ ['cn' => $cn]
+ );
+ if (! $r->succeeded()) {
+ throw new RuntimeException($r->getErrorMessage());
+ }
+
+ $ticket = $r->getRaw('ticket');
+ if ($ticket === null) {
+ // RestApiResponse::succeeded() returns true if Icinga 2 reports an error in the results key, e.g.
+ // {
+ // "results": [
+ // {
+ // "code": 500.0,
+ // "status": "Ticket salt is not configured in ApiListener object"
+ // }
+ // ]
+ // }
+ throw new RuntimeException($r->getRaw('status', 'Ticket is empty'));
+ }
+
+ return $ticket;
+ }
+
+ public function checkHostNow($host)
+ {
+ $filter = 'host.name == "' . $host . '"';
+
+ return $this->client()->post(
+ 'actions/reschedule-check?filter=' . rawurlencode($filter),
+ (object) array(
+ 'type' => 'Host'
+ )
+ );
+ }
+
+ public function checkServiceNow($host, $service)
+ {
+ $filter = 'host.name == "' . $host . '" && service.name == "' . $service . '"';
+ $this->client()->post(
+ 'actions/reschedule-check?filter=' . rawurlencode($filter),
+ (object) array(
+ 'type' => 'Service'
+ )
+ );
+ }
+
+ public function acknowledgeHostProblem($host, $author, $comment)
+ {
+ $filter = 'host.name == "' . $host . '"';
+ return $this->client()->post(
+ 'actions/acknowledge-problem?type=Host&filter=' . rawurlencode($filter),
+ (object) array(
+ 'author' => $author,
+ 'comment' => $comment
+ )
+ );
+ }
+
+ public function removeHostAcknowledgement($host)
+ {
+ $filter = 'host.name == "' . $host . '"';
+ return $this->client()->post(
+ 'actions/remove-acknowledgement?type=Host&filter=' . rawurlencode($filter)
+ );
+ }
+
+ public function reloadNow()
+ {
+ try {
+ $this->client()->post('actions/restart-process');
+
+ return true;
+ } catch (Exception $e) {
+ return $e->getMessage();
+ }
+ }
+
+ public function getHostOutput($host)
+ {
+ try {
+ $object = $this->getObject($host, 'hosts');
+ } catch (Exception $e) {
+ return 'Unable to fetch the requested object';
+ }
+ if (isset($object->attrs->last_check_result)) {
+ return $object->attrs->last_check_result->output;
+ } else {
+ return '(no check result available)';
+ }
+ }
+
+ public function checkHostAndWaitForResult($host, $timeout = 10)
+ {
+ $object = $this->getObject($host, 'hosts');
+ if (isset($object->attrs->last_check_result)) {
+ $oldOutput = $object->attrs->last_check_result->output;
+ } else {
+ $oldOutput = '';
+ }
+
+ $now = microtime(true);
+ $this->checkHostNow($host);
+
+ while (true) {
+ try {
+ $object = $this->getObject($host, 'hosts');
+ if (isset($object->attrs->last_check_result)) {
+ $res = $object->attrs->last_check_result;
+ if ($res->execution_start > $now || $res->output !== $oldOutput) {
+ return $res;
+ }
+ } else {
+ // no check result available
+ }
+ } catch (Exception $e) {
+ // Unable to fetch the requested object
+ throw new RuntimeException(sprintf(
+ 'Unable to fetch the requested host "%s"',
+ $host
+ ));
+ }
+ if (microtime(true) > ($now + $timeout)) {
+ break;
+ }
+
+ usleep(50000);
+ }
+
+ return false;
+ }
+
+ public function checkServiceAndWaitForResult($host, $service, $timeout = 10)
+ {
+ $now = microtime(true);
+ $this->checkServiceNow($host, $service);
+
+ while (true) {
+ try {
+ $object = $this->getObject("$host!$service", 'services');
+ if (isset($object->attrs->last_check_result)) {
+ $res = $object->attrs->last_check_result;
+ if ($res->execution_start > $now) {
+ return $res;
+ }
+ } else {
+ // no check result available
+ }
+ } catch (Exception $e) {
+ // Unable to fetch the requested object
+ throw new RuntimeException(sprintf(
+ 'Unable to fetch the requested service "%s" on "%s"',
+ $service,
+ $host
+ ));
+ }
+ if (microtime(true) > ($now + $timeout)) {
+ break;
+ }
+
+ usleep(150000);
+ }
+
+ return false;
+ }
+
+ public function getServiceOutput($host, $service)
+ {
+ try {
+ $object = $this->getObject($host . '!' . $service, 'services');
+ } catch (\Exception $e) {
+ return 'Unable to fetch the requested object';
+ }
+ if (isset($object->attrs->last_check_result)) {
+ return $object->attrs->last_check_result->output;
+ } else {
+ return '(no check result available)';
+ }
+ }
+
+ public function supportsRuntimeCreationFor(IcingaObject $object)
+ {
+ $valid = array('host');
+ return in_array($object->getShortTableName(), $valid);
+ }
+
+ protected function assertRuntimeCreationSupportFor(IcingaObject $object)
+ {
+ if (!$this->supportsRuntimeCreationFor($object)) {
+ throw new RuntimeException(sprintf(
+ 'Object creation at runtime is not supported for "%s"',
+ $object->getShortTableName()
+ ));
+ }
+ }
+
+ // Note: this is for testing purposes only, NOT production-ready
+ public function createObjectAtRuntime(IcingaObject $object)
+ {
+ $this->assertRuntimeCreationSupportFor($object);
+
+ $key = $object->getShortTableName();
+
+ $command = sprintf(
+ "f = function() {\n"
+ . ' existing = get_%s("%s")'
+ . "\n if (existing) { return false }"
+ . "\n%s\n}\nInternal.run_with_activation_context(f)\n",
+ $key,
+ $object->get('object_name'),
+ (string) $object
+ );
+
+ return $this->runConsoleCommand($command)->getSingleResult();
+ }
+
+ public function getConstants()
+ {
+ $constants = array();
+ $command = 'var constants = [];
+for (k => v in globals) {
+ if (typeof(v) in [String, Number, Boolean]) {
+ res = { name = k, value = v }
+ constants.add({name = k, value = v})
+ }
+};
+constants
+';
+
+ foreach ($this->runConsoleCommand($command)->getSingleResult() as $row) {
+ $constants[$row->name] = $row->value;
+ }
+
+ return $constants;
+ }
+
+ public function runConsoleCommand($command)
+ {
+ return $this->client()->post(
+ 'console/execute-script',
+ array('command' => $command)
+ );
+ }
+
+ public function getConstant($name)
+ {
+ $constants = $this->getConstants();
+ if (array_key_exists($name, $constants)) {
+ return $constants[$name];
+ }
+
+ return null;
+ }
+
+ public function getTypes()
+ {
+ return $this->client()->get('types')->getResult('name');
+ }
+
+ public function getType($type)
+ {
+ $res = $this->client()->get('types', array('name' => $type))->getResult('name');
+ return $res[$type]; // TODO: error checking
+ }
+
+ public function getStatus()
+ {
+ return $this->client()->get('status')->getResult('name');
+ }
+
+ public function listObjects($type, $pluralType)
+ {
+ // TODO: more abstraction needed
+ // TODO: autofetch and cache pluraltypes
+ try {
+ $result = $this->client()->get(
+ 'objects/' . $pluralType,
+ array(
+ 'attrs' => array('__name')
+ )
+ )->getResult('name');
+ } catch (NotFoundError $e) {
+ $result = [];
+ }
+
+ return array_keys($result);
+ }
+
+ public function getPackages()
+ {
+ return $this->client()->get('config/packages')->getResult('name');
+ }
+
+ public function getActiveStageName()
+ {
+ return current($this->listPackageStages($this->getPackageName(), true));
+ }
+
+ protected function getPackageName()
+ {
+ return $this->db->settings()->get('icinga_package_name');
+ }
+
+ public function getActiveChecksum(Db $conn)
+ {
+ $db = $conn->getDbAdapter();
+ $stage = $this->getActiveStageName();
+ if (! $stage) {
+ return null;
+ }
+
+ $query = $db->select()->from(
+ array('l' => 'director_deployment_log'),
+ array('checksum' => $conn->dbHexFunc('l.config_checksum'))
+ )->where('l.stage_name = ?', $stage);
+
+ return $db->fetchOne($query);
+ }
+
+ protected function getDirectorObjects($type, $plural, $map)
+ {
+ $attrs = array_merge(
+ array_keys($map),
+ array('package', 'templates', 'active')
+ );
+
+ $objects = array();
+ $result = $this->getObjects($plural, $attrs, $this->getPackageName());
+ foreach ($result as $name => $row) {
+ $attrs = $row->attrs;
+
+ $properties = array(
+ 'object_name' => $name,
+ 'object_type' => 'external_object'
+ );
+
+ foreach ($map as $key => $prop) {
+ if (property_exists($attrs, $key)) {
+ $properties[$prop] = $attrs->$key;
+ }
+ }
+
+ $objects[$name] = IcingaObject::createByType($type, $properties, $this->db);
+ }
+
+ return $objects;
+ }
+
+ /**
+ * @return IcingaZone[]
+ */
+ public function getZoneObjects()
+ {
+ return $this->getDirectorObjects('Zone', 'zones', [
+ 'parent' => 'parent',
+ 'global' => 'is_global',
+ ]);
+ }
+
+ public function getUserObjects()
+ {
+ return $this->getDirectorObjects('User', 'users', [
+ 'display_name' => 'display_name',
+ 'email' => 'email',
+ 'groups' => 'groups',
+ 'vars' => 'vars',
+ ]);
+ }
+
+ protected function buildEndpointZoneMap()
+ {
+ $zones = $this->getObjects('zones', ['endpoints'], $this->getPackageName());
+ $zoneMap = array();
+
+ foreach ($zones as $name => $zone) {
+ if (! is_array($zone->attrs->endpoints)) {
+ continue;
+ }
+ foreach ($zone->attrs->endpoints as $endpoint) {
+ $zoneMap[$endpoint] = $name;
+ }
+ }
+
+ return $zoneMap;
+ }
+
+ public function getEndpointObjects()
+ {
+ $zoneMap = $this->buildEndpointZoneMap();
+ $objects = $this->getDirectorObjects('Endpoint', 'endpoints', [
+ 'host' => 'host',
+ 'port' => 'port',
+ 'log_duration' => 'log_duration',
+ ]);
+
+ foreach ($objects as $object) {
+ $name = $object->object_name;
+ if (array_key_exists($name, $zoneMap)) {
+ $object->zone = $zoneMap[$name];
+ }
+ }
+
+ return $objects;
+ }
+
+ public function getHostObjects()
+ {
+ $params = [
+ 'display_name' => 'display_name',
+ 'address' => 'address',
+ 'address6' => 'address6',
+ 'templates' => 'imports',
+ 'groups' => 'groups',
+ 'vars' => 'vars',
+ 'check_command' => 'check_command',
+ 'max_check_attempts' => 'max_check_attempts',
+ 'check_period' => 'check_period',
+ 'check_interval' => 'check_interval',
+ 'retry_interval' => 'retry_interval',
+ 'enable_notifications' => 'enable_notifications',
+ 'enable_active_checks' => 'enable_active_checks',
+ 'enable_passive_checks' => 'enable_passive_checks',
+ 'enable_event_handler' => 'enable_event_handler',
+ 'enable_flapping' => 'enable_flapping',
+ 'enable_perfdata' => 'enable_perfdata',
+ 'event_command' => 'event_command',
+ 'volatile' => 'volatile',
+ 'zone' => 'zone',
+ 'command_endpoint' => 'command_endpoint',
+ 'notes' => 'notes',
+ 'notes_url' => 'notes_url',
+ 'action_url' => 'action_url',
+ 'icon_image' => 'icon_image',
+ 'icon_image_alt' => 'icon_image_alt',
+ ];
+
+ if (version_compare($this->getVersion(), '2.8.0', '>=')) {
+ $params['flapping_threshold_high'] = 'flapping_threshold_high';
+ $params['flapping_threshold_low'] = 'flapping_threshold_low';
+ }
+
+ return $this->getDirectorObjects('Host', 'hosts', $params);
+ }
+
+ public function getHostGroupObjects()
+ {
+ return $this->getDirectorObjects('HostGroup', 'hostgroups', [
+ 'display_name' => 'display_name',
+ ]);
+ }
+
+ public function getUserGroupObjects()
+ {
+ return $this->getDirectorObjects('UserGroup', 'usergroups', [
+ 'display_name' => 'display_name',
+ ]);
+ }
+
+ /**
+ * @return IcingaCommand[]
+ */
+ public function getCheckCommandObjects()
+ {
+ return $this->getSpecificCommandObjects('Check');
+ }
+
+ /**
+ * @return IcingaCommand[]
+ */
+ public function getNotificationCommandObjects()
+ {
+ return $this->getSpecificCommandObjects('Notification');
+ }
+
+ /**
+ * @return IcingaCommand[]
+ */
+ public function getEventCommandObjects()
+ {
+ return $this->getSpecificCommandObjects('Event');
+ }
+
+ /**
+ * @return IcingaCommand[]
+ */
+ public function getSpecificCommandObjects($type)
+ {
+ IcingaCommand::setPluginDir($this->getConstant('PluginDir'));
+
+ $objects = $this->getDirectorObjects('Command', "${type}Commands", [
+ 'arguments' => 'arguments',
+ // 'env' => 'env',
+ 'timeout' => 'timeout',
+ 'command' => 'command',
+ 'vars' => 'vars',
+ ]);
+ foreach ($objects as $obj) {
+ $obj->methods_execute = "Plugin$type";
+ }
+
+ return $objects;
+ }
+
+ public function listPackageStages($name, $active = null)
+ {
+ $packages = $this->getPackages();
+ $found = array();
+
+ if (array_key_exists($name, $packages)) {
+ $package = $packages[$name];
+ $current = $package->{'active-stage'};
+ foreach ($package->stages as $stage) {
+ if ($active === null) {
+ $found[] = $stage;
+ } elseif ($active === true) {
+ if ($current === $stage) {
+ $found[] = $stage;
+ }
+ } elseif ($active === false) {
+ if ($current !== $stage) {
+ $found[] = $stage;
+ }
+ }
+ }
+ }
+
+ return $found;
+ }
+
+ public function collectLogFiles(Db $db)
+ {
+ $existing = $this->listPackageStages($this->getPackageName());
+ $missing = [];
+ $empty = [];
+ foreach (DirectorDeploymentLog::getUncollected($db) as $deployment) {
+ $stage = $deployment->get('stage_name');
+ if (! in_array($stage, $existing)) {
+ $missing[] = $deployment;
+ continue;
+ }
+
+ try {
+ $availableFiles = $this->listStageFiles($stage);
+ } catch (Exception $e) {
+ // Could not collect stage files. Doesn't matter, let's try next time
+ continue;
+ }
+
+ if (in_array('startup.log', $availableFiles)
+ && in_array('status', $availableFiles)
+ ) {
+ if ($this->getStagedFile($stage, 'status') === '0') {
+ $deployment->set('startup_succeeded', 'y');
+ } else {
+ $deployment->set('startup_succeeded', 'n');
+ }
+ $deployment->set('startup_log', $this->shortenStartupLog(
+ $this->getStagedFile($stage, 'startup.log')
+ ));
+ } else {
+ // Stage seems to be incomplete, let's try again next time
+ $empty[] = $deployment;
+ continue;
+ }
+ $deployment->set('stage_collected', 'y');
+
+ $deployment->store();
+
+ /** @var DeploymentHook[] $hooks */
+ $hooks = Hook::all('director/Deployment');
+ foreach ($hooks as $hook) {
+ $hook->onCollect($deployment);
+ }
+ }
+
+ foreach ($missing as $deployment) {
+ $deployment->set('stage_collected', 'n');
+ $deployment->store();
+ }
+
+ $running = DirectorDeploymentLog::getRelatedToActiveStage($this, $db);
+ if ($running !== null) {
+ foreach ($empty as $deployment) {
+ if ($deployment->getDeploymentTimestamp() < $running->getDeploymentTimestamp()) {
+ $deployment->set('stage_collected', 'n');
+ $deployment->store();
+ $this->deleteStage($this->getPackageName(), $deployment->get('stage_name'));
+ }
+ }
+ }
+ }
+
+ public function wipeInactiveStages(Db $db)
+ {
+ $uncollected = DirectorDeploymentLog::getUncollected($db);
+ $packageName = $this->getPackageName();
+ foreach ($this->listPackageStages($packageName, false) as $stage) {
+ if (array_key_exists($stage, $uncollected)) {
+ continue;
+ }
+ $this->client()->delete($this->prepareStageUrl($packageName, $stage));
+ }
+ }
+
+ public function listStageFiles($stage, $packageName = null)
+ {
+ if ($packageName === null) {
+ $packageName = $this->getPackageName();
+ }
+ return array_keys(
+ $this->client()
+ ->get($this->prepareStageUrl($packageName, $stage))
+ ->getResult('name', ['type' => 'file'])
+ );
+ }
+
+ public function getStagedFile($stage, $file, $packageName = null)
+ {
+ if ($packageName === null) {
+ $packageName = $this->getPackageName();
+ }
+ return $this->client()
+ ->getRaw($this->prepareFileUrl($packageName, $stage, $file));
+ }
+
+ public function hasPackage($name)
+ {
+ $modules = $this->getPackages();
+ return \array_key_exists($name, $modules);
+ }
+
+ public function createPackage($name)
+ {
+ return $this->client()->post($this->preparePackageUrl($name))->succeeded();
+ }
+
+ public function deletePackage($name)
+ {
+ return $this->client()->delete($this->preparePackageUrl($name))->succeeded();
+ }
+
+ public function assertPackageExists($name)
+ {
+ if (! $this->hasPackage($name)) {
+ if (! $this->createPackage($name)) {
+ throw new RuntimeException(sprintf(
+ 'Failed to create the package "%s" through the REST API',
+ $name
+ ));
+ }
+ }
+
+ return $this;
+ }
+
+ public function deleteStage($packageName, $stageName)
+ {
+ $this->client()->delete(
+ $this->prepareStageUrl($packageName, $stageName)
+ )->succeeded();
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function stream()
+ {
+ $allTypes = array(
+ 'CheckResult',
+ 'StateChange',
+ 'Notification',
+ 'AcknowledgementSet',
+ 'AcknowledgementCleared',
+ 'CommentAdded',
+ 'CommentRemoved',
+ 'DowntimeAdded',
+ 'DowntimeRemoved',
+ 'DowntimeTriggered'
+ );
+
+ $queue = 'director-rand';
+
+ $url = sprintf('events?queue=%s&types=%s', $queue, implode('&types=', $allTypes));
+
+ $this->client()->request('post', $url, null, false, true);
+ }
+
+ /**
+ * @param IcingaConfig $config
+ * @param Db $db
+ * @param null $packageName
+ * @return DirectorDeploymentLog
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ public function dumpConfig(IcingaConfig $config, Db $db, $packageName = null)
+ {
+ if ($packageName === null) {
+ $packageName = $db->settings()->get('icinga_package_name');
+ }
+ $start = microtime(true);
+ /** @var DirectorDeploymentLog $deployment */
+ $deployment = DirectorDeploymentLog::create(array(
+ // 'config_id' => $config->id,
+ // 'peer_identity' => $endpoint->object_name,
+ 'peer_identity' => $this->client->getPeerIdentity(),
+ 'start_time' => date('Y-m-d H:i:s'),
+ 'config_checksum' => $config->getChecksum(),
+ 'last_activity_checksum' => $config->getLastActivityChecksum()
+ // 'triggered_by' => Util::getUsername(),
+ // 'username' => Util::getUsername(),
+ // 'module_name' => $moduleName,
+ ));
+
+ /** @var DeploymentHook[] $hooks */
+ $hooks = Hook::all('director/Deployment');
+ foreach ($hooks as $hook) {
+ $hook->beforeDeploy($deployment);
+ }
+
+ $this->assertPackageExists($packageName);
+
+ $response = $this->client()->post('config/stages/' . \rawurlencode($packageName), [
+ 'files' => $config->getFileContents()
+ ]);
+
+ $duration = (int) ((microtime(true) - $start) * 1000);
+ // $deployment->duration_ms = $duration;
+ $deployment->set('duration_dump', $duration);
+
+ $succeeded = 'n';
+ if ($response->succeeded()) {
+ if ($stage = $response->getResult('stage', ['package' => $packageName])) { // Status?
+ $deployment->set('stage_name', key($stage));
+ $succeeded = 'y';
+ }
+ }
+ $deployment->set('dump_succeeded', $succeeded);
+ $deployment->store($db);
+
+ if ($succeeded === 'y') {
+ foreach ($hooks as $hook) {
+ $hook->triggerSuccessfulDump($deployment);
+ }
+ }
+
+ return $deployment;
+ }
+
+ protected function shortenStartupLog($log)
+ {
+ $logLen = strlen($log);
+ if ($logLen < 1024 * 60) {
+ return $log;
+ }
+
+ $part = substr($log, 0, 1024 * 20);
+ $parts = explode("\n", $part);
+ array_pop($parts);
+ $begin = implode("\n", $parts) . "\n\n";
+
+ $part = substr($log, -1024 * 20);
+ $parts = explode("\n", $part);
+ array_shift($parts);
+ $end = "\n\n" . implode("\n", $parts);
+
+ return $begin . sprintf(
+ '[..] %d bytes removed by Director [..]',
+ $logLen - (strlen($begin) + strlen($end))
+ ) . $end;
+ }
+
+ protected function preparePackageUrl($packageName)
+ {
+ return 'config/packages/' . \rawurlencode($packageName);
+ }
+
+ protected function prepareStageUrl($packageName, $stage)
+ {
+ return \sprintf(
+ 'config/stages/%s/%s',
+ \rawurlencode($packageName),
+ \rawurlencode($stage)
+ );
+ }
+
+ protected function prepareFileUrl($packageName, $stage, $file)
+ {
+ return \sprintf(
+ 'config/files/%s/%s/%s',
+ \rawurlencode($packageName),
+ \rawurlencode($stage),
+ \rawurlencode($file)
+ );
+ }
+
+ protected function client()
+ {
+ if ($this->initialized === false) {
+ $this->initialized = true;
+ $this->enableWorkaroundForConnectionIssues();
+ }
+
+ return $this->client;
+ }
+}
diff --git a/library/Director/Core/DeploymentApiInterface.php b/library/Director/Core/DeploymentApiInterface.php
new file mode 100644
index 0000000..026f0fd
--- /dev/null
+++ b/library/Director/Core/DeploymentApiInterface.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Icinga\Module\Director\Core;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+
+/**
+ * Interface to a deployment API of the monitoring configuration
+ *
+ * @package Icinga\Module\Director\Core
+ */
+interface DeploymentApiInterface
+{
+ /**
+ * Collecting log files from the deployment system
+ * and write them into the database.
+ *
+ * @param Db $db
+ */
+ public function collectLogFiles(Db $db);
+
+ /**
+ * Cleanup old stages that are collected and not active
+ *
+ * @param Db $db
+ */
+ public function wipeInactiveStages(Db $db);
+
+ /**
+ * Returns the active configuration stage
+ *
+ * @return string
+ */
+ public function getActiveStageName();
+
+ /**
+ * List files in a named stage
+ *
+ * @param string $stage name of the stage
+ * @return string[]
+ */
+ public function listStageFiles($stage);
+
+ /**
+ * Retrieve a raw file from the named stage
+ *
+ * @param string $stage Stage name
+ * @param string $file Relative file path
+ *
+ * @return string
+ */
+ public function getStagedFile($stage, $file);
+
+ /**
+ * Explicitly delete a stage
+ *
+ * @param string $packageName
+ * @param string $stageName
+ *
+ * @return bool
+ */
+ public function deleteStage($packageName, $stageName);
+
+ /**
+ * Deploy the config and activate it
+ *
+ * @param IcingaConfig $config
+ * @param Db $db
+ * @param string $packageName
+ *
+ * @return mixed
+ */
+ public function dumpConfig(IcingaConfig $config, Db $db, $packageName = null);
+}
diff --git a/library/Director/Core/Json.php b/library/Director/Core/Json.php
new file mode 100644
index 0000000..507349c
--- /dev/null
+++ b/library/Director/Core/Json.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Icinga\Module\Director\Core;
+
+use Icinga\Module\Director\Exception\JsonEncodeException;
+
+class Json
+{
+ public static function encode($mixed, $flags = null)
+ {
+ if ($flags === null) {
+ $result = \json_encode($mixed);
+ } else {
+ $result = \json_encode($mixed, $flags);
+ }
+
+ if ($result === false && json_last_error() !== JSON_ERROR_NONE) {
+ throw JsonEncodeException::forLastJsonError();
+ }
+
+ return $result;
+ }
+
+ public static function decode($string)
+ {
+ $result = \json_decode($string);
+
+ if ($result === null && json_last_error() !== JSON_ERROR_NONE) {
+ throw JsonEncodeException::forLastJsonError();
+ }
+
+ return $result;
+ }
+}
diff --git a/library/Director/Core/LegacyDeploymentApi.php b/library/Director/Core/LegacyDeploymentApi.php
new file mode 100644
index 0000000..7287c4a
--- /dev/null
+++ b/library/Director/Core/LegacyDeploymentApi.php
@@ -0,0 +1,466 @@
+<?php
+
+namespace Icinga\Module\Director\Core;
+
+use Exception;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Objects\DirectorDeploymentLog;
+
+/**
+ * Legacy DeploymentApi for Icinga 1.x configuration deployment
+ *
+ * @package Icinga\Module\Director\Core
+ */
+class LegacyDeploymentApi implements DeploymentApiInterface
+{
+ protected $db;
+ protected $deploymentPath;
+ protected $activationScript;
+
+ protected $dir_mode;
+ protected $file_mode;
+
+ public function __construct(Db $db)
+ {
+ $this->db = $db;
+ $settings = $this->db->settings();
+ $this->deploymentPath = $settings->deployment_path_v1;
+ $this->activationScript = $settings->activation_script_v1;
+
+ $this->dir_mode = base_convert($settings->get('deployment_file_mode_v1', '2775'), 8, 10);
+ $this->file_mode = base_convert($settings->get('deployment_dir_mode_v1', '0664'), 8, 10);
+ }
+
+ /**
+ * TODO: merge in common class
+ * @inheritdoc
+ */
+ public function collectLogFiles(Db $db)
+ {
+ $packageName = $db->settings()->get('icinga_package_name');
+ $existing = $this->listPackageStages($packageName);
+
+ foreach (DirectorDeploymentLog::getUncollected($db) as $deployment) {
+ $stage = $deployment->get('stage_name');
+ if (! in_array($stage, $existing)) {
+ continue;
+ }
+
+ try {
+ $availableFiles = $this->listStageFiles($stage);
+ } catch (Exception $e) {
+ // Could not collect stage files. Doesn't matter, let's try next time
+ continue;
+ }
+
+ if (in_array('startup.log', $availableFiles)
+ && in_array('status', $availableFiles)
+ ) {
+ $status = $this->getStagedFile($stage, 'status');
+ $status = trim($status);
+ if ($status === '0') {
+ $deployment->set('startup_succeeded', 'y');
+ } else {
+ $deployment->set('startup_succeeded', 'n');
+ }
+ $deployment->set('startup_log', $this->shortenStartupLog(
+ $this->getStagedFile($stage, 'startup.log')
+ ));
+ } else {
+ // Stage seems to be incomplete, let's try again next time
+ continue;
+ }
+ $deployment->set('stage_collected', 'y');
+
+ $deployment->store();
+ }
+ }
+
+ /**
+ * TODO: merge in common class
+ * @inheritdoc
+ */
+ public function wipeInactiveStages(Db $db)
+ {
+ $uncollected = DirectorDeploymentLog::getUncollected($db);
+ $packageName = $db->settings()->get('icinga_package_name');
+ $currentStage = $this->getActiveStageName();
+
+ // try to expire old deployments
+ foreach ($uncollected as $name => $deployment) {
+ /** @var DirectorDeploymentLog $deployment */
+ if ($deployment->get('dump_succeeded') === 'n'
+ || $deployment->get('startup_succeeded') === null
+ ) {
+ $start_time = strtotime($deployment->start_time);
+
+ // older than an hour and no startup
+ if ($start_time + 3600 < time()) {
+ $deployment->set('startup_succeeded', 'n');
+ $deployment->set('startup_log', 'Activation timed out...');
+ $deployment->store();
+ }
+ }
+ }
+
+ foreach ($this->listPackageStages($packageName) as $stage) {
+ if (array_key_exists($stage, $uncollected)
+ && $uncollected[$stage]->get('startup_succeeded') === null
+ ) {
+ continue;
+ } elseif ($stage === $currentStage) {
+ continue;
+ } else {
+ $this->deleteStage($packageName, $stage);
+ }
+ }
+ }
+
+ /** @inheritdoc */
+ public function getActiveStageName()
+ {
+ $this->assertDeploymentPath();
+
+ $path = $this->deploymentPath . DIRECTORY_SEPARATOR . 'active';
+
+ if (file_exists($path)) {
+ if (is_link($path)) {
+ $linkTarget = readlink($path);
+ $linkTargetDir = dirname($linkTarget);
+ $linkTargetName = basename($linkTarget);
+
+ if ($linkTargetDir === $this->deploymentPath || $linkTargetDir === '.') {
+ return $linkTargetName;
+ } else {
+ throw new IcingaException(
+ 'Active stage link pointing to a invalid target: %s -> %s',
+ $path,
+ $linkTarget
+ );
+ }
+ } else {
+ throw new IcingaException('Active stage is not a symlink: %s', $path);
+ }
+ } else {
+ return false;
+ }
+ }
+
+ /** @inheritdoc */
+ public function listStageFiles($stage)
+ {
+ $path = $this->getStagePath($stage);
+ if (! is_dir($path)) {
+ throw new IcingaException('Deployment stage "%s" does not exist at: %s', $stage, $path);
+ }
+ return $this->listDirectoryContents($path);
+ }
+
+ /** @inheritdoc */
+ public function listPackageStages($packageName)
+ {
+ $this->assertPackageName($packageName);
+ $this->assertDeploymentPath();
+
+ $dh = @opendir($this->deploymentPath);
+ if ($dh === null) {
+ throw new IcingaException('Can not list contents of %s', $this->deploymentPath);
+ }
+
+ $stages = array();
+ while ($file = readdir($dh)) {
+ if ($file === '.' || $file === '..') {
+ continue;
+ } elseif (is_dir($this->deploymentPath . DIRECTORY_SEPARATOR . $file)
+ && substr($file, 0, 9) === 'director-'
+ ) {
+ $stages[] = $file;
+ }
+ }
+
+ return $stages;
+ }
+
+ /** @inheritdoc */
+ public function getStagedFile($stage, $file)
+ {
+ $path = $this->getStagePath($stage);
+
+ $filePath = $path . DIRECTORY_SEPARATOR . $file;
+
+ if (! file_exists($filePath)) {
+ throw new IcingaException('Could not find file %s', $filePath);
+ } else {
+ return file_get_contents($filePath);
+ }
+ }
+
+ /** @inheritdoc */
+ public function deleteStage($packageName, $stageName)
+ {
+ $this->assertPackageName($packageName);
+ $this->assertDeploymentPath();
+
+ $path = $this->getStagePath($stageName);
+
+ static::rrmdir($path);
+ }
+
+ /** @inheritdoc */
+ public function dumpConfig(IcingaConfig $config, Db $db, $packageName = null)
+ {
+ if ($packageName === null) {
+ $packageName = $db->settings()->get('icinga_package_name');
+ }
+ $this->assertPackageName($packageName);
+ $this->assertDeploymentPath();
+
+ $start = microtime(true);
+ $deployment = DirectorDeploymentLog::create(array(
+ // 'config_id' => $config->id,
+ // 'peer_identity' => $endpoint->object_name,
+ 'peer_identity' => $this->deploymentPath,
+ 'start_time' => date('Y-m-d H:i:s'),
+ 'config_checksum' => $config->getChecksum(),
+ 'last_activity_checksum' => $config->getLastActivityChecksum()
+ // 'triggered_by' => Util::getUsername(),
+ // 'username' => Util::getUsername(),
+ // 'module_name' => $moduleName,
+ ));
+
+ $stage_name = 'director-' .date('Ymd-His');
+ $deployment->set('stage_name', $stage_name);
+
+ try {
+ $succeeded = $this->deployStage($stage_name, $config->getFileContents());
+ if ($succeeded === true) {
+ $succeeded = $this->activateStage($stage_name);
+ }
+ } catch (Exception $e) {
+ $deployment->set('dump_succeeded', 'n');
+ $deployment->set('startup_log', $e->getMessage());
+ $deployment->set('startup_succeeded', 'n');
+ $deployment->store($db);
+ throw $e;
+ }
+
+ $duration = (int) ((microtime(true) - $start) * 1000);
+ $deployment->set('duration_dump', $duration);
+
+ $deployment->set('dump_succeeded', $succeeded === true ? 'y' : 'n');
+
+ $deployment->store($db);
+ return $succeeded;
+ }
+
+ /**
+ * Deploy a new stage, and write all files to it
+ *
+ * @param string $stage Name of the stage
+ * @param array $files Array of files, $fileName => $content
+ *
+ * @return bool Success status
+ *
+ * @throws IcingaException When something could not be accessed
+ */
+ protected function deployStage($stage, $files)
+ {
+ $path = $this->deploymentPath . DIRECTORY_SEPARATOR . $stage;
+
+ if (file_exists($path)) {
+ throw new IcingaException('Stage "%s" does already exist at: ', $stage, $path);
+ } else {
+ $this->mkdir($path);
+
+ foreach ($files as $file => $content) {
+ $fullPath = $path . DIRECTORY_SEPARATOR . $file;
+ $this->mkdir(dirname($fullPath), true);
+
+ $fh = @fopen($fullPath, 'w');
+ if ($fh === null) {
+ throw new IcingaException('Could not open file "%s" for writing.', $fullPath);
+ }
+ chmod($fullPath, $this->file_mode);
+
+ fwrite($fh, $content);
+ fclose($fh);
+ }
+
+ return true;
+ }
+ }
+
+ /**
+ * Starts activation of
+ *
+ * Note: script should probably fork to background?
+ *
+ * @param string $stage Stage to activate
+ *
+ * @return bool
+ *
+ * @throws IcingaException For an execution error
+ */
+ protected function activateStage($stage)
+ {
+ if ($this->activationScript === null || trim($this->activationScript) === '') {
+ // skip activation, could be done by external cron worker
+ return true;
+ } else {
+ $command = sprintf('%s %s 2>&1', escapeshellcmd($this->activationScript), escapeshellarg($stage));
+ $output = null;
+ $rc = null;
+ exec($command, $output, $rc);
+ $output = join("\n", $output);
+ if ($rc !== 0) {
+ throw new IcingaException("Activation script did exit with return code %d:\n\n%s", $rc, $output);
+ }
+ return true;
+ }
+ }
+
+ /**
+ * Recursively dump directory contents, with relative path
+ *
+ * @param string $path Absolute path to read from
+ * @param int $depth Internal counter
+ *
+ * @return string[]
+ *
+ * @throws IcingaException When directory could not be opened
+ */
+ protected function listDirectoryContents($path, $depth = 0)
+ {
+ $dh = @opendir($path);
+ if ($dh === null) {
+ throw new IcingaException('Can not list contents of %s', $path);
+ }
+
+ $files = array();
+ while ($file = readdir($dh)) {
+ $fullPath = $path . DIRECTORY_SEPARATOR . $file;
+ if ($file === '.' || $file === '..') {
+ continue;
+ } elseif (is_dir($fullPath)) {
+ $subdirFiles = $this->listDirectoryContents($fullPath, $depth + 1);
+ foreach ($subdirFiles as $subFile) {
+ $files[] = $file . DIRECTORY_SEPARATOR . $subFile;
+ }
+ } else {
+ $files[] = $file;
+ }
+ }
+
+ if ($depth === 0) {
+ sort($files);
+ }
+
+ return $files;
+ }
+
+ /**
+ * Assert that only the director module is interacted with
+ *
+ * @param string $packageName
+ * @throws IcingaException When another module is requested
+ */
+ protected function assertPackageName($packageName)
+ {
+ if ($packageName !== 'director') {
+ throw new IcingaException('Does not supported different modules!');
+ }
+ }
+
+ /**
+ * Assert the deployment path to be configured, existing, and writeable
+ *
+ * @throws IcingaException
+ */
+ protected function assertDeploymentPath()
+ {
+ if ($this->deploymentPath === null) {
+ throw new IcingaException('Deployment path is not configured for legacy config!');
+ } elseif (! is_dir($this->deploymentPath)) {
+ throw new IcingaException('Deployment path is not a directory: %s', $this->deploymentPath);
+ } elseif (! is_writeable($this->deploymentPath)) {
+ throw new IcingaException('Deployment path is not a writeable: %s', $this->deploymentPath);
+ }
+ }
+
+ /**
+ * TODO: avoid code duplication: copied from CoreApi
+ *
+ * @param string $log The log contents to shorten
+ * @return string
+ */
+ protected function shortenStartupLog($log)
+ {
+ $logLen = strlen($log);
+ if ($logLen < 1024 * 60) {
+ return $log;
+ }
+
+ $part = substr($log, 0, 1024 * 20);
+ $parts = explode("\n", $part);
+ array_pop($parts);
+ $begin = implode("\n", $parts) . "\n\n";
+
+ $part = substr($log, -1024 * 20);
+ $parts = explode("\n", $part);
+ array_shift($parts);
+ $end = "\n\n" . implode("\n", $parts);
+
+ return $begin . sprintf(
+ '[..] %d bytes removed by Director [..]',
+ $logLen - (strlen($begin) + strlen($end))
+ ) . $end;
+ }
+
+ /**
+ * Return the full path of a stage
+ *
+ * @param string $stage Name of the stage
+ *
+ * @return string
+ */
+ public function getStagePath($stage)
+ {
+ $this->assertDeploymentPath();
+ return $this->deploymentPath . DIRECTORY_SEPARATOR . $stage;
+ }
+
+ /**
+ * @from https://php.net/manual/de/function.rmdir.php#108113
+ * @param $dir
+ */
+ protected static function rrmdir($dir)
+ {
+ foreach (glob($dir . '/*') as $file) {
+ if (is_dir($file)) {
+ static::rrmdir($file);
+ } else {
+ unlink($file);
+ }
+ }
+
+ rmdir($dir);
+ }
+
+ protected function mkdir($path, $recursive = false)
+ {
+ if (! file_exists($path)) {
+ if ($recursive) {
+ $this->mkdir(dirname($path));
+ }
+
+ try {
+ mkdir($path);
+ chmod($path, $this->dir_mode);
+ } catch (Exception $e) {
+ throw new IcingaException('Could not create path "%s": %s', $path, $e->getMessage());
+ }
+ }
+ }
+}
diff --git a/library/Director/Core/RestApiClient.php b/library/Director/Core/RestApiClient.php
new file mode 100644
index 0000000..b0854ff
--- /dev/null
+++ b/library/Director/Core/RestApiClient.php
@@ -0,0 +1,276 @@
+<?php
+
+namespace Icinga\Module\Director\Core;
+
+use Icinga\Application\Benchmark;
+use RuntimeException;
+
+class RestApiClient
+{
+ protected $version = 'v1';
+
+ protected $peer;
+
+ protected $port;
+
+ protected $user;
+
+ protected $pass;
+
+ protected $curl;
+
+ protected $readBuffer = '';
+
+ protected $onEvent;
+
+ protected $onEventWantsRaw;
+
+ protected $keepAlive = true;
+
+ public function __construct($peer, $port = 5665, $cn = null)
+ {
+ $this->peer = $peer;
+ $this->port = $port;
+ }
+
+ // TODO: replace with Web2 CA trust resource plus cert and get rid
+ // of user/pass or at least strongly advise against using it
+ public function setCredentials($user, $pass)
+ {
+ $this->user = $user;
+ $this->pass = $pass;
+
+ return $this;
+ }
+
+ public function onEvent($callback, $raw = false)
+ {
+ $this->onEventWantsRaw = $raw;
+ $this->onEvent = $callback;
+
+ return $this;
+ }
+
+ public function getPeerIdentity()
+ {
+ return $this->peer;
+ }
+
+ public function setKeepAlive($keepAlive = true)
+ {
+ $this->keepAlive = (bool) $keepAlive;
+
+ return $this;
+ }
+
+ protected function url($url)
+ {
+ return sprintf('https://%s:%d/%s/%s', $this->peer, $this->port, $this->version, $url);
+ }
+
+ /**
+ * @param $method
+ * @param $url
+ * @param null $body
+ * @param bool $raw
+ * @param bool $stream
+ * @return RestApiResponse
+ */
+ public function request($method, $url, $body = null, $raw = false, $stream = false)
+ {
+ if (function_exists('curl_version')) {
+ return $this->curlRequest($method, $url, $body, $raw, $stream);
+ } else {
+ throw new RuntimeException(
+ 'No CURL extension detected, it must be installed and enabled'
+ );
+ }
+ }
+
+ protected function curlRequest($method, $url, $body = null, $raw = false, $stream = false)
+ {
+ $auth = sprintf('%s:%s', $this->user, $this->pass);
+ $headers = [
+ 'Host: ' . $this->getPeerIdentity(),
+ ];
+
+ if (! $this->keepAlive) {
+ // This fails on Icinga 2.9:
+ // $headers[] = 'Connection: close';
+ }
+
+ if (! $raw) {
+ $headers[] = 'Accept: application/json';
+ }
+
+ if ($body !== null) {
+ $body = Json::encode($body);
+ $headers[] = 'Content-Type: application/json';
+ }
+
+ $curl = $this->curl();
+ $opts = [
+ CURLOPT_URL => $this->url($url),
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_USERPWD => $auth,
+ CURLOPT_CUSTOMREQUEST => strtoupper($method),
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CONNECTTIMEOUT => 3,
+
+ // TODO: Fix this!
+ CURLOPT_SSL_VERIFYHOST => false,
+ CURLOPT_SSL_VERIFYPEER => false,
+ ];
+
+ if ($body !== null) {
+ $opts[CURLOPT_POSTFIELDS] = $body;
+ }
+
+ if ($stream) {
+ $opts[CURLOPT_WRITEFUNCTION] = [$this, 'readPart'];
+ $opts[CURLOPT_TCP_NODELAY] = 1;
+ }
+
+ curl_setopt_array($curl, $opts);
+ // TODO: request headers, validate status code
+
+ Benchmark::measure('Rest Api, sending ' . $url);
+ $res = curl_exec($curl);
+ if ($res === false) {
+ $error = curl_error($curl);
+ $this->disconnect();
+
+ throw new RuntimeException("CURL ERROR: $error");
+ }
+
+ $statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
+ if ($statusCode === 401) {
+ $this->disconnect();
+ throw new RuntimeException(
+ 'Unable to authenticate, please check your API credentials'
+ );
+ }
+
+ Benchmark::measure('Rest Api, got response');
+ if (! $this->keepAlive) {
+ $this->disconnect();
+ }
+
+ if ($stream) {
+ return $this;
+ }
+
+ if ($raw) {
+ return $res;
+ } else {
+ return RestApiResponse::fromJsonResult($res);
+ }
+ }
+
+ /**
+ * @param resource $curl
+ * @param $data
+ * @return int
+ */
+ protected function readPart($curl, &$data)
+ {
+ $length = strlen($data);
+ $this->readBuffer .= $data;
+ $this->processEvents();
+ return $length;
+ }
+
+ public function get($url, $body = null)
+ {
+ return $this->request('get', $url, $body);
+ }
+
+ public function getRaw($url, $body = null)
+ {
+ return $this->request('get', $url, $body, true);
+ }
+
+ public function post($url, $body = null)
+ {
+ return $this->request('post', $url, $body);
+ }
+
+ public function put($url, $body = null)
+ {
+ return $this->request('put', $url, $body);
+ }
+
+ public function delete($url, $body = null)
+ {
+ return $this->request('delete', $url, $body);
+ }
+
+ /**
+ * @return resource
+ */
+ protected function curl()
+ {
+ if ($this->curl === null) {
+ $this->curl = curl_init(sprintf('https://%s:%d', $this->peer, $this->port));
+ if (! $this->curl) {
+ throw new RuntimeException('CURL INIT ERROR: ' . curl_error($this->curl));
+ }
+ }
+
+ return $this->curl;
+ }
+
+ protected function processEvents()
+ {
+ $offset = 0;
+ while (false !== ($pos = strpos($this->readBuffer, "\n", $offset))) {
+ if ($pos === $offset) {
+ // echo "Got empty line $offset / $pos\n";
+ $offset = $pos + 1;
+ continue;
+ }
+ $this->processReadBuffer($offset, $pos);
+
+ $offset = $pos + 1;
+ }
+
+ if ($offset > 0) {
+ $this->readBuffer = substr($this->readBuffer, $offset + 1);
+ }
+
+ // echo "REMAINING: " . strlen($this->readBuffer) . "\n";
+ }
+
+ protected function processReadBuffer($offset, $pos)
+ {
+ if ($this->onEvent === null) {
+ return;
+ }
+
+ $func = $this->onEvent;
+ $str = substr($this->readBuffer, $offset, $pos);
+ // printf("Processing %s bytes\n", strlen($str));
+
+ if ($this->onEventWantsRaw) {
+ $func($str);
+ } else {
+ $func(Json::decode($str));
+ }
+ }
+
+ public function disconnect()
+ {
+ if ($this->curl !== null) {
+ if (is_resource($this->curl)) {
+ @curl_close($this->curl);
+ }
+
+ $this->curl = null;
+ }
+ }
+
+ public function __destruct()
+ {
+ $this->disconnect();
+ }
+}
diff --git a/library/Director/Core/RestApiResponse.php b/library/Director/Core/RestApiResponse.php
new file mode 100644
index 0000000..523ed35
--- /dev/null
+++ b/library/Director/Core/RestApiResponse.php
@@ -0,0 +1,149 @@
+<?php
+
+namespace Icinga\Module\Director\Core;
+
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\NotFoundError;
+
+class RestApiResponse
+{
+ protected $errorMessage;
+
+ protected $results;
+
+ protected function __construct()
+ {
+ }
+
+ public static function fromJsonResult($json)
+ {
+ $response = new static;
+ return $response->parseJsonResult($json);
+ }
+
+ public static function fromErrorMessage($error)
+ {
+ $response = new static;
+ $response->errorMessage = $error;
+
+ return $response;
+ }
+
+ public function getResult($desiredKey, $filter = array())
+ {
+ return $this->extractResult($this->results, $desiredKey, $filter);
+ }
+
+ public function getRaw($key = null, $default = null)
+ {
+ if ($key === null) {
+ return $this->results;
+ } elseif (isset($this->results[0]) && property_exists($this->results[0], $key)) {
+ return $this->results[0]->$key;
+ } else {
+ return $default;
+ }
+ }
+
+ public function getSingleResult()
+ {
+ if ($this->isErrorCode($this->results[0]->code)) {
+ throw new IcingaException(
+ $this->results[0]->status
+ );
+ } else {
+ return $this->results[0]->result;
+ }
+ }
+
+ protected function isErrorCode($code)
+ {
+ $code = (int) ceil($code);
+ return $code >= 400;
+ }
+
+ protected function extractResult($results, $desiredKey, $filter = array())
+ {
+ $response = array();
+ foreach ($results as $result) {
+ foreach ($filter as $key => $val) {
+ if (! property_exists($result, $key)) {
+ continue;
+ }
+ if ($result->$key !== $val) {
+ continue;
+ }
+ }
+ if (! property_exists($result, $desiredKey)) {
+ continue;
+ }
+
+ $response[$result->$desiredKey] = $result;
+ }
+ return $response;
+ }
+
+ public function getErrorMessage()
+ {
+ return $this->errorMessage;
+ }
+
+ public function succeeded()
+ {
+ return $this->errorMessage === null;
+ }
+
+ protected function parseJsonResult($json)
+ {
+ $result = @json_decode($json);
+ if ($result === null) {
+ $this->setJsonError();
+ // <h1>Bad Request</h1><p><pre>bad version</pre></p>
+ throw new IcingaException(
+ 'Parsing JSON result failed: '
+ . $this->errorMessage
+ . ' (Got: ' . substr($json, 0, 60) . ')'
+ );
+ }
+ if (property_exists($result, 'error')) {
+ if (property_exists($result, 'status')) {
+ if ((int) $result->error === 404) {
+ throw new NotFoundError($result->status);
+ } else {
+ throw new IcingaException('API request failed: ' . $result->status);
+ }
+ } else {
+ throw new IcingaException('API request failed: ' . var_export($result, 1));
+ }
+ }
+
+ $this->results = $result->results; // TODO: Check if set
+ return $this;
+ }
+
+ // TODO: just return json_last_error_msg() for PHP >= 5.5.0
+ protected function setJsonError()
+ {
+ switch (json_last_error()) {
+ case JSON_ERROR_DEPTH:
+ $this->errorMessage = 'The maximum stack depth has been exceeded';
+ break;
+ case JSON_ERROR_CTRL_CHAR:
+ $this->errorMessage = 'Control character error, possibly incorrectly encoded';
+ break;
+ case JSON_ERROR_STATE_MISMATCH:
+ $this->errorMessage = 'Invalid or malformed JSON';
+ break;
+ case JSON_ERROR_SYNTAX:
+ $this->errorMessage = 'Syntax error';
+ break;
+ case JSON_ERROR_UTF8:
+ $this->errorMessage = 'Malformed UTF-8 characters, possibly incorrectly encoded';
+ break;
+ default:
+ $this->errorMessage = 'An error occured when parsing a JSON string';
+ }
+
+ return $this;
+ }
+}
diff --git a/library/Director/CoreBeta/ApiStream.php b/library/Director/CoreBeta/ApiStream.php
new file mode 100644
index 0000000..478fd40
--- /dev/null
+++ b/library/Director/CoreBeta/ApiStream.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Icinga\Module\Director\CoreBeta;
+
+use Exception;
+
+class ApiClient extends Stream
+{
+ protected $port;
+
+ public static function create($peer, $port = 5665)
+ {
+ $stream = new static();
+ }
+
+ protected function createClientConnection()
+ {
+ $context = $this->createSslContext();
+ if ($context === false) {
+ echo "Unable to set SSL options\n";
+ return false;
+ }
+
+ $conn = stream_socket_client(
+ 'ssl://' . $this->peername . ':' . $this->peerport,
+ $errno,
+ $errstr,
+ 15,
+ STREAM_CLIENT_CONNECT,
+ $context
+ );
+
+ return $conn;
+ }
+
+ protected function createSslContext()
+ {
+ $local = 'ssl://' . $this->local;
+ $context = stream_context_create();
+
+ // Hack, we need key and cert:
+ $certfile = preg_replace('~\..+$~', '', $this->certname) . '.combi';
+
+ $options = array(
+ 'ssl' => array(
+ 'verify_host' => true,
+ 'cafile' => $this->ssldir . '/ca.crt',
+ 'local_cert' => $this->ssldir . '/' . $certfile,
+ 'CN_match' => 'monitor1',
+ )
+ );
+
+ $result = stream_context_set_option($context, $options);
+
+ return $context;
+ }
+}
diff --git a/library/Director/CoreBeta/Stream.php b/library/Director/CoreBeta/Stream.php
new file mode 100644
index 0000000..5add9a3
--- /dev/null
+++ b/library/Director/CoreBeta/Stream.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Icinga\Module\Director\CoreBeta;
+
+abstract class Stream
+{
+ protected $stream;
+
+ protected $buffer = '';
+
+ protected $bufferLength = 0;
+
+ protected function __construct($stream)
+ {
+ $this->stream = $stream;
+ }
+}
diff --git a/library/Director/CoreBeta/StreamContext.php b/library/Director/CoreBeta/StreamContext.php
new file mode 100644
index 0000000..4844b79
--- /dev/null
+++ b/library/Director/CoreBeta/StreamContext.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Icinga\Module\Director\CoreBeta;
+
+use Icinga\Exception\ProgrammingError;
+
+class StreamContext
+{
+ protected $options = array();
+
+ public function ssl()
+ {
+ if ($this->ssl === null) {
+ $this->ssl = new StreamContextSslOptions();
+ }
+
+ return $this->ssl;
+ }
+
+ public function isSsl()
+ {
+ return $this->ssl !== null;
+ }
+
+ public function setCA(CA $ca)
+ {
+ // $this->options
+ }
+
+ protected function createSslContext()
+ {
+ $local = 'ssl://' . $this->local;
+ $context = stream_context_create();
+
+ // Hack, we need key and cert:
+ $certfile = preg_replace('~\..+$~', '', $this->certname) . '.combi';
+
+ $options = array(
+ 'ssl' => array(
+ 'verify_host' => true,
+ 'cafile' => $this->ssldir . '/ca.crt',
+ 'local_cert' => $this->ssldir . '/' . $certfile,
+ 'CN_match' => 'monitor1',
+ )
+ );
+
+ $result = stream_context_set_option($context, $options);
+
+ return $context;
+ }
+
+ public function setContextOptions($options)
+ {
+ if (array_key_exists('ssl', $options)) {
+ throw new ProgrammingError('Direct access to ssl options is not allowed');
+ }
+ }
+
+ protected function reallySetContextOptions($options)
+ {
+ if ($this->context === null) {
+ $this->options = $options;
+ } else {
+ stream_context_set_option($this->context, $options);
+ }
+ }
+
+ protected function lazyContext()
+ {
+ if ($this->context === null) {
+ $this->context = stream_context_create();
+ $this->setContextOptions($this->getOptions());
+
+ // stream_context_set_option($this->context
+ if ($this->isSsl()) {
+ $this->options['ssl'] = $this->ssl()->getOptions();
+ }
+
+ $result = stream_context_set_option($this->context, $this->options);
+ }
+
+ return $this->context;
+ }
+
+ public function getRawContext()
+ {
+ return $this->lazyContext();
+ }
+}
diff --git a/library/Director/CoreBeta/StreamContextSslOptions.php b/library/Director/CoreBeta/StreamContextSslOptions.php
new file mode 100644
index 0000000..d01d4a5
--- /dev/null
+++ b/library/Director/CoreBeta/StreamContextSslOptions.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Icinga\Module\Director\CoreBeta;
+
+use Icinga\Exception\ProgrammingError;
+
+class StreamContextSslOptions
+{
+ protected $options = array(
+ 'verify_peer' => true,
+ );
+
+ public function setCA(CA $ca)
+ {
+ $this->ca = $ca;
+ }
+
+ public function capturePeerCert($capture = true)
+ {
+ $this->options['capture_peer_cert'] = (bool) $capture;
+ return $this;
+ }
+
+ public function capturePeerChain($capture = true)
+ {
+ $this->options['capture_peer_chain'] = (bool) $capture;
+ return $this;
+ }
+
+ public function setCiphers($ciphers)
+ {
+ $this->options['ciphers'] = $ciphers;
+ return $this;
+ }
+
+ public function setPeerName($name)
+ {
+ if (version_compare(PHP_VERSION, '5.6.0') >= 0) {
+ $this->options['peer_name'] = $name;
+ $this->options['verify_peer_name'] = true;
+ } else {
+ $this->options['CN_match'] = $name;
+ }
+ return $this;
+ }
+
+ public function getOptions()
+ {
+ // TODO: Fail on missing cert
+ return $this->options;
+ }
+}