diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:46:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:46:43 +0000 |
commit | 3e02d5aff85babc3ffbfcf52313f2108e313aa23 (patch) | |
tree | b01f3923360c20a6a504aff42d45670c58af3ec5 /library/Icinga/Protocol | |
parent | Initial commit. (diff) | |
download | icingaweb2-upstream.tar.xz icingaweb2-upstream.zip |
Adding upstream version 2.12.1.upstream/2.12.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'library/Icinga/Protocol')
-rw-r--r-- | library/Icinga/Protocol/Dns.php | 89 | ||||
-rw-r--r-- | library/Icinga/Protocol/File/Exception/FileReaderException.php | 12 | ||||
-rw-r--r-- | library/Icinga/Protocol/File/FileIterator.php | 81 | ||||
-rw-r--r-- | library/Icinga/Protocol/File/FileQuery.php | 86 | ||||
-rw-r--r-- | library/Icinga/Protocol/File/FileReader.php | 208 | ||||
-rw-r--r-- | library/Icinga/Protocol/File/LogFileIterator.php | 149 | ||||
-rw-r--r-- | library/Icinga/Protocol/Ldap/Discovery.php | 143 | ||||
-rw-r--r-- | library/Icinga/Protocol/Ldap/LdapCapabilities.php | 440 | ||||
-rw-r--r-- | library/Icinga/Protocol/Ldap/LdapConnection.php | 1584 | ||||
-rw-r--r-- | library/Icinga/Protocol/Ldap/LdapException.php | 14 | ||||
-rw-r--r-- | library/Icinga/Protocol/Ldap/LdapQuery.php | 361 | ||||
-rw-r--r-- | library/Icinga/Protocol/Ldap/LdapUtils.php | 148 | ||||
-rw-r--r-- | library/Icinga/Protocol/Ldap/Node.php | 69 | ||||
-rw-r--r-- | library/Icinga/Protocol/Ldap/Root.php | 242 | ||||
-rw-r--r-- | library/Icinga/Protocol/Nrpe/Connection.php | 111 | ||||
-rw-r--r-- | library/Icinga/Protocol/Nrpe/Packet.php | 69 |
16 files changed, 3806 insertions, 0 deletions
diff --git a/library/Icinga/Protocol/Dns.php b/library/Icinga/Protocol/Dns.php new file mode 100644 index 0000000..3d422d7 --- /dev/null +++ b/library/Icinga/Protocol/Dns.php @@ -0,0 +1,89 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol; + +/** + * Discover dns records using regular or reverse lookup + */ +class Dns +{ + /** + * Discover all service records on a given domain + * + * @param string $domain The domain to search + * @param string $service The type of the service, like for example 'ldaps' or 'ldap' + * @param string $protocol The transport protocol used by the service, defaults to 'tcp' + * + * @return array An array of all found service records + */ + public static function getSrvRecords($domain, $service, $protocol = 'tcp') + { + $records = dns_get_record('_' . $service . '._' . $protocol . '.' . $domain, DNS_SRV); + return $records === false ? array() : $records; + } + + /** + * Get all ldap records for the given domain + * + * @param string $query The domain to query + * @param int $type The type of DNS-entry to fetch, see + * http://www.php.net/manual/de/function.dns-get-record.php for available types + * + * @return array|null An array of record entries + */ + public static function records($query, $type = DNS_ANY) + { + return dns_get_record($query, $type); + } + + /** + * Reverse lookup all host names available on the given ip address + * + * @param string $ipAddress + * @param int $type + * + * @return array|null + */ + public static function ptr($ipAddress, $type = DNS_ANY) + { + $host = gethostbyaddr($ipAddress); + if ($host === false || $host === $ipAddress) { + // malformed input or no host found + return null; + } + return self::records($host, $type); + } + + /** + * Get the IPv4 address of the given hostname. + * + * @param $hostname The hostname to resolve + * + * @return string|null The IPv4 address of the given hostname or null, when no entry exists. + */ + public static function ipv4($hostname) + { + $records = dns_get_record($hostname, DNS_A); + if ($records !== false && count($records) > 0) { + return $records[0]['ip']; + } + return null; + } + + /** + * Get the IPv6 address of the given hostname. + * + * @param $hostname The hostname to resolve + * + * @return string|null The IPv6 address of the given hostname or null, when no entry exists. + */ + public static function ipv6($hostname) + { + $records = dns_get_record($hostname, DNS_AAAA); + if ($records !== false && count($records) > 0) { + return $records[0]['ip']; + } + return null; + } +} diff --git a/library/Icinga/Protocol/File/Exception/FileReaderException.php b/library/Icinga/Protocol/File/Exception/FileReaderException.php new file mode 100644 index 0000000..237352c --- /dev/null +++ b/library/Icinga/Protocol/File/Exception/FileReaderException.php @@ -0,0 +1,12 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ +namespace Icinga\Protocol\File; + +use Icinga\Exception\IcingaException; + +/** + * Exception thrown if a file reader specific error occurs + */ +class FileReaderException extends IcingaException +{ +} diff --git a/library/Icinga/Protocol/File/FileIterator.php b/library/Icinga/Protocol/File/FileIterator.php new file mode 100644 index 0000000..64b6600 --- /dev/null +++ b/library/Icinga/Protocol/File/FileIterator.php @@ -0,0 +1,81 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\File; + +use Icinga\Util\EnumeratingFilterIterator; +use Icinga\Util\File; + +/** + * Class FileIterator + * + * Iterate over a file, yielding only fields of non-empty lines which match a PCRE expression + */ +class FileIterator extends EnumeratingFilterIterator +{ + /** + * A PCRE string with the fields to extract from the file's lines as named subpatterns + * + * @var string + */ + protected $fields; + + /** + * An associative array of the current line's fields ($field => $value) + * + * @var array + */ + protected $currentData; + + public function __construct($filename, $fields) + { + $this->fields = $fields; + $f = new File($filename); + $f->setFlags( + File::DROP_NEW_LINE | + File::READ_AHEAD | + File::SKIP_EMPTY + ); + parent::__construct($f); + } + + /** + * Return the current data + * + * @return array + */ + public function current(): array + { + return $this->currentData; + } + + /** + * Accept lines matching the given PCRE pattern + * + * @return bool + * + * @throws FileReaderException If PHP failed parsing the PCRE pattern + */ + public function accept(): bool + { + $data = array(); + $matched = preg_match( + $this->fields, + $this->getInnerIterator()->current(), + $data + ); + + if ($matched === false) { + throw new FileReaderException('Failed parsing regular expression!'); + } elseif ($matched === 1) { + foreach ($data as $key => $value) { + if (is_int($key)) { + unset($data[$key]); + } + } + $this->currentData = $data; + return true; + } + return false; + } +} diff --git a/library/Icinga/Protocol/File/FileQuery.php b/library/Icinga/Protocol/File/FileQuery.php new file mode 100644 index 0000000..504de2e --- /dev/null +++ b/library/Icinga/Protocol/File/FileQuery.php @@ -0,0 +1,86 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\File; + +use Icinga\Data\SimpleQuery; +use Icinga\Data\Filter\Filter; + +/** + * Class FileQuery + * + * Query for Datasource Icinga\Protocol\File\FileReader + * + * @package Icinga\Protocol\File + */ +class FileQuery extends SimpleQuery +{ + /** + * Sort direction + * + * @var int + */ + private $sortDir; + + /** + * Filters to apply on result + * + * @var array + */ + private $filters = array(); + + /** + * Nothing to do here + */ + public function applyFilter(Filter $filter) + { + } + + /** + * Sort query result chronological + * + * @param string $dir Sort direction, 'ASC' or 'DESC' (default) + * + * @return FileQuery + */ + public function order($field, $direction = null) + { + $this->sortDir = ( + $direction === null || strtoupper(trim($direction)) === 'DESC' + ) ? self::SORT_DESC : self::SORT_ASC; + return $this; + } + + /** + * Return true if sorting descending, false otherwise + * + * @return bool + */ + public function sortDesc() + { + return $this->sortDir === self::SORT_DESC; + } + + /** + * Add an mandatory filter expression to be applied on this query + * + * @param string $expression the filter expression to be applied + * + * @return FileQuery + */ + public function andWhere($expression) + { + $this->filters[] = $expression; + return $this; + } + + /** + * Get filters currently applied on this query + * + * @return array + */ + public function getFilters() + { + return $this->filters; + } +} diff --git a/library/Icinga/Protocol/File/FileReader.php b/library/Icinga/Protocol/File/FileReader.php new file mode 100644 index 0000000..a06494c --- /dev/null +++ b/library/Icinga/Protocol/File/FileReader.php @@ -0,0 +1,208 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\File; + +use Countable; +use ArrayIterator; +use Icinga\Data\Selectable; +use Icinga\Data\ConfigObject; + +/** + * Read file line by line + */ +class FileReader implements Selectable, Countable +{ + /** + * A PCRE string with the fields to extract from the file's lines as named subpatterns + * + * @var string + */ + protected $fields; + + /** + * Name of the target file + * + * @var string + */ + protected $filename; + + /** + * Cache for static::count() + * + * @var int + */ + protected $count = null; + + /** + * Create a new reader + * + * @param ConfigObject $config + * + * @throws FileReaderException If a required $config directive (filename or fields) is missing + */ + public function __construct(ConfigObject $config) + { + foreach (array('filename', 'fields') as $key) { + if (isset($config->{$key})) { + $this->{$key} = $config->{$key}; + } else { + throw new FileReaderException('The directive `%s\' is required', $key); + } + } + } + + /** + * Instantiate a FileIterator object with the target file + * + * @return FileIterator + */ + public function iterate() + { + return new LogFileIterator($this->filename, $this->fields); + } + + /** + * Instantiate a FileQuery object + * + * @return FileQuery + */ + public function select() + { + return new FileQuery($this); + } + + /** + * Fetch and return all rows of the given query's result set using an iterator + * + * @param FileQuery $query + * + * @return ArrayIterator + */ + public function query(FileQuery $query) + { + return new ArrayIterator($this->fetchAll($query)); + } + + /** + * Return the number of available valid lines. + * + * @return int + */ + public function count(): int + { + if ($this->count === null) { + $this->count = iterator_count($this->iterate()); + } + return $this->count; + } + + /** + * Fetch result as an array of objects + * + * @param FileQuery $query + * + * @return array + */ + public function fetchAll(FileQuery $query) + { + $all = array(); + foreach ($this->fetchPairs($query) as $index => $value) { + $all[$index] = (object) $value; + } + return $all; + } + + /** + * Fetch result as a key/value pair array + * + * @param FileQuery $query + * + * @return array + */ + public function fetchPairs(FileQuery $query) + { + $skip = $query->getOffset(); + $read = $query->getLimit(); + if ($skip === null) { + $skip = 0; + } + $lines = array(); + if ($query->sortDesc()) { + $count = $this->count(); + if ($count <= $skip) { + return $lines; + } elseif ($count < ($skip + $read)) { + $read = $count - $skip; + $skip = 0; + } else { + $skip = $count - ($skip + $read); + } + } + foreach ($this->iterate() as $index => $line) { + if ($index >= $skip) { + if ($index >= $skip + $read) { + break; + } + $lines[] = $line; + } + } + if ($query->sortDesc()) { + $lines = array_reverse($lines); + } + return $lines; + } + + /** + * Fetch first result row + * + * @param FileQuery $query + * + * @return object + */ + public function fetchRow(FileQuery $query) + { + $all = $this->fetchAll($query); + if (isset($all[0])) { + return $all[0]; + } + return null; + } + + /** + * Fetch first result column + * + * @param FileQuery $query + * + * @return array + */ + public function fetchColumn(FileQuery $query) + { + $column = array(); + foreach ($this->fetchPairs($query) as $pair) { + foreach ($pair as $value) { + $column[] = $value; + break; + } + } + return $column; + } + + /** + * Fetch first column value from first result row + * + * @param FileQuery $query + * + * @return mixed + */ + public function fetchOne(FileQuery $query) + { + $pairs = $this->fetchPairs($query); + if (isset($pairs[0])) { + foreach ($pairs[0] as $value) { + return $value; + } + } + return null; + } +} diff --git a/library/Icinga/Protocol/File/LogFileIterator.php b/library/Icinga/Protocol/File/LogFileIterator.php new file mode 100644 index 0000000..67a4d99 --- /dev/null +++ b/library/Icinga/Protocol/File/LogFileIterator.php @@ -0,0 +1,149 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\File; + +use Icinga\Exception\IcingaException; +use SplFileObject; +use Iterator; + +/** + * Iterate over a log file, yielding the regex fields of the log messages + */ +class LogFileIterator implements Iterator +{ + /** + * Log file + * + * @var SplFileObject + */ + protected $file; + + /** + * A PCRE string with the fields to extract + * from the log messages as named subpatterns + * + * @var string + */ + protected $fields; + + /** + * Value for static::current() + * + * @var array + */ + protected $current; + + /** + * Index for static::key() + * + * @var int + */ + protected $index; + + /** + * Value for static::valid() + * + * @var boolean + */ + protected $valid; + + /** + * @var string + */ + protected $next = null; + + /** + * @param string $filename The log file's name + * @param string $fields A PCRE string with the fields to extract + * from the log messages as named subpatterns + */ + public function __construct($filename, $fields) + { + $this->file = new SplFileObject($filename); + $this->file->setFlags( + SplFileObject::DROP_NEW_LINE | + SplFileObject::READ_AHEAD + ); + $this->fields = $fields; + } + + public function rewind(): void + { + $this->file->rewind(); + $this->index = 0; + $this->nextMessage(); + } + + public function next(): void + { + $this->file->next(); + ++$this->index; + $this->nextMessage(); + } + + public function current(): array + { + return $this->current; + } + + public function key(): int + { + return $this->index; + } + + public function valid(): bool + { + return $this->valid; + } + + protected function nextMessage() + { + $message = $this->next === null ? array() : array($this->next); + $this->valid = null; + while ($this->file->valid()) { + if (false === ($res = preg_match( + $this->fields, + $current = $this->file->current() + ))) { + throw new IcingaException('Failed at preg_match()'); + } + if (empty($message)) { + if ($res === 1) { + $message[] = $current; + } + } elseif ($res === 1) { + $this->next = $current; + $this->valid = true; + break; + } else { + $message[] = $current; + } + + $this->file->next(); + } + if ($this->valid === null) { + $this->next = null; + $this->valid = ! empty($message); + } + + if ($this->valid) { + while (! empty($message)) { + $matches = array(); + if (false === ($res = preg_match( + $this->fields, + implode(PHP_EOL, $message), + $matches + ))) { + throw new IcingaException('Failed at preg_match()'); + } + if ($res === 1) { + $this->current = $matches; + return; + } + array_pop($message); + } + $this->valid = false; + } + } +} diff --git a/library/Icinga/Protocol/Ldap/Discovery.php b/library/Icinga/Protocol/Ldap/Discovery.php new file mode 100644 index 0000000..9c7990a --- /dev/null +++ b/library/Icinga/Protocol/Ldap/Discovery.php @@ -0,0 +1,143 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +use Icinga\Data\ConfigObject; +use Icinga\Protocol\Dns; + +class Discovery +{ + /** + * @var LdapConnection + */ + private $connection; + + /** + * @param LdapConnection $conn The ldap connection to use for the discovery + */ + public function __construct(LdapConnection $conn) + { + $this->connection = $conn; + } + + /** + * Suggests a resource configuration of hostname, port and root_dn + * based on the discovery + * + * @return array The suggested configuration as an array + */ + public function suggestResourceSettings() + { + return array( + 'hostname' => $this->connection->getHostname(), + 'port' => $this->connection->getPort(), + 'root_dn' => $this->connection->getCapabilities()->getDefaultNamingContext() + ); + } + + /** + * Suggests a backend configuration of base_dn, user_class and user_name_attribute + * based on the discovery + * + * @return array The suggested configuration as an array + */ + public function suggestBackendSettings() + { + if ($this->isAd()) { + return array( + 'backend' => 'msldap', + 'base_dn' => $this->connection->getCapabilities()->getDefaultNamingContext(), + 'user_class' => 'user', + 'user_name_attribute' => 'sAMAccountName' + ); + } else { + return array( + 'backend' => 'ldap', + 'base_dn' => $this->connection->getCapabilities()->getDefaultNamingContext(), + 'user_class' => 'inetOrgPerson', + 'user_name_attribute' => 'uid' + ); + } + } + + /** + * Whether the suggested ldap server is an ActiveDirectory + * + * @return boolean + */ + public function isAd() + { + return $this->connection->getCapabilities()->isActiveDirectory(); + } + + /** + * Whether the discovery was successful + * + * @return bool False when the suggestions are guessed + */ + public function isSuccess() + { + return $this->connection->discoverySuccessful(); + } + + /** + * Why the discovery failed + * + * @return \Exception|null + */ + public function getError() + { + return $this->connection->getDiscoveryError(); + } + + /** + * Discover LDAP servers on the given domain + * + * @param ?string $domain The object containing the form elements + * + * @return Discovery True when the discovery was successful, false when the configuration was guessed + */ + public static function discoverDomain($domain) + { + if (! isset($domain)) { + return false; + } + + // Attempt 1: Connect to the domain directly + $disc = Discovery::discover($domain, 389); + if ($disc->isSuccess()) { + return $disc; + } + + // Attempt 2: Discover all available ldap dns records and connect to the first one + $records = array_merge(Dns::getSrvRecords($domain, 'ldap'), Dns::getSrvRecords($domain, 'ldaps')); + if (isset($records[0])) { + $record = $records[0]; + return Discovery::discover( + isset($record['target']) ? $record['target'] : $domain, + isset($record['port']) ? $record['port'] : $domain + ); + } + + // Return the first failed discovery, which will suggest properties based on guesses + return $disc; + } + + /** + * Convenience method to instantiate a new Discovery + * + * @param $host The host on which to execute the discovery + * @param $port The port on which to execute the discovery + * + * @return Discovery The resulting Discovery + */ + public static function discover($host, $port) + { + $conn = new LdapConnection(new ConfigObject(array( + 'hostname' => $host, + 'port' => $port + ))); + return new Discovery($conn); + } +} diff --git a/library/Icinga/Protocol/Ldap/LdapCapabilities.php b/library/Icinga/Protocol/Ldap/LdapCapabilities.php new file mode 100644 index 0000000..721655a --- /dev/null +++ b/library/Icinga/Protocol/Ldap/LdapCapabilities.php @@ -0,0 +1,440 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +use Icinga\Application\Logger; +use stdClass; + +/** + * The properties and capabilities of an LDAP server + * + * Provides information about the available encryption mechanisms (StartTLS), the supported + * LDAP protocol (v2/v3), vendor-specific extensions or protocols controls and extensions. + */ +class LdapCapabilities +{ + const LDAP_SERVER_START_TLS_OID = '1.3.6.1.4.1.1466.20037'; + + const LDAP_PAGED_RESULT_OID_STRING = '1.2.840.113556.1.4.319'; + + const LDAP_SERVER_SHOW_DELETED_OID = '1.2.840.113556.1.4.417'; + + const LDAP_SERVER_SORT_OID = '1.2.840.113556.1.4.473'; + + const LDAP_SERVER_CROSSDOM_MOVE_TARGET_OID = '1.2.840.113556.1.4.521'; + + const LDAP_SERVER_NOTIFICATION_OID = '1.2.840.113556.1.4.528'; + + const LDAP_SERVER_EXTENDED_DN_OID = '1.2.840.113556.1.4.529'; + + const LDAP_SERVER_LAZY_COMMIT_OID = '1.2.840.113556.1.4.619'; + + const LDAP_SERVER_SD_FLAGS_OID = '1.2.840.113556.1.4.801'; + + const LDAP_SERVER_TREE_DELETE_OID = '1.2.840.113556.1.4.805'; + + const LDAP_SERVER_DIRSYNC_OID = '1.2.840.113556.1.4.841'; + + const LDAP_SERVER_VERIFY_NAME_OID = '1.2.840.113556.1.4.1338'; + + const LDAP_SERVER_DOMAIN_SCOPE_OID = '1.2.840.113556.1.4.1339'; + + const LDAP_SERVER_SEARCH_OPTIONS_OID = '1.2.840.113556.1.4.1340'; + + const LDAP_SERVER_PERMISSIVE_MODIFY_OID = '1.2.840.113556.1.4.1413'; + + const LDAP_SERVER_ASQ_OID = '1.2.840.113556.1.4.1504'; + + const LDAP_SERVER_FAST_BIND_OID = '1.2.840.113556.1.4.1781'; + + const LDAP_CONTROL_VLVREQUEST = '2.16.840.1.113730.3.4.9'; + + + // MS Capabilities, Source: http://msdn.microsoft.com/en-us/library/cc223359.aspx + + // Running Active Directory as AD DS + const LDAP_CAP_ACTIVE_DIRECTORY_OID = '1.2.840.113556.1.4.800'; + + // Capable of signing and sealing on an NTLM authenticated connection + // and of performing subsequent binds on a signed or sealed connection + const LDAP_CAP_ACTIVE_DIRECTORY_LDAP_INTEG_OID = '1.2.840.113556.1.4.1791'; + + // If AD DS: running at least W2K3, if AD LDS running at least W2K8 + const LDAP_CAP_ACTIVE_DIRECTORY_V51_OID = '1.2.840.113556.1.4.1670'; + + // If AD LDS: accepts DIGEST-MD5 binds for AD LDSsecurity principals + const LDAP_CAP_ACTIVE_DIRECTORY_ADAM_DIGEST = '1.2.840.113556.1.4.1880'; + + // Running Active Directory as AD LDS + const LDAP_CAP_ACTIVE_DIRECTORY_ADAM_OID = '1.2.840.113556.1.4.1851'; + + // If AD DS: it's a Read Only DC (RODC) + const LDAP_CAP_ACTIVE_DIRECTORY_PARTIAL_SECRETS_OID = '1.2.840.113556.1.4.1920'; + + // Running at least W2K8 + const LDAP_CAP_ACTIVE_DIRECTORY_V60_OID = '1.2.840.113556.1.4.1935'; + + // Running at least W2K8r2 + const LDAP_CAP_ACTIVE_DIRECTORY_V61_R2_OID = '1.2.840.113556.1.4.2080'; + + // Running at least W2K12 + const LDAP_CAP_ACTIVE_DIRECTORY_W8_OID = '1.2.840.113556.1.4.2237'; + + /** + * Attributes of the LDAP Server returned by the discovery query + * + * @var stdClass + */ + private $attributes; + + /** + * Map of supported available OIDS + * + * @var array + */ + private $oids; + + /** + * Construct a new capability + * + * @param $attributes stdClass The attributes returned, may be null for guessing default capabilities + */ + public function __construct($attributes = null) + { + $this->setAttributes($attributes); + } + + /** + * Set the attributes and (re)build the OIDs + * + * @param $attributes stdClass The attributes returned, may be null for guessing default capabilities + */ + protected function setAttributes($attributes) + { + $this->attributes = $attributes; + $this->oids = array(); + + $keys = array('supportedControl', 'supportedExtension', 'supportedFeatures', 'supportedCapabilities'); + foreach ($keys as $key) { + if (isset($attributes->$key)) { + if (is_array($attributes->$key)) { + foreach ($attributes->$key as $oid) { + $this->oids[$oid] = true; + } + } else { + $this->oids[$attributes->$key] = true; + } + } + } + } + + /** + * Return if the capability object contains support for StartTLS + * + * @return bool Whether StartTLS is supported + */ + public function hasStartTls() + { + return isset($this->oids[self::LDAP_SERVER_START_TLS_OID]); + } + + /** + * Return if the capability object contains support for paged results + * + * @return bool Whether StartTLS is supported + */ + public function hasPagedResult() + { + return isset($this->oids[self::LDAP_PAGED_RESULT_OID_STRING]); + } + + /** + * Whether the ldap server is an ActiveDirectory server + * + * @return boolean + */ + public function isActiveDirectory() + { + return isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_OID]); + } + + /** + * Whether the ldap server is an OpenLDAP server + * + * @return bool + */ + public function isOpenLdap() + { + return isset($this->attributes->structuralObjectClass) && + $this->attributes->structuralObjectClass === 'OpenLDAProotDSE'; + } + + /** + * Return if the capability objects contains support for LdapV3, defaults to true if discovery failed + * + * @return bool + */ + public function hasLdapV3() + { + if (! isset($this->attributes) || ! isset($this->attributes->supportedLDAPVersion)) { + // Default to true, if unknown + return true; + } + + return (is_string($this->attributes->supportedLDAPVersion) + && (int) $this->attributes->supportedLDAPVersion === 3) + || (is_array($this->attributes->supportedLDAPVersion) + && in_array(3, $this->attributes->supportedLDAPVersion)); + } + + /** + * Whether the capability with the given OID is supported + * + * @param $oid string The OID of the capability + * + * @return bool + */ + public function hasOid($oid) + { + return isset($this->oids[$oid]); + } + + /** + * Get the default naming context + * + * @return string|null the default naming context, or null when no contexts are available + */ + public function getDefaultNamingContext() + { + // defaultNamingContext entry has higher priority + if (isset($this->attributes->defaultNamingContext)) { + return $this->attributes->defaultNamingContext; + } + + // if its missing use namingContext + $namingContexts = $this->namingContexts(); + return empty($namingContexts) ? null : $namingContexts[0]; + } + + /** + * Get the configuration naming context + * + * @return string|null + */ + public function getConfigurationNamingContext() + { + if (isset($this->attributes->configurationNamingContext)) { + return $this->attributes->configurationNamingContext; + } + } + + /** + * Get the NetBIOS name + * + * @return string|null + */ + public function getNetBiosName() + { + if (isset($this->attributes->nETBIOSName)) { + return $this->attributes->nETBIOSName; + } + } + + /** + * Fetch the namingContexts + * + * @return array the available naming contexts + */ + public function namingContexts() + { + if (!isset($this->attributes->namingContexts)) { + return array(); + } + if (!is_array($this->attributes->namingContexts)) { + return array($this->attributes->namingContexts); + } + return$this->attributes->namingContexts; + } + + public function getVendor() + { + /* + rfc #3045 specifies that the name of the server MAY be included in the attribute 'verndorName', + AD and OpenLDAP don't do this, but for all all other vendors we follow the standard and + just hope for the best. + */ + + if ($this->isActiveDirectory()) { + return 'Microsoft Active Directory'; + } + + if ($this->isOpenLdap()) { + return 'OpenLDAP'; + } + + if (! isset($this->attributes->vendorName)) { + return null; + } + return $this->attributes->vendorName; + } + + public function getVersion() + { + /* + rfc #3045 specifies that the version of the server MAY be included in the attribute 'vendorVersion', + but AD and OpenLDAP don't do this. For OpenLDAP there is no way to query the server versions, but for all + all other vendors we follow the standard and just hope for the best. + */ + + if ($this->isActiveDirectory()) { + return $this->getAdObjectVersionName(); + } + + if (! isset($this->attributes->vendorVersion)) { + return null; + } + return $this->attributes->vendorVersion; + } + + /** + * Discover the capabilities of the given LDAP server + * + * @param LdapConnection $connection The ldap connection to use + * + * @return LdapCapabilities + * + * @throws LdapException In case the capability query has failed + */ + public static function discoverCapabilities(LdapConnection $connection) + { + $ds = $connection->getConnection(); + + $fields = array( + 'configurationNamingContext', + 'defaultNamingContext', + 'namingContexts', + 'vendorName', + 'vendorVersion', + 'supportedSaslMechanisms', + 'dnsHostName', + 'schemaNamingContext', + 'supportedLDAPVersion', // => array(3, 2) + 'supportedCapabilities', + 'supportedControl', + 'supportedExtension', + 'objectVersion', + '+' + ); + + $result = @ldap_read($ds, '', (string) $connection->select()->from('*', $fields), $fields); + if (! $result) { + throw new LdapException( + 'Capability query failed (%s; Default port: %d): %s. Check if hostname and port' + . ' of the ldap resource are correct and if anonymous access is permitted.', + $connection->getHostname(), + $connection->getPort(), + ldap_error($ds) + ); + } + + $entry = ldap_first_entry($ds, $result); + if ($entry === false) { + throw new LdapException( + 'Capabilities not available (%s; Default port: %d): %s. Discovery of root DSE probably not permitted.', + $connection->getHostname(), + $connection->getPort(), + ldap_error($ds) + ); + } + + $cap = new LdapCapabilities($connection->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields)); + $cap->discoverAdConfigOptions($connection); + + if (isset($cap->attributes) && Logger::getInstance()->getLevel() === Logger::DEBUG) { + Logger::debug('Capability query discovered the following attributes:'); + foreach ($cap->attributes as $name => $value) { + if ($value !== null) { + Logger::debug(' %s = %s', $name, $value); + } + } + Logger::debug('Capability query attribute listing ended.'); + } + + return $cap; + } + + /** + * Discover the AD-specific configuration options of the given LDAP server + * + * @param LdapConnection $connection The ldap connection to use + * + * @throws LdapException In case the configuration options query has failed + */ + protected function discoverAdConfigOptions(LdapConnection $connection) + { + if ($this->isActiveDirectory()) { + $configurationNamingContext = $this->getConfigurationNamingContext(); + $defaultNamingContext = $this->getDefaultNamingContext(); + if (!($configurationNamingContext === null || $defaultNamingContext === null)) { + $ds = $connection->bind()->getConnection(); + $adFields = array('nETBIOSName'); + $partitions = 'CN=Partitions,' . $configurationNamingContext; + + $result = @ldap_list( + $ds, + $partitions, + (string) $connection->select()->from('*', $adFields)->where('nCName', $defaultNamingContext), + $adFields + ); + if ($result) { + $entry = ldap_first_entry($ds, $result); + if ($entry === false) { + throw new LdapException( + 'Configuration options not available (%s:%d). Discovery of "%s" probably not permitted.', + $connection->getHostname(), + $connection->getPort(), + $partitions + ); + } + + $this->setAttributes((object) array_merge( + (array) $this->attributes, + (array) $connection->cleanupAttributes(ldap_get_attributes($ds, $entry), $adFields) + )); + } else { + if (ldap_errno($ds) !== 1) { + // One stands for "operations error" which occurs if not bound non-anonymously. + + throw new LdapException( + 'Configuration options query failed (%s:%d): %s. Check if hostname and port of the' + . ' ldap resource are correct and if anonymous access is permitted.', + $connection->getHostname(), + $connection->getPort(), + ldap_error($ds) + ); + } + } + } + } + } + + /** + * Determine the active directory version using the available capabillities + * + * @return null|string The server version description or null when unknown + */ + protected function getAdObjectVersionName() + { + if (isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_W8_OID])) { + return 'Windows Server 2012 (or newer)'; + } + if (isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_V61_R2_OID])) { + return 'Windows Server 2008 R2 (or newer)'; + } + if (isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_V60_OID])) { + return 'Windows Server 2008 (or newer)'; + } + return null; + } +} diff --git a/library/Icinga/Protocol/Ldap/LdapConnection.php b/library/Icinga/Protocol/Ldap/LdapConnection.php new file mode 100644 index 0000000..a620e6d --- /dev/null +++ b/library/Icinga/Protocol/Ldap/LdapConnection.php @@ -0,0 +1,1584 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +use ArrayIterator; +use Exception; +use Icinga\Data\Filter\FilterNot; +use LogicException; +use stdClass; +use Icinga\Application\Config; +use Icinga\Application\Logger; +use Icinga\Data\ConfigObject; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterChain; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Data\Inspectable; +use Icinga\Data\Inspection; +use Icinga\Data\Selectable; +use Icinga\Data\Sortable; +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Url; + +/** + * Encapsulate LDAP connections and query creation + */ +class LdapConnection implements Selectable, Inspectable +{ + /** + * Indicates that the target object cannot be found + * + * @var int + */ + const LDAP_NO_SUCH_OBJECT = 32; + + /** + * Indicates that in a search operation, the size limit specified by the client or the server has been exceeded + * + * @var int + */ + const LDAP_SIZELIMIT_EXCEEDED = 4; + + /** + * Indicates that an LDAP server limit set by an administrative authority has been exceeded + * + * @var int + */ + const LDAP_ADMINLIMIT_EXCEEDED = 11; + + /** + * Indicates that during a bind operation one of the following occurred: The client passed either an incorrect DN + * or password, or the password is incorrect because it has expired, intruder detection has locked the account, or + * another similar reason. + * + * @var int + */ + const LDAP_INVALID_CREDENTIALS = 49; + + /** + * The default page size to use for paged queries + * + * @var int + */ + const PAGE_SIZE = 1000; + + /** + * Encrypt connection using STARTTLS (upgrading a plain text connection) + * + * @var string + */ + const STARTTLS = 'starttls'; + + /** + * Encrypt connection using LDAP over SSL (using a separate port) + * + * @var string + */ + const LDAPS = 'ldaps'; + + /** @var ConfigObject Connection configuration */ + protected $config; + + /** + * Encryption for the connection if any + * + * @var string + */ + protected $encryption; + + /** + * The LDAP link identifier being used + * + * @var resource + */ + protected $ds; + + /** + * The ip address, hostname or ldap URI being used to connect with the LDAP server + * + * @var string + */ + protected $hostname; + + /** + * The port being used to connect with the LDAP server + * + * @var int + */ + protected $port; + + /** + * The distinguished name being used to bind to the LDAP server + * + * @var string + */ + protected $bindDn; + + /** + * The password being used to bind to the LDAP server + * + * @var string + */ + protected $bindPw; + + /** + * The distinguished name being used as the base path for queries which do not provide one theirselves + * + * @var string + */ + protected $rootDn; + + /** + * Whether the bind on this connection has already been performed + * + * @var bool + */ + protected $bound; + + /** + * The current connection's root node + * + * @var Root + */ + protected $root; + + /** + * LDAP_OPT_NETWORK_TIMEOUT for the LDAP connection + * + * @var int + */ + protected $timeout; + + /** + * The properties and capabilities of the LDAP server + * + * @var LdapCapabilities + */ + protected $capabilities; + + /** + * Whether discovery was successful + * + * @var bool + */ + protected $discoverySuccess; + + /** + * The cause of the discovery's failure + * + * @var Exception|null + */ + private $discoveryError; + + /** + * Whether the current connection is encrypted + * + * @var bool + */ + protected $encrypted = null; + + /** + * Create a new connection object + * + * @param ConfigObject $config + */ + public function __construct(ConfigObject $config) + { + $this->config = $config; + $this->hostname = $config->hostname; + $this->bindDn = $config->bind_dn; + $this->bindPw = $config->bind_pw; + $this->rootDn = $config->root_dn; + $this->port = (int) $config->get('port', 389); + $this->timeout = (int) $config->get('timeout', 5); + + $this->encryption = $config->encryption; + if ($this->encryption !== null) { + $this->encryption = strtolower($this->encryption); + } + } + + /** + * Return the ip address, hostname or ldap URI being used to connect with the LDAP server + * + * @return string + */ + public function getHostname() + { + return $this->hostname; + } + + /** + * Return the port being used to connect with the LDAP server + * + * @return int + */ + public function getPort() + { + return $this->port; + } + + /** + * Return the distinguished name being used as the base path for queries which do not provide one theirselves + * + * @return string + */ + public function getDn() + { + return $this->rootDn; + } + + /** + * Return the root node for this connection + * + * @return Root + */ + public function root() + { + if ($this->root === null) { + $this->root = Root::forConnection($this); + } + + return $this->root; + } + + /** + * Return the LDAP link identifier being used + * + * Establishes a connection if necessary. + * + * @return resource + */ + public function getConnection() + { + if ($this->ds === null) { + $this->ds = $this->prepareNewConnection(); + } + + return $this->ds; + } + + /** + * Return the capabilities of the current connection + * + * @return LdapCapabilities + */ + public function getCapabilities() + { + if ($this->capabilities === null) { + try { + $this->capabilities = LdapCapabilities::discoverCapabilities($this); + $this->discoverySuccess = true; + $this->discoveryError = null; + } catch (LdapException $e) { + Logger::debug($e); + Logger::warning('LADP discovery failed, assuming default LDAP capabilities.'); + $this->capabilities = new LdapCapabilities(); // create empty default capabilities + $this->discoverySuccess = false; + $this->discoveryError = $e; + } + } + + return $this->capabilities; + } + + /** + * Return whether discovery was successful + * + * @return bool true if the capabilities were successfully determined, false if the capabilities were guessed + */ + public function discoverySuccessful() + { + if ($this->discoverySuccess === null) { + $this->getCapabilities(); // Initializes self::$discoverySuccess + } + + return $this->discoverySuccess; + } + + /** + * Get discovery error if any + * + * @return Exception|null + */ + public function getDiscoveryError() + { + return $this->discoveryError; + } + + /** + * Return whether the current connection is encrypted + * + * @return bool + */ + public function isEncrypted() + { + if ($this->encrypted === null) { + return false; + } + + return $this->encrypted; + } + + /** + * Perform a LDAP bind on the current connection + * + * @throws LdapException In case the LDAP bind was unsuccessful or insecure + */ + public function bind() + { + if ($this->bound) { + return $this; + } + + $ds = $this->getConnection(); + + $success = @ldap_bind($ds, $this->bindDn, $this->bindPw); + if (! $success) { + throw new LdapException( + 'LDAP bind (%s / %s) to %s failed: %s', + $this->bindDn, + '***' /* $this->bindPw */, + $this->normalizeHostname($this->hostname), + ldap_error($ds) + ); + } + + $this->bound = true; + return $this; + } + + /** + * Provide a query on this connection + * + * @return LdapQuery + */ + public function select() + { + return new LdapQuery($this); + } + + /** + * Fetch and return all rows of the given query's result set using an iterator + * + * @param LdapQuery $query The query returning the result set + * + * @return ArrayIterator + */ + public function query(LdapQuery $query) + { + return new ArrayIterator($this->fetchAll($query)); + } + + /** + * Count all rows of the given query's result set + * + * @param LdapQuery $query The query returning the result set + * + * @return int + */ + public function count(LdapQuery $query) + { + $this->bind(); + + if (($unfoldAttribute = $query->getUnfoldAttribute()) !== null) { + $desiredColumns = $query->getColumns(); + if (isset($desiredColumns[$unfoldAttribute])) { + $fields = array($unfoldAttribute => $desiredColumns[$unfoldAttribute]); + } elseif (in_array($unfoldAttribute, $desiredColumns, true)) { + $fields = array($unfoldAttribute); + } else { + throw new ProgrammingError( + 'The attribute used to unfold a query\'s result must be selected' + ); + } + + $res = $this->runQuery($query, $fields); + return count($res); + } + + $ds = $this->getConnection(); + $results = $this->ldapSearch($query, array('dn')); + + if ($results === false) { + if (ldap_errno($ds) !== self::LDAP_NO_SUCH_OBJECT) { + throw new LdapException( + 'LDAP count query "%s" (base %s) failed: %s', + (string) $query, + $query->getBase() ?: $this->getDn(), + ldap_error($ds) + ); + } + } + + return ldap_count_entries($ds, $results); + } + + /** + * Retrieve an array containing all rows of the result set + * + * @param LdapQuery $query The query returning the result set + * @param array $fields Request these attributes instead of the ones registered in the given query + * + * @return array + */ + public function fetchAll(LdapQuery $query, array $fields = null) + { + $this->bind(); + + if ($query->getUsePagedResults() && $this->getCapabilities()->hasPagedResult()) { + return $this->runPagedQuery($query, $fields); + } else { + return $this->runQuery($query, $fields); + } + } + + /** + * Fetch the first row of the result set + * + * @param LdapQuery $query The query returning the result set + * @param array $fields Request these attributes instead of the ones registered in the given query + * + * @return mixed + */ + public function fetchRow(LdapQuery $query, array $fields = null) + { + $clonedQuery = clone $query; + $clonedQuery->limit(1); + $clonedQuery->setUsePagedResults(false); + $results = $this->fetchAll($clonedQuery, $fields); + return array_shift($results) ?: false; + } + + /** + * Fetch the first column of all rows of the result set as an array + * + * @param LdapQuery $query The query returning the result set + * @param array $fields Request these attributes instead of the ones registered in the given query + * + * @return array + * + * @throws ProgrammingError In case no attribute is being requested + */ + public function fetchColumn(LdapQuery $query, array $fields = null) + { + if ($fields === null) { + $fields = $query->getColumns(); + } + + if (empty($fields)) { + throw new ProgrammingError('You must request at least one attribute when fetching a single column'); + } + + $alias = key($fields); + $results = $this->fetchAll($query, array($alias => current($fields))); + $column = is_int($alias) ? current($fields) : $alias; + $values = array(); + foreach ($results as $row) { + if (isset($row->$column)) { + $values[] = $row->$column; + } + } + + return $values; + } + + /** + * Fetch the first column of the first row of the result set + * + * @param LdapQuery $query The query returning the result set + * @param array $fields Request these attributes instead of the ones registered in the given query + * + * @return string + */ + public function fetchOne(LdapQuery $query, array $fields = null) + { + $row = $this->fetchRow($query, $fields); + if ($row === false) { + return false; + } + + $values = get_object_vars($row); + if (empty($values)) { + return false; + } + + if ($fields === null) { + // Fetch the desired columns from the query if not explicitly overriden in the method's parameter + $fields = $query->getColumns(); + } + + if (empty($fields)) { + // The desired columns may be empty independently whether provided by the query or the method's parameter + return array_shift($values); + } + + $alias = key($fields); + return $values[is_string($alias) ? $alias : $fields[$alias]]; + } + + /** + * Fetch all rows of the result set as an array of key-value pairs + * + * The first column is the key, the second column is the value. + * + * @param LdapQuery $query The query returning the result set + * @param array $fields Request these attributes instead of the ones registered in the given query + * + * @return array + * + * @throws ProgrammingError In case there are less than two attributes being requested + */ + public function fetchPairs(LdapQuery $query, array $fields = null) + { + if ($fields === null) { + $fields = $query->getColumns(); + } + + if (count($fields) < 2) { + throw new ProgrammingError('You are required to request at least two attributes'); + } + + $columns = $desiredColumnNames = array(); + foreach ($fields as $alias => $column) { + if (is_int($alias)) { + $columns[] = $column; + $desiredColumnNames[] = $column; + } else { + $columns[$alias] = $column; + $desiredColumnNames[] = $alias; + } + + if (count($desiredColumnNames) === 2) { + break; + } + } + + $results = $this->fetchAll($query, $columns); + $pairs = array(); + foreach ($results as $row) { + $colOne = $desiredColumnNames[0]; + $colTwo = $desiredColumnNames[1]; + $pairs[$row->$colOne] = $row->$colTwo; + } + + return $pairs; + } + + /** + * Fetch an LDAP entry by its DN + * + * @param string $dn + * @param array|null $fields + * + * @return StdClass|bool + */ + public function fetchByDn($dn, array $fields = null) + { + return $this->select() + ->from('*', $fields) + ->setBase($dn) + ->setScope('base') + ->fetchRow(); + } + + /** + * Test the given LDAP credentials by establishing a connection and attempting a LDAP bind + * + * @param string $bindDn + * @param string $bindPw + * + * @return bool Whether the given credentials are valid + * + * @throws LdapException In case an error occured while establishing the connection or attempting the bind + */ + public function testCredentials($bindDn, $bindPw) + { + $ds = $this->getConnection(); + $success = @ldap_bind($ds, $bindDn, $bindPw); + if (! $success) { + if (ldap_errno($ds) === self::LDAP_INVALID_CREDENTIALS) { + Logger::debug( + 'Testing LDAP credentials (%s / %s) failed: %s', + $bindDn, + '***', + ldap_error($ds) + ); + return false; + } + + throw new LdapException(ldap_error($ds)); + } + + return true; + } + + /** + * Return whether an entry identified by the given distinguished name exists + * + * @param string $dn + * + * @return bool + */ + public function hasDn($dn) + { + $ds = $this->getConnection(); + $this->bind(); + + $result = ldap_read($ds, $dn, '(objectClass=*)', array('objectClass')); + return ldap_count_entries($ds, $result) > 0; + } + + /** + * Delete a root entry and all of its children identified by the given distinguished name + * + * @param string $dn + * + * @return bool + * + * @throws LdapException In case an error occured while deleting an entry + */ + public function deleteRecursively($dn) + { + $ds = $this->getConnection(); + $this->bind(); + + $result = @ldap_list($ds, $dn, '(objectClass=*)', array('objectClass')); + if ($result === false) { + if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) { + return false; + } + + throw new LdapException('LDAP list for "%s" failed: %s', $dn, ldap_error($ds)); + } + + $children = ldap_get_entries($ds, $result); + for ($i = 0; $i < $children['count']; $i++) { + $result = $this->deleteRecursively($children[$i]['dn']); + if (! $result) { + // TODO: return result code, if delete fails + throw new LdapException('Recursively deleting "%s" failed', $dn); + } + } + + return $this->deleteDn($dn); + } + + /** + * Delete a single entry identified by the given distinguished name + * + * @param string $dn + * + * @return bool + * + * @throws LdapException In case an error occured while deleting the entry + */ + public function deleteDn($dn) + { + $ds = $this->getConnection(); + $this->bind(); + + $result = @ldap_delete($ds, $dn); + if ($result === false) { + if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) { + return false; // TODO: Isn't it a success if something i'd like to remove is not existing at all??? + } + + throw new LdapException('LDAP delete for "%s" failed: %s', $dn, ldap_error($ds)); + } + + return true; + } + + /** + * Fetch the distinguished name of the result of the given query + * + * @param LdapQuery $query The query returning the result set + * + * @return string The distinguished name, or false when the given query yields no results + * + * @throws LdapException In case the query yields multiple results + */ + public function fetchDn(LdapQuery $query) + { + $rows = $this->fetchAll($query, array()); + if (count($rows) > 1) { + throw new LdapException('Cannot fetch single DN for %s', $query); + } + + return key($rows); + } + + /** + * Run the given LDAP query and return the resulting entries + * + * @param LdapQuery $query The query to fetch results with + * @param array $fields Request these attributes instead of the ones registered in the given query + * + * @return array + * + * @throws LdapException In case an error occured while fetching the results + */ + protected function runQuery(LdapQuery $query, array $fields = null) + { + $limit = $query->getLimit(); + $offset = $query->hasOffset() ? $query->getOffset() : 0; + + if ($fields === null) { + $fields = $query->getColumns(); + } + + $ds = $this->getConnection(); + + $serverSorting = ! $this->config->disable_server_side_sort + && $this->getCapabilities()->hasOid(LdapCapabilities::LDAP_SERVER_SORT_OID); + + if ($query->hasOrder()) { + if ($serverSorting) { + ldap_set_option($ds, LDAP_OPT_SERVER_CONTROLS, array( + array( + 'oid' => LdapCapabilities::LDAP_SERVER_SORT_OID, + 'value' => $this->encodeSortRules($query->getOrder()) + ) + )); + } elseif (! empty($fields)) { + foreach ($query->getOrder() as $rule) { + if (! in_array($rule[0], $fields, true)) { + $fields[] = $rule[0]; + } + } + } + } + + $unfoldAttribute = $query->getUnfoldAttribute(); + if ($unfoldAttribute) { + foreach ($query->getFilter()->listFilteredColumns() as $filterColumn) { + $fieldKey = array_search($filterColumn, $fields, true); + if ($fieldKey === false || is_string($fieldKey)) { + $fields[] = $filterColumn; + } + } + } + + $results = $this->ldapSearch( + $query, + array_values($fields), + 0, + ($serverSorting || ! $query->hasOrder()) && $limit ? $offset + $limit : 0 + ); + if ($results === false) { + if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) { + return array(); + } + + throw new LdapException( + 'LDAP query "%s" (base %s) failed. Error: %s', + $query, + $query->getBase() ?: $this->rootDn, + ldap_error($ds) + ); + } elseif (ldap_count_entries($ds, $results) === 0) { + return array(); + } + + $count = 0; + $entries = array(); + $entry = ldap_first_entry($ds, $results); + do { + if ($unfoldAttribute) { + $rows = $this->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields, $unfoldAttribute); + if (is_array($rows)) { + // TODO: Register the DN the same way as a section name in the ArrayDatasource! + foreach ($rows as $row) { + if ($query->getFilter()->matches($row)) { + $count += 1; + if (! $serverSorting || $offset === 0 || $offset < $count) { + $entries[] = $row; + } + + if ($serverSorting && $limit > 0 && $limit === count($entries)) { + break; + } + } + } + } else { + $count += 1; + if (! $serverSorting || $offset === 0 || $offset < $count) { + $entries[ldap_get_dn($ds, $entry)] = $rows; + } + } + } else { + $count += 1; + if (! $serverSorting || $offset === 0 || $offset < $count) { + $entries[ldap_get_dn($ds, $entry)] = $this->cleanupAttributes( + ldap_get_attributes($ds, $entry), + $fields + ); + } + } + } while ((! $serverSorting || $limit === 0 || $limit !== count($entries)) + && ($entry = ldap_next_entry($ds, $entry)) + ); + + if (! $serverSorting) { + if ($query->hasOrder()) { + uasort($entries, array($query, 'compare')); + } + + if ($limit && $count > $limit) { + $entries = array_splice($entries, $query->hasOffset() ? $query->getOffset() : 0, $limit); + } + } + + ldap_free_result($results); + return $entries; + } + + /** + * Run the given LDAP query and return the resulting entries + * + * This utilizes paged search requests as defined in RFC 2696. + * + * @param LdapQuery $query The query to fetch results with + * @param array $fields Request these attributes instead of the ones registered in the given query + * @param int $pageSize The maximum page size, defaults to self::PAGE_SIZE + * + * @return array + * + * @throws LdapException In case an error occured while fetching the results + */ + protected function runPagedQuery(LdapQuery $query, array $fields = null, $pageSize = null) + { + if ($pageSize === null) { + $pageSize = static::PAGE_SIZE; + } + + $limit = $query->getLimit(); + $offset = $query->hasOffset() ? $query->getOffset() : 0; + + if ($fields === null) { + $fields = $query->getColumns(); + } + + $ds = $this->getConnection(); + + $serverSorting = false;//$this->getCapabilities()->hasOid(LdapCapabilities::LDAP_SERVER_SORT_OID); + if (! $serverSorting && $query->hasOrder() && ! empty($fields)) { + foreach ($query->getOrder() as $rule) { + if (! in_array($rule[0], $fields, true)) { + $fields[] = $rule[0]; + } + } + } + + $unfoldAttribute = $query->getUnfoldAttribute(); + if ($unfoldAttribute) { + foreach ($query->getFilter()->listFilteredColumns() as $filterColumn) { + $fieldKey = array_search($filterColumn, $fields, true); + if ($fieldKey === false || is_string($fieldKey)) { + $fields[] = $filterColumn; + } + } + } + + $controls = []; + $legacyControlHandling = version_compare(PHP_VERSION, '7.3.0') < 0; + if ($serverSorting && $query->hasOrder()) { + $control = [ + 'oid' => LDAP_CONTROL_SORTREQUEST, + 'value' => $this->encodeSortRules($query->getOrder()) + ]; + if ($legacyControlHandling) { + ldap_set_option($ds, LDAP_OPT_SERVER_CONTROLS, [$control]); + } else { + $controls[LDAP_CONTROL_SORTREQUEST] = $control; + } + } + + $count = 0; + $cookie = ''; + $entries = array(); + do { + if ($legacyControlHandling) { + // Do not request the pagination control as a critical extension, as we want the + // server to return results even if the paged search request cannot be satisfied + ldap_control_paged_result($ds, $pageSize, false, $cookie); + } else { + $controls[LDAP_CONTROL_PAGEDRESULTS] = [ + 'oid' => LDAP_CONTROL_PAGEDRESULTS, + 'iscritical' => false, // See above + 'value' => [ + 'size' => $pageSize, + 'cookie' => $cookie + ] + ]; + } + + $results = $this->ldapSearch( + $query, + array_values($fields), + 0, + ($serverSorting || ! $query->hasOrder()) && $limit ? $offset + $limit : 0, + 0, + LDAP_DEREF_NEVER, + empty($controls) ? null : $controls + ); + if ($results === false) { + if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) { + break; + } + + throw new LdapException( + 'LDAP query "%s" (base %s) failed. Error: %s', + (string) $query, + $query->getBase() ?: $this->getDn(), + ldap_error($ds) + ); + } elseif (ldap_count_entries($ds, $results) === 0) { + if (in_array( + ldap_errno($ds), + array(static::LDAP_SIZELIMIT_EXCEEDED, static::LDAP_ADMINLIMIT_EXCEEDED), + true + )) { + Logger::warning( + 'Unable to request more than %u results. Does the server allow paged search requests? (%s)', + $count, + ldap_error($ds) + ); + } + + break; + } + + $entry = ldap_first_entry($ds, $results); + do { + if ($unfoldAttribute) { + $rows = $this->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields, $unfoldAttribute); + if (is_array($rows)) { + // TODO: Register the DN the same way as a section name in the ArrayDatasource! + foreach ($rows as $row) { + if ($query->getFilter()->matches($row)) { + $count += 1; + if (! $serverSorting || $offset === 0 || $offset < $count) { + $entries[] = $row; + } + + if ($serverSorting && $limit > 0 && $limit === count($entries)) { + break; + } + } + } + } else { + $count += 1; + if (! $serverSorting || $offset === 0 || $offset < $count) { + $entries[ldap_get_dn($ds, $entry)] = $rows; + } + } + } else { + $count += 1; + if (! $serverSorting || $offset === 0 || $offset < $count) { + $entries[ldap_get_dn($ds, $entry)] = $this->cleanupAttributes( + ldap_get_attributes($ds, $entry), + $fields + ); + } + } + } while ((! $serverSorting || $limit === 0 || $limit !== count($entries)) + && ($entry = ldap_next_entry($ds, $entry)) + ); + + if ($legacyControlHandling) { + if (false === @ldap_control_paged_result_response($ds, $results, $cookie)) { + // If the page size is greater than or equal to the sizeLimit value, the server should ignore the + // control as the request can be satisfied in a single page: https://www.ietf.org/rfc/rfc2696.txt + // This applies no matter whether paged search requests are permitted or not. You're done once you + // got everything you were out for. + if ($serverSorting && count($entries) !== $limit) { + // The server does not support pagination, but still returned a response by ignoring the + // pagedResultsControl. We output a warning to indicate that the pagination control was ignored. + Logger::warning( + 'Unable to request paged LDAP results. Does the server allow paged search requests?' + ); + } + } + } else { + ldap_parse_result($ds, $results, $errno, $dn, $errmsg, $refs, $controlsReturned); + $cookie = $controlsReturned[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie']; + } + + ldap_free_result($results); + } while ($cookie && (! $serverSorting || $limit === 0 || count($entries) < $limit)); + + if ($legacyControlHandling && $cookie) { + // A sequence of paged search requests is abandoned by the client sending a search request containing a + // pagedResultsControl with the size set to zero (0) and the cookie set to the last cookie returned by + // the server: https://www.ietf.org/rfc/rfc2696.txt + ldap_control_paged_result($ds, 0, false, $cookie); + // Returns no entries, due to the page size + ldap_search($ds, $query->getBase() ?: $this->getDn(), (string) $query); + } + + if (! $serverSorting) { + if ($query->hasOrder()) { + uasort($entries, array($query, 'compare')); + } + + if ($limit && $count > $limit) { + $entries = array_splice($entries, $query->hasOffset() ? $query->getOffset() : 0, $limit); + } + } + + return $entries; + } + + /** + * Clean up the given attributes and return them as simple object + * + * Applies column aliases, aggregates/unfolds multi-value attributes + * as array and sets null for each missing attribute. + * + * @param array $attributes + * @param array $requestedFields + * @param string $unfoldAttribute + * + * @return object|array An array in case the object has been unfolded + */ + public function cleanupAttributes($attributes, array $requestedFields, $unfoldAttribute = null) + { + // In case the result contains attributes with a differing case than the requested fields, it is + // necessary to create another array to map attributes case insensitively to their requested counterparts. + // This does also apply the virtual alias handling. (Since an LDAP server does not handle such) + $loweredFieldMap = array(); + foreach ($requestedFields as $alias => $name) { + $loweredName = strtolower($name); + if (isset($loweredFieldMap[$loweredName])) { + if (! is_array($loweredFieldMap[$loweredName])) { + $loweredFieldMap[$loweredName] = array($loweredFieldMap[$loweredName]); + } + + $loweredFieldMap[$loweredName][] = is_string($alias) ? $alias : $name; + } else { + $loweredFieldMap[$loweredName] = is_string($alias) ? $alias : $name; + } + } + + $cleanedAttributes = array(); + for ($i = 0; $i < $attributes['count']; $i++) { + $attribute_name = $attributes[$i]; + if ($attributes[$attribute_name]['count'] === 1) { + $attribute_value = $attributes[$attribute_name][0]; + } else { + $attribute_value = array(); + for ($j = 0; $j < $attributes[$attribute_name]['count']; $j++) { + $attribute_value[] = $attributes[$attribute_name][$j]; + } + } + + $requestedAttributeName = isset($loweredFieldMap[strtolower($attribute_name)]) + ? $loweredFieldMap[strtolower($attribute_name)] + : $attribute_name; + if (is_array($requestedAttributeName)) { + foreach ($requestedAttributeName as $requestedName) { + $cleanedAttributes[$requestedName] = $attribute_value; + } + } else { + $cleanedAttributes[$requestedAttributeName] = $attribute_value; + } + } + + // The result may not contain all requested fields, so populate the cleaned + // result with the missing fields and their value being set to null + foreach ($requestedFields as $alias => $name) { + if (! is_string($alias)) { + $alias = $name; + } + + if (! array_key_exists($alias, $cleanedAttributes)) { + $cleanedAttributes[$alias] = null; + Logger::debug('LDAP query result does not provide the requested field "%s"', $name); + } + } + + if ($unfoldAttribute !== null + && isset($cleanedAttributes[$unfoldAttribute]) + && is_array($cleanedAttributes[$unfoldAttribute]) + ) { + $siblings = array(); + foreach ($loweredFieldMap as $loweredName => $requestedNames) { + if (is_array($requestedNames) && in_array($unfoldAttribute, $requestedNames, true)) { + $siblings = array_diff($requestedNames, array($unfoldAttribute)); + break; + } + } + + $values = $cleanedAttributes[$unfoldAttribute]; + unset($cleanedAttributes[$unfoldAttribute]); + $baseRow = (object) $cleanedAttributes; + $rows = array(); + foreach ($values as $value) { + $row = clone $baseRow; + $row->{$unfoldAttribute} = $value; + foreach ($siblings as $sibling) { + $row->{$sibling} = $value; + } + + $rows[] = $row; + } + + return $rows; + } + + return (object) $cleanedAttributes; + } + + /** + * Encode the given array of sort rules as ASN.1 octet stream according to RFC 2891 + * + * @param array $sortRules + * + * @return string Binary representation of the octet stream + */ + protected function encodeSortRules(array $sortRules) + { + $sequenceOf = ''; + + foreach ($sortRules as $rule) { + if ($rule[1] === Sortable::SORT_DESC) { + $reversed = '8101ff'; + } else { + $reversed = ''; + } + + $attributeType = unpack('H*', $rule[0]); + $attributeType = $attributeType[1]; + $attributeOctets = strlen($attributeType) / 2; + if ($attributeOctets >= 127) { + // Use the indefinite form of the length octets (the long form would be another option) + $attributeType = '0440' . $attributeType . '0000'; + } else { + $attributeType = '04' . str_pad(dechex($attributeOctets), 2, '0', STR_PAD_LEFT) . $attributeType; + } + + $sequence = $attributeType . $reversed; + $sequenceOctects = strlen($sequence) / 2; + if ($sequenceOctects >= 127) { + $sequence = '3040' . $sequence . '0000'; + } else { + $sequence = '30' . str_pad(dechex($sequenceOctects), 2, '0', STR_PAD_LEFT) . $sequence; + } + + $sequenceOf .= $sequence; + } + + $sequenceOfOctets = strlen($sequenceOf) / 2; + if ($sequenceOfOctets >= 127) { + $sequenceOf = '3040' . $sequenceOf . '0000'; + } else { + $sequenceOf = '30' . str_pad(dechex($sequenceOfOctets), 2, '0', STR_PAD_LEFT) . $sequenceOf; + } + + return hex2bin($sequenceOf); + } + + /** + * Prepare and establish a connection with the LDAP server + * + * @param Inspection $info Optional inspection to fill with diagnostic info + * + * @return resource A LDAP link identifier + * + * @throws LdapException In case the connection is not possible + */ + protected function prepareNewConnection(Inspection $info = null) + { + if (! isset($info)) { + $info = new Inspection(''); + } + + $hostname = $this->normalizeHostname($this->hostname); + + $ds = ldap_connect($hostname); + + // Set a proper timeout for each connection + ldap_set_option($ds, LDAP_OPT_NETWORK_TIMEOUT, $this->timeout); + + // Usage of ldap_rename, setting LDAP_OPT_REFERRALS to 0 or using STARTTLS requires LDAPv3. + // If this does not work we're probably not in a PHP 5.3+ environment as it is VERY + // unlikely that the server complains about it by itself prior to a bind request + ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3); + + // Not setting this results in "Operations error" on AD when using the whole domain as search base + ldap_set_option($ds, LDAP_OPT_REFERRALS, 0); + + if ($this->encryption === static::LDAPS) { + $info->write('Connect using LDAPS'); + } elseif ($this->encryption === static::STARTTLS) { + $this->encrypted = true; + $info->write('Connect using STARTTLS'); + if (! ldap_start_tls($ds)) { + throw new LdapException('LDAP STARTTLS failed: %s', ldap_error($ds)); + } + } elseif ($this->encryption !== static::LDAPS) { + $this->encrypted = false; + $info->write('Connect without encryption'); + } + + return $ds; + } + + /** + * Perform a LDAP search and return the result + * + * @param LdapQuery $query + * @param array $attributes An array of the required attributes + * @param int $attrsonly Should be set to 1 if only attribute types are wanted + * @param int $sizelimit Enables you to limit the count of entries fetched + * @param int $timelimit Sets the number of seconds how long is spend on the search + * @param int $deref + * @param array $controls LDAP Controls to send with the request (Only supported with PHP v7.3+) + * + * @return resource|bool A search result identifier or false on error + * + * @throws LogicException If the LDAP query search scope is unsupported + */ + public function ldapSearch( + LdapQuery $query, + array $attributes = null, + $attrsonly = 0, + $sizelimit = 0, + $timelimit = 0, + $deref = LDAP_DEREF_NEVER, + $controls = null + ) { + $queryString = (string) $query; + $baseDn = $query->getBase() ?: $this->getDn(); + $scope = $query->getScope(); + + if (Logger::getInstance()->getLevel() === Logger::DEBUG) { + // We're checking the level by ourselves to avoid rendering the ldapsearch commandline for nothing + $starttlsParam = $this->encryption === static::STARTTLS ? ' -ZZ' : ''; + + $bindParams = ''; + if ($this->bound) { + $bindParams = ' -D "' . $this->bindDn . '"' . ($this->bindPw ? ' -W' : ''); + } + + if ($deref === LDAP_DEREF_NEVER) { + $derefName = 'never'; + } elseif ($deref === LDAP_DEREF_ALWAYS) { + $derefName = 'always'; + } elseif ($deref === LDAP_DEREF_SEARCHING) { + $derefName = 'search'; + } else { // $deref === LDAP_DEREF_FINDING + $derefName = 'find'; + } + + Logger::debug("Issuing LDAP search. Use '%s' to reproduce.", sprintf( + 'ldapsearch -P 3%s -H "%s"%s -b "%s" -s "%s" -z %u -l %u -a "%s"%s%s%s', + $starttlsParam, + $this->normalizeHostname($this->hostname), + $bindParams, + $baseDn, + $scope, + $sizelimit, + $timelimit, + $derefName, + $attrsonly ? ' -A' : '', + $queryString ? ' "' . $queryString . '"' : '', + $attributes ? ' "' . join('" "', $attributes) . '"' : '' + )); + } + + switch ($scope) { + case LdapQuery::SCOPE_SUB: + $function = 'ldap_search'; + break; + case LdapQuery::SCOPE_ONE: + $function = 'ldap_list'; + break; + case LdapQuery::SCOPE_BASE: + $function = 'ldap_read'; + break; + default: + throw new LogicException('LDAP scope %s not supported by ldapSearch', $scope); + } + + // Explicit calls with and without controls, + // because the parameter is only supported since PHP 7.3. + // Since it is a public method, + // providing controls will naturally fail if the parameter is not supported by PHP. + if ($controls !== null) { + return @$function( + $this->getConnection(), + $baseDn, + $queryString, + $attributes, + $attrsonly, + $sizelimit, + $timelimit, + $deref, + $controls + ); + } else { + return @$function( + $this->getConnection(), + $baseDn, + $queryString, + $attributes, + $attrsonly, + $sizelimit, + $timelimit, + $deref + ); + } + } + + /** + * Create an LDAP entry + * + * @param string $dn The distinguished name to use + * @param array $attributes The entry's attributes + * + * @return bool Whether the operation was successful + */ + public function addEntry($dn, array $attributes) + { + return ldap_add($this->getConnection(), $dn, $attributes); + } + + /** + * Modify an LDAP entry + * + * @param string $dn The distinguished name to use + * @param array $attributes The attributes to update the entry with + * + * @return bool Whether the operation was successful + */ + public function modifyEntry($dn, array $attributes) + { + return ldap_modify($this->getConnection(), $dn, $attributes); + } + + /** + * Change the distinguished name of an LDAP entry + * + * @param string $dn The entry's current distinguished name + * @param string $newRdn The new relative distinguished name + * @param string $newParentDn The new parent or superior entry's distinguished name + * + * @return resource The resulting search result identifier + * + * @throws LdapException In case an error occured + */ + public function moveEntry($dn, $newRdn, $newParentDn) + { + $ds = $this->getConnection(); + $result = ldap_rename($ds, $dn, $newRdn, $newParentDn, false); + if ($result === false) { + throw new LdapException('Could not move entry "%s" to "%s": %s', $dn, $newRdn, ldap_error($ds)); + } + + return $result; + } + + /** + * Return the LDAP specific configuration directory with the given relative path being appended + * + * @param string $sub + * + * @return string + */ + protected function getConfigDir($sub = null) + { + $dir = Config::$configDir . '/ldap'; + if ($sub !== null) { + $dir .= '/' . $sub; + } + + return $dir; + } + + /** + * Render and return a valid LDAP filter representation of the given filter + * + * @param Filter $filter + * @param int $level + * + * @return string + */ + public function renderFilter(Filter $filter, $level = 0) + { + if ($filter->isExpression()) { + /** @var $filter FilterExpression */ + return $this->renderFilterExpression($filter); + } + + /** @var $filter FilterChain */ + $parts = array(); + foreach ($filter->filters() as $filterPart) { + $part = $this->renderFilter($filterPart, $level + 1); + if ($part) { + $parts[] = $part; + } + } + + if (empty($parts)) { + return ''; + } + + $format = '%1$s(%2$s)'; + if (count($parts) === 1 && ! $filter instanceof FilterNot) { + $format = '%2$s'; + } + if ($level === 0) { + $format = '(' . $format . ')'; + } + + return sprintf($format, $filter->getOperatorSymbol(), implode(')(', $parts)); + } + + /** + * Render and return a valid LDAP filter expression of the given filter + * + * @param FilterExpression $filter + * + * @return string + */ + protected function renderFilterExpression(FilterExpression $filter) + { + $column = $filter->getColumn(); + $sign = $filter->getSign(); + $expression = $filter->getExpression(); + $format = '%1$s%2$s%3$s'; + + if ($expression === null || $expression === true) { + $expression = '*'; + } elseif (is_array($expression)) { + $seqFormat = '|(%s)'; + if ($sign === '!=') { + $seqFormat = '!(' . $seqFormat . ')'; + $sign = '='; + } + + $seqParts = array(); + foreach ($expression as $expressionValue) { + $seqParts[] = sprintf( + $format, + LdapUtils::quoteForSearch($column), + $sign, + LdapUtils::quoteForSearch($expressionValue, true) + ); + } + + return sprintf($seqFormat, implode(')(', $seqParts)); + } + + if ($sign === '!=') { + $format = '!(%1$s=%3$s)'; + } + + return sprintf( + $format, + LdapUtils::quoteForSearch($column), + $sign, + LdapUtils::quoteForSearch($expression, true) + ); + } + + /** + * Inspect if this LDAP Connection is working as expected + * + * Check if connection, bind and encryption is working as expected and get additional + * information about the used + * + * @return Inspection Inspection result + */ + public function inspect() + { + $insp = new Inspection('Ldap Connection'); + + // Try to connect to the server with the given connection parameters + try { + $ds = $this->prepareNewConnection($insp); + } catch (Exception $e) { + if ($this->encryption === 'starttls') { + // The Exception does not return any proper error messages in case of certificate errors. Connecting + // by STARTTLS will usually fail at this point when the certificate is unknown, + // so at least try to give some hints. + $insp->write('NOTE: There might be an issue with the chosen encryption. Ensure that the LDAP-Server ' . + 'supports STARTTLS and that the LDAP-Client is configured to accept its certificate.'); + } + return $insp->error($e->getMessage()); + } + + // Try a bind-command with the given user credentials, this must not fail + $success = @ldap_bind($ds, $this->bindDn, $this->bindPw); + $msg = sprintf( + 'LDAP bind (%s / %s) to %s', + $this->bindDn, + '***' /* $this->bindPw */, + $this->normalizeHostname($this->hostname) + ); + if (! $success) { + // ldap_error does not return any proper error messages in case of certificate errors. Connecting + // by LDAPS will usually fail at this point when the certificate is unknown, so at least try to give + // some hints. + if ($this->encryption === 'ldaps') { + $insp->write('NOTE: There might be an issue with the chosen encryption. Ensure that the LDAP-Server ' . + ' supports LDAPS and that the LDAP-Client is configured to accept its certificate.'); + } + return $insp->error(sprintf('%s failed: %s', $msg, ldap_error($ds))); + } + $insp->write(sprintf($msg . ' successful')); + + // Try to execute a schema discovery this may fail if schema discovery is not supported + try { + $cap = LdapCapabilities::discoverCapabilities($this); + $discovery = new Inspection('Discovery Results'); + $vendor = $cap->getVendor(); + if (isset($vendor)) { + $discovery->write($vendor); + } + $version = $cap->getVersion(); + if (isset($version)) { + $discovery->write($version); + } + $discovery->write('Supports STARTTLS: ' . ($cap->hasStartTls() ? 'True' : 'False')); + $discovery->write('Default naming context: ' . $cap->getDefaultNamingContext()); + $insp->write($discovery); + } catch (Exception $e) { + $insp->write('Schema discovery not possible: ' . $e->getMessage()); + } + return $insp; + } + + protected function normalizeHostname($hostname) + { + $scheme = $this->encryption === static::LDAPS ? 'ldaps://' : 'ldap://'; + $normalizeHostname = function ($hostname) use ($scheme) { + if (strpos($hostname, $scheme) === false) { + $hostname = $scheme . $hostname; + } + + if (! preg_match('/:\d+$/', $hostname)) { + $hostname .= ':' . $this->port; + } + + return $hostname; + }; + + $ldapUrls = explode(' ', $hostname); + if (count($ldapUrls) > 1) { + foreach ($ldapUrls as & $uri) { + $uri = $normalizeHostname($uri); + } + + $hostname = implode(' ', $ldapUrls); + } else { + $hostname = $normalizeHostname($hostname); + } + + return $hostname; + } +} diff --git a/library/Icinga/Protocol/Ldap/LdapException.php b/library/Icinga/Protocol/Ldap/LdapException.php new file mode 100644 index 0000000..740ee29 --- /dev/null +++ b/library/Icinga/Protocol/Ldap/LdapException.php @@ -0,0 +1,14 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +use Icinga\Exception\IcingaException; + +/** + * Class LdapException + * @package Icinga\Protocol\Ldap + */ +class LdapException extends IcingaException +{ +} diff --git a/library/Icinga/Protocol/Ldap/LdapQuery.php b/library/Icinga/Protocol/Ldap/LdapQuery.php new file mode 100644 index 0000000..f4e1986 --- /dev/null +++ b/library/Icinga/Protocol/Ldap/LdapQuery.php @@ -0,0 +1,361 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +use Icinga\Data\Filter\Filter; +use LogicException; +use Icinga\Data\SimpleQuery; + +/** + * LDAP query class + */ +class LdapQuery extends SimpleQuery +{ + /** + * The base dn being used for this query + * + * @var string + */ + protected $base; + + /** + * Whether this query is permitted to utilize paged results + * + * @var bool + */ + protected $usePagedResults; + + /** + * The name of the attribute used to unfold the result + * + * @var string + */ + protected $unfoldAttribute; + + /** + * This query's native LDAP filter + * + * @var string + */ + protected $nativeFilter; + + /** + * Only fetch the entry at the base of the search + */ + const SCOPE_BASE = 'base'; + + /** + * Fetch entries one below the base DN + */ + const SCOPE_ONE = 'one'; + + /** + * Fetch all entries below the base DN + */ + const SCOPE_SUB = 'sub'; + + /** + * All available scopes + * + * @var array + */ + public static $scopes = array( + LdapQuery::SCOPE_BASE, + LdapQuery::SCOPE_ONE, + LdapQuery::SCOPE_SUB + ); + + /** + * LDAP search scope (default: SCOPE_SUB) + * + * @var string + */ + protected $scope = LdapQuery::SCOPE_SUB; + + /** + * Initialize this query + */ + protected function init() + { + $this->usePagedResults = false; + } + + /** + * Set the base dn to be used for this query + * + * @param string $base + * + * @return $this + */ + public function setBase($base) + { + $this->base = $base; + return $this; + } + + /** + * Return the base dn being used for this query + * + * @return string + */ + public function getBase() + { + return $this->base; + } + + /** + * Set whether this query is permitted to utilize paged results + * + * @param bool $state + * + * @return $this + */ + public function setUsePagedResults($state = true) + { + $this->usePagedResults = (bool) $state; + return $this; + } + + /** + * Return whether this query is permitted to utilize paged results + * + * @return bool + */ + public function getUsePagedResults() + { + return $this->usePagedResults; + } + + /** + * Set the attribute to be used to unfold the result + * + * @param string $attributeName + * + * @return $this + */ + public function setUnfoldAttribute($attributeName) + { + $this->unfoldAttribute = $attributeName; + return $this; + } + + /** + * Return the attribute to use to unfold the result + * + * @return string + */ + public function getUnfoldAttribute() + { + return $this->unfoldAttribute; + } + + /** + * Set this query's native LDAP filter + * + * @param string $filter + * + * @return $this + */ + public function setNativeFilter($filter) + { + $this->nativeFilter = $filter; + return $this; + } + + /** + * Return this query's native LDAP filter + * + * @return string + */ + public function getNativeFilter() + { + return $this->nativeFilter; + } + + /** + * Choose an objectClass and the columns you are interested in + * + * {@inheritdoc} This creates an objectClass filter. + */ + public function from($target, array $fields = null) + { + $this->where('objectClass', $target); + return parent::from($target, $fields); + } + + public function where($condition, $value = null) + { + $this->addFilter(Filter::expression($condition, '=', $value)); + return $this; + } + + public function addFilter(Filter $filter) + { + $this->makeCaseInsensitive($filter); + return parent::addFilter($filter); + } + + public function setFilter(Filter $filter) + { + $this->makeCaseInsensitive($filter); + return parent::setFilter($filter); + } + + protected function makeCaseInsensitive(Filter $filter) + { + if ($filter->isExpression()) { + /** @var \Icinga\Data\Filter\FilterExpression $filter */ + $filter->setCaseSensitive(false); + } else { + /** @var \Icinga\Data\Filter\FilterChain $filter */ + foreach ($filter->filters() as $subFilter) { + $this->makeCaseInsensitive($subFilter); + } + } + } + + public function compare($a, $b, $orderIndex = 0) + { + if (array_key_exists($orderIndex, $this->order)) { + $column = $this->order[$orderIndex][0]; + $direction = $this->order[$orderIndex][1]; + + $flippedColumns = $this->flippedColumns ?: array_flip($this->columns); + if (array_key_exists($column, $flippedColumns) && is_string($flippedColumns[$column])) { + $column = $flippedColumns[$column]; + } + + if (is_array($a->$column)) { + // rfc2891 states: If a sort key is a multi-valued attribute, and an entry happens to + // have multiple values for that attribute and no other controls are + // present that affect the sorting order, then the server SHOULD use the + // least value (according to the ORDERING rule for that attribute). + $a = clone $a; + $a->$column = array_reduce($a->$column, function ($carry, $item) use ($direction) { + $result = $carry === null ? 0 : strcmp($item, $carry); + return ($direction === self::SORT_ASC ? $result : $result * -1) < 1 ? $item : $carry; + }); + } + + if (is_array($b->$column)) { + $b = clone $b; + $b->$column = array_reduce($b->$column, function ($carry, $item) use ($direction) { + $result = $carry === null ? 0 : strcmp($item, $carry); + return ($direction === self::SORT_ASC ? $result : $result * -1) < 1 ? $item : $carry; + }); + } + } + + return parent::compare($a, $b, $orderIndex); + } + + /** + * Fetch result as tree + * + * @return Root + * + * @todo This is untested waste, not being used anywhere and ignores the query's order and base dn. + * Evaluate whether it's reasonable to properly implement and test it. + */ + public function fetchTree() + { + $result = $this->fetchAll(); + $sorted = array(); + $quotedDn = preg_quote($this->ds->getDn(), '/'); + foreach ($result as $key => & $item) { + $new_key = LdapUtils::implodeDN( + array_reverse( + LdapUtils::explodeDN( + preg_replace('/,' . $quotedDn . '$/', '', $key) + ) + ) + ); + $sorted[$new_key] = $key; + } + + ksort($sorted); + + $tree = Root::forConnection($this->ds); + $root_dn = $tree->getDN(); + foreach ($sorted as $sort_key => & $key) { + if ($key === $root_dn) { + continue; + } + $tree->createChildByDN($key, $result[$key]); + } + return $tree; + } + + /** + * Fetch the distinguished name of the first result + * + * @return string|false The distinguished name or false in case it's not possible to fetch a result + * + * @throws LdapException In case the query returns multiple results + * (i.e. it's not possible to fetch a unique DN) + */ + public function fetchDn() + { + return $this->ds->fetchDn($this); + } + + /** + * Render and return this query's filter + * + * @return string + */ + public function renderFilter() + { + $filter = $this->ds->renderFilter($this->filter); + if ($this->nativeFilter) { + $filter = '(&(' . $this->nativeFilter . ')' . $filter . ')'; + } + + return $filter; + } + + /** + * Return the LDAP filter to be applied on this query + * + * @return string + */ + public function __toString() + { + return $this->renderFilter(); + } + + /** + * Get LDAP search scope + * + * @return string + */ + public function getScope() + { + return $this->scope; + } + + /** + * Set LDAP search scope + * + * Valid: sub one base (Default: sub) + * + * @param string $scope + * + * @return LdapQuery + * + * @throws LogicException If scope value is invalid + */ + public function setScope($scope) + { + if (! in_array($scope, static::$scopes)) { + throw new LogicException( + 'Can\'t set scope %d, it is is invalid. Use one of %s or LdapQuery\'s constants.', + $scope, + implode(', ', static::$scopes) + ); + } + $this->scope = $scope; + return $this; + } +} diff --git a/library/Icinga/Protocol/Ldap/LdapUtils.php b/library/Icinga/Protocol/Ldap/LdapUtils.php new file mode 100644 index 0000000..9c9ae10 --- /dev/null +++ b/library/Icinga/Protocol/Ldap/LdapUtils.php @@ -0,0 +1,148 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +/** + * This class provides useful LDAP-related functions + * + * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com> + * @author Icinga-Web Team <info@icinga.com> + * @package Icinga\Protocol\Ldap + * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License + */ +class LdapUtils +{ + /** + * Extends PHPs ldap_explode_dn() function + * + * UTF-8 chars like German umlauts would otherwise be escaped and shown + * as backslash-prefixed hexcode-sequenzes. + * + * @param string $dn DN + * @param boolean $with_type Returns 'type=value' when true and 'value' when false + * + * @return array + */ + public static function explodeDN($dn, $with_type = true) + { + $res = ldap_explode_dn($dn, $with_type ? 0 : 1); + + foreach ($res as $k => $v) { + $res[$k] = preg_replace_callback( + '/\\\([0-9a-f]{2})/i', + function ($m) { + return chr(hexdec($m[1])); + }, + $v + ); + } + unset($res['count']); + return $res; + } + + /** + * Implode unquoted RDNs to a DN + * + * TODO: throw away, this is not how it shall be done + * + * @param array $parts DN-component + * + * @return string + */ + public static function implodeDN($parts) + { + $str = ''; + foreach ($parts as $part) { + if ($str !== '') { + $str .= ','; + } + list($key, $val) = preg_split('~=~', $part, 2); + $str .= $key . '=' . self::quoteForDN($val); + } + return $str; + } + + /** + * Test if supplied value looks like a DN + * + * @param mixed $value + * + * @return bool + */ + public static function isDn($value) + { + if (is_string($value)) { + return ldap_dn2ufn($value) !== false; + } + return false; + } + + /** + * Quote a string that should be used in a DN + * + * Special characters will be escaped + * + * @param string $str DN-component + * + * @return string + */ + public static function quoteForDN($str) + { + return self::quoteChars( + $str, + array( + ',', + '=', + '+', + '<', + '>', + ';', + '\\', + '"', + '#' + ) + ); + } + + /** + * Quote a string that should be used in an LDAP search + * + * Special characters will be escaped + * + * @param string String to be escaped + * @param bool $allow_wildcard + * @return string + */ + public static function quoteForSearch($str, $allow_wildcard = false) + { + if ($allow_wildcard) { + return self::quoteChars($str, array('(', ')', '\\', chr(0))); + } + return self::quoteChars($str, array('*', '(', ')', '\\', chr(0))); + } + + /** + * Escape given characters in the given string + * + * Special characters will be escaped + * + * @param $str + * @param $chars + * @internal param String $string to be escaped + * @return string + */ + protected static function quoteChars($str, $chars) + { + $quotedChars = array(); + foreach ($chars as $k => $v) { + // Temporarily prefixing with illegal '(' + $quotedChars[$k] = '(' . str_pad(dechex(ord($v)), 2, '0'); + } + $str = str_replace($chars, $quotedChars, $str); + // Replacing temporary '(' with '\\'. This is a workaround, as + // str_replace behaves pretty strange with leading a backslash: + $str = preg_replace('~\(~', '\\', $str); + return $str; + } +} diff --git a/library/Icinga/Protocol/Ldap/Node.php b/library/Icinga/Protocol/Ldap/Node.php new file mode 100644 index 0000000..176f962 --- /dev/null +++ b/library/Icinga/Protocol/Ldap/Node.php @@ -0,0 +1,69 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +/** + * This class represents an LDAP node object + * + * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com> + * @author Icinga-Web Team <info@icinga.com> + * @package Icinga\Protocol\Ldap + * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License + */ +class Node extends Root +{ + /** + * @var LdapConnection + */ + protected $connection; + + /** + * @var + */ + protected $rdn; + + /** + * @var Root + */ + protected $parent; + + /** + * @param Root $parent + */ + protected function __construct(Root $parent) + { + $this->connection = $parent->getConnection(); + $this->parent = $parent; + } + + /** + * @param $parent + * @param $rdn + * @param array $props + * @return Node + */ + public static function createWithRDN($parent, $rdn, $props = array()) + { + $node = new Node($parent); + $node->rdn = $rdn; + $node->props = $props; + return $node; + } + + /** + * @return mixed + */ + public function getRDN() + { + return $this->rdn; + } + + /** + * @return mixed|string + */ + public function getDN() + { + return $this->getRDN() . ',' . $this->parent->getDN(); + } +} diff --git a/library/Icinga/Protocol/Ldap/Root.php b/library/Icinga/Protocol/Ldap/Root.php new file mode 100644 index 0000000..48d8719 --- /dev/null +++ b/library/Icinga/Protocol/Ldap/Root.php @@ -0,0 +1,242 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +use Icinga\Exception\IcingaException; + +/** + * This class is a special node object, representing your connections root node + * + * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com> + * @author Icinga-Web Team <info@icinga.com> + * @package Icinga\Protocol\Ldap + * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License + * @package Icinga\Protocol\Ldap + */ +class Root +{ + /** + * @var string + */ + protected $rdn; + + /** + * @var LdapConnection + */ + protected $connection; + + /** + * @var array + */ + protected $children = array(); + + /** + * @var array + */ + protected $props = array(); + + /** + * @param LdapConnection $connection + */ + protected function __construct(LdapConnection $connection) + { + $this->connection = $connection; + } + + /** + * @return bool + */ + public function hasParent() + { + return false; + } + + /** + * @param LdapConnection $connection + * @return Root + */ + public static function forConnection(LdapConnection $connection) + { + $root = new Root($connection); + return $root; + } + + /** + * @param $dn + * @param array $props + * @return Node + */ + public function createChildByDN($dn, $props = array()) + { + $dn = $this->stripMyDN($dn); + $parts = array_reverse(LdapUtils::explodeDN($dn)); + $parent = $this; + $child = null; + while ($rdn = array_shift($parts)) { + if ($parent->hasChildRDN($rdn)) { + $child = $parent->getChildByRDN($rdn); + } else { + $child = Node::createWithRDN($parent, $rdn, (array)$props); + $parent->addChild($child); + } + $parent = $child; + } + return $child; + } + + /** + * @param $rdn + * @return bool + */ + public function hasChildRDN($rdn) + { + return array_key_exists(strtolower($rdn), $this->children); + } + + /** + * @param $rdn + * @return mixed + * @throws IcingaException + */ + public function getChildByRDN($rdn) + { + if (!$this->hasChildRDN($rdn)) { + throw new IcingaException( + 'The child RDN "%s" is not available', + $rdn + ); + } + return $this->children[strtolower($rdn)]; + } + + /** + * @return array + */ + public function children() + { + return $this->children; + } + + public function countChildren() + { + return count($this->children); + } + + /** + * @return bool + */ + public function hasChildren() + { + return !empty($this->children); + } + + /** + * @param Node $child + * @return $this + */ + public function addChild(Node $child) + { + $this->children[strtolower($child->getRDN())] = $child; + return $this; + } + + /** + * @param $dn + * @return string + */ + protected function stripMyDN($dn) + { + $this->assertSubDN($dn); + return substr($dn, 0, strlen($dn) - strlen($this->getDN()) - 1); + } + + /** + * @param $dn + * @return $this + * @throws IcingaException + */ + protected function assertSubDN($dn) + { + $mydn = $this->getDN(); + $end = substr($dn, -1 * strlen($mydn)); + if (strtolower($end) !== strtolower($mydn)) { + throw new IcingaException( + '"%s" is not a child of "%s"', + $dn, + $mydn + ); + } + if (strlen($dn) === strlen($mydn)) { + throw new IcingaException( + '"%s" is not a child of "%s", they are equal', + $dn, + $mydn + ); + } + return $this; + } + + /** + * @param LdapConnection $connection + * @return $this + */ + public function setConnection(LdapConnection $connection) + { + $this->connection = $connection; + return $this; + } + + /** + * @return LdapConnection + */ + public function getConnection() + { + return $this->connection; + } + + /** + * @return bool + */ + public function hasBeenChanged() + { + return false; + } + + /** + * @return mixed + */ + public function getRDN() + { + return $this->getDN(); + } + + /** + * @return mixed + */ + public function getDN() + { + return $this->connection->getDn(); + } + + /** + * @param $key + * @return null + */ + public function __get($key) + { + if (!array_key_exists($key, $this->props)) { + return null; + } + return $this->props[$key]; + } + + /** + * @param $key + * @return bool + */ + public function __isset($key) + { + return array_key_exists($key, $this->props); + } +} diff --git a/library/Icinga/Protocol/Nrpe/Connection.php b/library/Icinga/Protocol/Nrpe/Connection.php new file mode 100644 index 0000000..491a965 --- /dev/null +++ b/library/Icinga/Protocol/Nrpe/Connection.php @@ -0,0 +1,111 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Nrpe; + +use Icinga\Exception\IcingaException; + +class Connection +{ + protected $host; + protected $port; + protected $connection; + protected $use_ssl = false; + protected $lastReturnCode = null; + + public function __construct($host, $port = 5666) + { + $this->host = $host; + $this->port = $port; + } + + public function useSsl($use_ssl = true) + { + $this->use_ssl = $use_ssl; + return $this; + } + + public function sendCommand($command, $args = null) + { + if (! empty($args)) { + $command .= '!' . implode('!', $args); + } + + $packet = Packet::createQuery($command); + return $this->send($packet); + } + + public function getLastReturnCode() + { + return $this->lastReturnCode; + } + + public function send(Packet $packet) + { + $conn = $this->connection(); + $bytes = $packet->getBinary(); + fputs($conn, $bytes, strlen($bytes)); + // TODO: Check result checksum! + $result = fread($conn, 8192); + if ($result === false) { + throw new IcingaException('CHECK_NRPE: Error receiving data from daemon.'); + } elseif (strlen($result) === 0) { + throw new IcingaException( + 'CHECK_NRPE: Received 0 bytes from daemon. Check the remote server logs for error messages' + ); + } + // TODO: CHECK_NRPE: Receive underflow - only %d bytes received (%d expected) + $code = unpack('n', substr($result, 8, 2)); + $this->lastReturnCode = $code[1]; + $this->disconnect(); + return rtrim(substr($result, 10, -2)); + } + + protected function connect() + { + $ctx = stream_context_create(); + if ($this->use_ssl) { + // TODO: fail if not ok: + $res = stream_context_set_option($ctx, 'ssl', 'ciphers', 'ADH'); + $uri = sprintf('ssl://%s:%d', $this->host, $this->port); + } else { + $uri = sprintf('tcp://%s:%d', $this->host, $this->port); + } + $this->connection = @stream_socket_client( + $uri, + $errno, + $errstr, + 10, + STREAM_CLIENT_CONNECT, + $ctx + ); + if (! $this->connection) { + throw new IcingaException( + 'NRPE Connection failed: %s', + $errstr + ); + } + } + + protected function connection() + { + if ($this->connection === null) { + $this->connect(); + } + return $this->connection; + } + + protected function disconnect() + { + if (is_resource($this->connection)) { + fclose($this->connection); + $this->connection = null; + } + return $this; + } + + public function __destruct() + { + $this->disconnect(); + } +} diff --git a/library/Icinga/Protocol/Nrpe/Packet.php b/library/Icinga/Protocol/Nrpe/Packet.php new file mode 100644 index 0000000..54c8526 --- /dev/null +++ b/library/Icinga/Protocol/Nrpe/Packet.php @@ -0,0 +1,69 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Nrpe; + +class Packet +{ + const QUERY = 0x01; + const RESPONSE = 0x02; + + protected $version = 0x02; + protected $type; + protected $body; + protected static $randomBytes; + + public function __construct($type, $body) + { + $this->type = $type; + $this->body = $body; + $this->regenerateRandomBytes(); + } + + // TODO: renew "from time to time" to allow long-running daemons + protected function regenerateRandomBytes() + { + self::$randomBytes = ''; + for ($i = 0; $i < 4096; $i++) { + self::$randomBytes .= pack('N', mt_rand()); + } + } + + public static function createQuery($body) + { + $packet = new Packet(self::QUERY, $body); + return $packet; + } + + protected function getFillString($length) + { + $max = strlen(self::$randomBytes) - $length; + return substr(self::$randomBytes, rand(0, $max), $length); + } + + // TODO: WTF is SR? And 2324? + public function getBinary() + { + $version = pack('n', $this->version); + $type = pack('n', $this->type); + $dummycrc = "\x00\x00\x00\x00"; + $result = "\x00\x00"; + $result = pack('n', 2324); + $body = $this->body + . "\x00" + . $this->getFillString(1023 - strlen($this->body)) + . 'SR'; + + $crc = pack( + 'N', + crc32($version . $type . $dummycrc . $result . $body) + ); + $bytes = $version . $type . $crc . $result . $body; + return $bytes; + } + + public function __toString() + { + return $this->body; + } +} |