1<?php 2/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ 3 4namespace Icinga\Protocol\Ldap; 5 6use ArrayIterator; 7use Exception; 8use Icinga\Data\Filter\FilterNot; 9use LogicException; 10use stdClass; 11use Icinga\Application\Config; 12use Icinga\Application\Logger; 13use Icinga\Data\ConfigObject; 14use Icinga\Data\Filter\Filter; 15use Icinga\Data\Filter\FilterChain; 16use Icinga\Data\Filter\FilterExpression; 17use Icinga\Data\Inspectable; 18use Icinga\Data\Inspection; 19use Icinga\Data\Selectable; 20use Icinga\Data\Sortable; 21use Icinga\Exception\ProgrammingError; 22use Icinga\Web\Url; 23 24/** 25 * Encapsulate LDAP connections and query creation 26 */ 27class LdapConnection implements Selectable, Inspectable 28{ 29 /** 30 * Indicates that the target object cannot be found 31 * 32 * @var int 33 */ 34 const LDAP_NO_SUCH_OBJECT = 32; 35 36 /** 37 * Indicates that in a search operation, the size limit specified by the client or the server has been exceeded 38 * 39 * @var int 40 */ 41 const LDAP_SIZELIMIT_EXCEEDED = 4; 42 43 /** 44 * Indicates that an LDAP server limit set by an administrative authority has been exceeded 45 * 46 * @var int 47 */ 48 const LDAP_ADMINLIMIT_EXCEEDED = 11; 49 50 /** 51 * Indicates that during a bind operation one of the following occurred: The client passed either an incorrect DN 52 * or password, or the password is incorrect because it has expired, intruder detection has locked the account, or 53 * another similar reason. 54 * 55 * @var int 56 */ 57 const LDAP_INVALID_CREDENTIALS = 49; 58 59 /** 60 * The default page size to use for paged queries 61 * 62 * @var int 63 */ 64 const PAGE_SIZE = 1000; 65 66 /** 67 * Encrypt connection using STARTTLS (upgrading a plain text connection) 68 * 69 * @var string 70 */ 71 const STARTTLS = 'starttls'; 72 73 /** 74 * Encrypt connection using LDAP over SSL (using a separate port) 75 * 76 * @var string 77 */ 78 const LDAPS = 'ldaps'; 79 80 /** @var ConfigObject Connection configuration */ 81 protected $config; 82 83 /** 84 * Encryption for the connection if any 85 * 86 * @var string 87 */ 88 protected $encryption; 89 90 /** 91 * The LDAP link identifier being used 92 * 93 * @var resource 94 */ 95 protected $ds; 96 97 /** 98 * The ip address, hostname or ldap URI being used to connect with the LDAP server 99 * 100 * @var string 101 */ 102 protected $hostname; 103 104 /** 105 * The port being used to connect with the LDAP server 106 * 107 * @var int 108 */ 109 protected $port; 110 111 /** 112 * The distinguished name being used to bind to the LDAP server 113 * 114 * @var string 115 */ 116 protected $bindDn; 117 118 /** 119 * The password being used to bind to the LDAP server 120 * 121 * @var string 122 */ 123 protected $bindPw; 124 125 /** 126 * The distinguished name being used as the base path for queries which do not provide one theirselves 127 * 128 * @var string 129 */ 130 protected $rootDn; 131 132 /** 133 * Whether the bind on this connection has already been performed 134 * 135 * @var bool 136 */ 137 protected $bound; 138 139 /** 140 * The current connection's root node 141 * 142 * @var Root 143 */ 144 protected $root; 145 146 /** 147 * LDAP_OPT_NETWORK_TIMEOUT for the LDAP connection 148 * 149 * @var int 150 */ 151 protected $timeout; 152 153 /** 154 * The properties and capabilities of the LDAP server 155 * 156 * @var LdapCapabilities 157 */ 158 protected $capabilities; 159 160 /** 161 * Whether discovery was successful 162 * 163 * @var bool 164 */ 165 protected $discoverySuccess; 166 167 /** 168 * The cause of the discovery's failure 169 * 170 * @var Exception|null 171 */ 172 private $discoveryError; 173 174 /** 175 * Whether the current connection is encrypted 176 * 177 * @var bool 178 */ 179 protected $encrypted = null; 180 181 /** 182 * Create a new connection object 183 * 184 * @param ConfigObject $config 185 */ 186 public function __construct(ConfigObject $config) 187 { 188 $this->config = $config; 189 $this->hostname = $config->hostname; 190 $this->bindDn = $config->bind_dn; 191 $this->bindPw = $config->bind_pw; 192 $this->rootDn = $config->root_dn; 193 $this->port = (int) $config->get('port', 389); 194 $this->timeout = (int) $config->get('timeout', 5); 195 196 $this->encryption = $config->encryption; 197 if ($this->encryption !== null) { 198 $this->encryption = strtolower($this->encryption); 199 } 200 } 201 202 /** 203 * Return the ip address, hostname or ldap URI being used to connect with the LDAP server 204 * 205 * @return string 206 */ 207 public function getHostname() 208 { 209 return $this->hostname; 210 } 211 212 /** 213 * Return the port being used to connect with the LDAP server 214 * 215 * @return int 216 */ 217 public function getPort() 218 { 219 return $this->port; 220 } 221 222 /** 223 * Return the distinguished name being used as the base path for queries which do not provide one theirselves 224 * 225 * @return string 226 */ 227 public function getDn() 228 { 229 return $this->rootDn; 230 } 231 232 /** 233 * Return the root node for this connection 234 * 235 * @return Root 236 */ 237 public function root() 238 { 239 if ($this->root === null) { 240 $this->root = Root::forConnection($this); 241 } 242 243 return $this->root; 244 } 245 246 /** 247 * Return the LDAP link identifier being used 248 * 249 * Establishes a connection if necessary. 250 * 251 * @return resource 252 */ 253 public function getConnection() 254 { 255 if ($this->ds === null) { 256 $this->ds = $this->prepareNewConnection(); 257 } 258 259 return $this->ds; 260 } 261 262 /** 263 * Return the capabilities of the current connection 264 * 265 * @return LdapCapabilities 266 */ 267 public function getCapabilities() 268 { 269 if ($this->capabilities === null) { 270 try { 271 $this->capabilities = LdapCapabilities::discoverCapabilities($this); 272 $this->discoverySuccess = true; 273 $this->discoveryError = null; 274 } catch (LdapException $e) { 275 Logger::debug($e); 276 Logger::warning('LADP discovery failed, assuming default LDAP capabilities.'); 277 $this->capabilities = new LdapCapabilities(); // create empty default capabilities 278 $this->discoverySuccess = false; 279 $this->discoveryError = $e; 280 } 281 } 282 283 return $this->capabilities; 284 } 285 286 /** 287 * Return whether discovery was successful 288 * 289 * @return bool true if the capabilities were successfully determined, false if the capabilities were guessed 290 */ 291 public function discoverySuccessful() 292 { 293 if ($this->discoverySuccess === null) { 294 $this->getCapabilities(); // Initializes self::$discoverySuccess 295 } 296 297 return $this->discoverySuccess; 298 } 299 300 /** 301 * Get discovery error if any 302 * 303 * @return Exception|null 304 */ 305 public function getDiscoveryError() 306 { 307 return $this->discoveryError; 308 } 309 310 /** 311 * Return whether the current connection is encrypted 312 * 313 * @return bool 314 */ 315 public function isEncrypted() 316 { 317 if ($this->encrypted === null) { 318 return false; 319 } 320 321 return $this->encrypted; 322 } 323 324 /** 325 * Establish a connection 326 * 327 * @throws LdapException In case the connection could not be established 328 * 329 * @deprecated The connection is established lazily now 330 */ 331 public function connect() 332 { 333 $this->getConnection(); 334 } 335 336 /** 337 * Perform a LDAP bind on the current connection 338 * 339 * @throws LdapException In case the LDAP bind was unsuccessful or insecure 340 */ 341 public function bind() 342 { 343 if ($this->bound) { 344 return $this; 345 } 346 347 $ds = $this->getConnection(); 348 349 $success = @ldap_bind($ds, $this->bindDn, $this->bindPw); 350 if (! $success) { 351 throw new LdapException( 352 'LDAP bind (%s / %s) to %s failed: %s', 353 $this->bindDn, 354 '***' /* $this->bindPw */, 355 $this->normalizeHostname($this->hostname), 356 ldap_error($ds) 357 ); 358 } 359 360 $this->bound = true; 361 return $this; 362 } 363 364 /** 365 * Provide a query on this connection 366 * 367 * @return LdapQuery 368 */ 369 public function select() 370 { 371 return new LdapQuery($this); 372 } 373 374 /** 375 * Fetch and return all rows of the given query's result set using an iterator 376 * 377 * @param LdapQuery $query The query returning the result set 378 * 379 * @return ArrayIterator 380 */ 381 public function query(LdapQuery $query) 382 { 383 return new ArrayIterator($this->fetchAll($query)); 384 } 385 386 /** 387 * Count all rows of the given query's result set 388 * 389 * @param LdapQuery $query The query returning the result set 390 * 391 * @return int 392 */ 393 public function count(LdapQuery $query) 394 { 395 $this->bind(); 396 397 if (($unfoldAttribute = $query->getUnfoldAttribute()) !== null) { 398 $desiredColumns = $query->getColumns(); 399 if (isset($desiredColumns[$unfoldAttribute])) { 400 $fields = array($unfoldAttribute => $desiredColumns[$unfoldAttribute]); 401 } elseif (in_array($unfoldAttribute, $desiredColumns, true)) { 402 $fields = array($unfoldAttribute); 403 } else { 404 throw new ProgrammingError( 405 'The attribute used to unfold a query\'s result must be selected' 406 ); 407 } 408 409 $res = $this->runQuery($query, $fields); 410 return count($res); 411 } 412 413 $ds = $this->getConnection(); 414 $results = $this->ldapSearch($query, array('dn')); 415 416 if ($results === false) { 417 if (ldap_errno($ds) !== self::LDAP_NO_SUCH_OBJECT) { 418 throw new LdapException( 419 'LDAP count query "%s" (base %s) failed: %s', 420 (string) $query, 421 $query->getBase() ?: $this->getDn(), 422 ldap_error($ds) 423 ); 424 } 425 } 426 427 return ldap_count_entries($ds, $results); 428 } 429 430 /** 431 * Retrieve an array containing all rows of the result set 432 * 433 * @param LdapQuery $query The query returning the result set 434 * @param array $fields Request these attributes instead of the ones registered in the given query 435 * 436 * @return array 437 */ 438 public function fetchAll(LdapQuery $query, array $fields = null) 439 { 440 $this->bind(); 441 442 if ($query->getUsePagedResults() && $this->getCapabilities()->hasPagedResult()) { 443 return $this->runPagedQuery($query, $fields); 444 } else { 445 return $this->runQuery($query, $fields); 446 } 447 } 448 449 /** 450 * Fetch the first row of the result set 451 * 452 * @param LdapQuery $query The query returning the result set 453 * @param array $fields Request these attributes instead of the ones registered in the given query 454 * 455 * @return mixed 456 */ 457 public function fetchRow(LdapQuery $query, array $fields = null) 458 { 459 $clonedQuery = clone $query; 460 $clonedQuery->limit(1); 461 $clonedQuery->setUsePagedResults(false); 462 $results = $this->fetchAll($clonedQuery, $fields); 463 return array_shift($results) ?: false; 464 } 465 466 /** 467 * Fetch the first column of all rows of the result set as an array 468 * 469 * @param LdapQuery $query The query returning the result set 470 * @param array $fields Request these attributes instead of the ones registered in the given query 471 * 472 * @return array 473 * 474 * @throws ProgrammingError In case no attribute is being requested 475 */ 476 public function fetchColumn(LdapQuery $query, array $fields = null) 477 { 478 if ($fields === null) { 479 $fields = $query->getColumns(); 480 } 481 482 if (empty($fields)) { 483 throw new ProgrammingError('You must request at least one attribute when fetching a single column'); 484 } 485 486 $alias = key($fields); 487 $results = $this->fetchAll($query, array($alias => current($fields))); 488 $column = is_int($alias) ? current($fields) : $alias; 489 $values = array(); 490 foreach ($results as $row) { 491 if (isset($row->$column)) { 492 $values[] = $row->$column; 493 } 494 } 495 496 return $values; 497 } 498 499 /** 500 * Fetch the first column of the first row of the result set 501 * 502 * @param LdapQuery $query The query returning the result set 503 * @param array $fields Request these attributes instead of the ones registered in the given query 504 * 505 * @return string 506 */ 507 public function fetchOne(LdapQuery $query, array $fields = null) 508 { 509 $row = $this->fetchRow($query, $fields); 510 if ($row === false) { 511 return false; 512 } 513 514 $values = get_object_vars($row); 515 if (empty($values)) { 516 return false; 517 } 518 519 if ($fields === null) { 520 // Fetch the desired columns from the query if not explicitly overriden in the method's parameter 521 $fields = $query->getColumns(); 522 } 523 524 if (empty($fields)) { 525 // The desired columns may be empty independently whether provided by the query or the method's parameter 526 return array_shift($values); 527 } 528 529 $alias = key($fields); 530 return $values[is_string($alias) ? $alias : $fields[$alias]]; 531 } 532 533 /** 534 * Fetch all rows of the result set as an array of key-value pairs 535 * 536 * The first column is the key, the second column is the value. 537 * 538 * @param LdapQuery $query The query returning the result set 539 * @param array $fields Request these attributes instead of the ones registered in the given query 540 * 541 * @return array 542 * 543 * @throws ProgrammingError In case there are less than two attributes being requested 544 */ 545 public function fetchPairs(LdapQuery $query, array $fields = null) 546 { 547 if ($fields === null) { 548 $fields = $query->getColumns(); 549 } 550 551 if (count($fields) < 2) { 552 throw new ProgrammingError('You are required to request at least two attributes'); 553 } 554 555 $columns = $desiredColumnNames = array(); 556 foreach ($fields as $alias => $column) { 557 if (is_int($alias)) { 558 $columns[] = $column; 559 $desiredColumnNames[] = $column; 560 } else { 561 $columns[$alias] = $column; 562 $desiredColumnNames[] = $alias; 563 } 564 565 if (count($desiredColumnNames) === 2) { 566 break; 567 } 568 } 569 570 $results = $this->fetchAll($query, $columns); 571 $pairs = array(); 572 foreach ($results as $row) { 573 $colOne = $desiredColumnNames[0]; 574 $colTwo = $desiredColumnNames[1]; 575 $pairs[$row->$colOne] = $row->$colTwo; 576 } 577 578 return $pairs; 579 } 580 581 /** 582 * Fetch an LDAP entry by its DN 583 * 584 * @param string $dn 585 * @param array|null $fields 586 * 587 * @return StdClass|bool 588 */ 589 public function fetchByDn($dn, array $fields = null) 590 { 591 return $this->select() 592 ->from('*', $fields) 593 ->setBase($dn) 594 ->setScope('base') 595 ->fetchRow(); 596 } 597 598 /** 599 * Test the given LDAP credentials by establishing a connection and attempting a LDAP bind 600 * 601 * @param string $bindDn 602 * @param string $bindPw 603 * 604 * @return bool Whether the given credentials are valid 605 * 606 * @throws LdapException In case an error occured while establishing the connection or attempting the bind 607 */ 608 public function testCredentials($bindDn, $bindPw) 609 { 610 $ds = $this->getConnection(); 611 $success = @ldap_bind($ds, $bindDn, $bindPw); 612 if (! $success) { 613 if (ldap_errno($ds) === self::LDAP_INVALID_CREDENTIALS) { 614 Logger::debug( 615 'Testing LDAP credentials (%s / %s) failed: %s', 616 $bindDn, 617 '***', 618 ldap_error($ds) 619 ); 620 return false; 621 } 622 623 throw new LdapException(ldap_error($ds)); 624 } 625 626 return true; 627 } 628 629 /** 630 * Return whether an entry identified by the given distinguished name exists 631 * 632 * @param string $dn 633 * 634 * @return bool 635 */ 636 public function hasDn($dn) 637 { 638 $ds = $this->getConnection(); 639 $this->bind(); 640 641 $result = ldap_read($ds, $dn, '(objectClass=*)', array('objectClass')); 642 return ldap_count_entries($ds, $result) > 0; 643 } 644 645 /** 646 * Delete a root entry and all of its children identified by the given distinguished name 647 * 648 * @param string $dn 649 * 650 * @return bool 651 * 652 * @throws LdapException In case an error occured while deleting an entry 653 */ 654 public function deleteRecursively($dn) 655 { 656 $ds = $this->getConnection(); 657 $this->bind(); 658 659 $result = @ldap_list($ds, $dn, '(objectClass=*)', array('objectClass')); 660 if ($result === false) { 661 if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) { 662 return false; 663 } 664 665 throw new LdapException('LDAP list for "%s" failed: %s', $dn, ldap_error($ds)); 666 } 667 668 $children = ldap_get_entries($ds, $result); 669 for ($i = 0; $i < $children['count']; $i++) { 670 $result = $this->deleteRecursively($children[$i]['dn']); 671 if (! $result) { 672 // TODO: return result code, if delete fails 673 throw new LdapException('Recursively deleting "%s" failed', $dn); 674 } 675 } 676 677 return $this->deleteDn($dn); 678 } 679 680 /** 681 * Delete a single entry identified by the given distinguished name 682 * 683 * @param string $dn 684 * 685 * @return bool 686 * 687 * @throws LdapException In case an error occured while deleting the entry 688 */ 689 public function deleteDn($dn) 690 { 691 $ds = $this->getConnection(); 692 $this->bind(); 693 694 $result = @ldap_delete($ds, $dn); 695 if ($result === false) { 696 if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) { 697 return false; // TODO: Isn't it a success if something i'd like to remove is not existing at all??? 698 } 699 700 throw new LdapException('LDAP delete for "%s" failed: %s', $dn, ldap_error($ds)); 701 } 702 703 return true; 704 } 705 706 /** 707 * Fetch the distinguished name of the result of the given query 708 * 709 * @param LdapQuery $query The query returning the result set 710 * 711 * @return string The distinguished name, or false when the given query yields no results 712 * 713 * @throws LdapException In case the query yields multiple results 714 */ 715 public function fetchDn(LdapQuery $query) 716 { 717 $rows = $this->fetchAll($query, array()); 718 if (count($rows) > 1) { 719 throw new LdapException('Cannot fetch single DN for %s', $query); 720 } 721 722 return key($rows); 723 } 724 725 /** 726 * Run the given LDAP query and return the resulting entries 727 * 728 * @param LdapQuery $query The query to fetch results with 729 * @param array $fields Request these attributes instead of the ones registered in the given query 730 * 731 * @return array 732 * 733 * @throws LdapException In case an error occured while fetching the results 734 */ 735 protected function runQuery(LdapQuery $query, array $fields = null) 736 { 737 $limit = $query->getLimit(); 738 $offset = $query->hasOffset() ? $query->getOffset() : 0; 739 740 if ($fields === null) { 741 $fields = $query->getColumns(); 742 } 743 744 $ds = $this->getConnection(); 745 746 $serverSorting = ! $this->config->disable_server_side_sort 747 && $this->getCapabilities()->hasOid(LdapCapabilities::LDAP_SERVER_SORT_OID); 748 749 if ($query->hasOrder()) { 750 if ($serverSorting) { 751 ldap_set_option($ds, LDAP_OPT_SERVER_CONTROLS, array( 752 array( 753 'oid' => LdapCapabilities::LDAP_SERVER_SORT_OID, 754 'value' => $this->encodeSortRules($query->getOrder()) 755 ) 756 )); 757 } elseif (! empty($fields)) { 758 foreach ($query->getOrder() as $rule) { 759 if (! in_array($rule[0], $fields, true)) { 760 $fields[] = $rule[0]; 761 } 762 } 763 } 764 } 765 766 $unfoldAttribute = $query->getUnfoldAttribute(); 767 if ($unfoldAttribute) { 768 foreach ($query->getFilter()->listFilteredColumns() as $filterColumn) { 769 $fieldKey = array_search($filterColumn, $fields, true); 770 if ($fieldKey === false || is_string($fieldKey)) { 771 $fields[] = $filterColumn; 772 } 773 } 774 } 775 776 $results = $this->ldapSearch( 777 $query, 778 array_values($fields), 779 0, 780 ($serverSorting || ! $query->hasOrder()) && $limit ? $offset + $limit : 0 781 ); 782 if ($results === false) { 783 if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) { 784 return array(); 785 } 786 787 throw new LdapException( 788 'LDAP query "%s" (base %s) failed. Error: %s', 789 $query, 790 $query->getBase() ?: $this->rootDn, 791 ldap_error($ds) 792 ); 793 } elseif (ldap_count_entries($ds, $results) === 0) { 794 return array(); 795 } 796 797 $count = 0; 798 $entries = array(); 799 $entry = ldap_first_entry($ds, $results); 800 do { 801 if ($unfoldAttribute) { 802 $rows = $this->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields, $unfoldAttribute); 803 if (is_array($rows)) { 804 // TODO: Register the DN the same way as a section name in the ArrayDatasource! 805 foreach ($rows as $row) { 806 if ($query->getFilter()->matches($row)) { 807 $count += 1; 808 if (! $serverSorting || $offset === 0 || $offset < $count) { 809 $entries[] = $row; 810 } 811 812 if ($serverSorting && $limit > 0 && $limit === count($entries)) { 813 break; 814 } 815 } 816 } 817 } else { 818 $count += 1; 819 if (! $serverSorting || $offset === 0 || $offset < $count) { 820 $entries[ldap_get_dn($ds, $entry)] = $rows; 821 } 822 } 823 } else { 824 $count += 1; 825 if (! $serverSorting || $offset === 0 || $offset < $count) { 826 $entries[ldap_get_dn($ds, $entry)] = $this->cleanupAttributes( 827 ldap_get_attributes($ds, $entry), 828 $fields 829 ); 830 } 831 } 832 } while ((! $serverSorting || $limit === 0 || $limit !== count($entries)) 833 && ($entry = ldap_next_entry($ds, $entry)) 834 ); 835 836 if (! $serverSorting) { 837 if ($query->hasOrder()) { 838 uasort($entries, array($query, 'compare')); 839 } 840 841 if ($limit && $count > $limit) { 842 $entries = array_splice($entries, $query->hasOffset() ? $query->getOffset() : 0, $limit); 843 } 844 } 845 846 ldap_free_result($results); 847 return $entries; 848 } 849 850 /** 851 * Run the given LDAP query and return the resulting entries 852 * 853 * This utilizes paged search requests as defined in RFC 2696. 854 * 855 * @param LdapQuery $query The query to fetch results with 856 * @param array $fields Request these attributes instead of the ones registered in the given query 857 * @param int $pageSize The maximum page size, defaults to self::PAGE_SIZE 858 * 859 * @return array 860 * 861 * @throws LdapException In case an error occured while fetching the results 862 */ 863 protected function runPagedQuery(LdapQuery $query, array $fields = null, $pageSize = null) 864 { 865 if ($pageSize === null) { 866 $pageSize = static::PAGE_SIZE; 867 } 868 869 $limit = $query->getLimit(); 870 $offset = $query->hasOffset() ? $query->getOffset() : 0; 871 872 if ($fields === null) { 873 $fields = $query->getColumns(); 874 } 875 876 $ds = $this->getConnection(); 877 878 $serverSorting = false;//$this->getCapabilities()->hasOid(LdapCapabilities::LDAP_SERVER_SORT_OID); 879 if (! $serverSorting && $query->hasOrder() && ! empty($fields)) { 880 foreach ($query->getOrder() as $rule) { 881 if (! in_array($rule[0], $fields, true)) { 882 $fields[] = $rule[0]; 883 } 884 } 885 } 886 887 $unfoldAttribute = $query->getUnfoldAttribute(); 888 if ($unfoldAttribute) { 889 foreach ($query->getFilter()->listFilteredColumns() as $filterColumn) { 890 $fieldKey = array_search($filterColumn, $fields, true); 891 if ($fieldKey === false || is_string($fieldKey)) { 892 $fields[] = $filterColumn; 893 } 894 } 895 } 896 897 $legacyControlHandling = version_compare(PHP_VERSION, '7.3.0') < 0; 898 899 $count = 0; 900 $cookie = ''; 901 $entries = array(); 902 do { 903 if ($legacyControlHandling) { 904 // Do not request the pagination control as a critical extension, as we want the 905 // server to return results even if the paged search request cannot be satisfied 906 ldap_control_paged_result($ds, $pageSize, false, $cookie); 907 } 908 909 if ($serverSorting && $query->hasOrder()) { 910 ldap_set_option($ds, LDAP_OPT_SERVER_CONTROLS, array( 911 array( 912 'oid' => LdapCapabilities::LDAP_SERVER_SORT_OID, 913 'value' => $this->encodeSortRules($query->getOrder()) 914 ) 915 )); 916 } 917 918 $results = $this->ldapSearch( 919 $query, 920 array_values($fields), 921 0, 922 ($serverSorting || ! $query->hasOrder()) && $limit ? $offset + $limit : 0, 923 0, 924 LDAP_DEREF_NEVER, 925 $legacyControlHandling ? null : $pageSize 926 ); 927 if ($results === false) { 928 if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) { 929 break; 930 } 931 932 throw new LdapException( 933 'LDAP query "%s" (base %s) failed. Error: %s', 934 (string) $query, 935 $query->getBase() ?: $this->getDn(), 936 ldap_error($ds) 937 ); 938 } elseif (ldap_count_entries($ds, $results) === 0) { 939 if (in_array( 940 ldap_errno($ds), 941 array(static::LDAP_SIZELIMIT_EXCEEDED, static::LDAP_ADMINLIMIT_EXCEEDED), 942 true 943 )) { 944 Logger::warning( 945 'Unable to request more than %u results. Does the server allow paged search requests? (%s)', 946 $count, 947 ldap_error($ds) 948 ); 949 } 950 951 break; 952 } 953 954 $entry = ldap_first_entry($ds, $results); 955 do { 956 if ($unfoldAttribute) { 957 $rows = $this->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields, $unfoldAttribute); 958 if (is_array($rows)) { 959 // TODO: Register the DN the same way as a section name in the ArrayDatasource! 960 foreach ($rows as $row) { 961 if ($query->getFilter()->matches($row)) { 962 $count += 1; 963 if (! $serverSorting || $offset === 0 || $offset < $count) { 964 $entries[] = $row; 965 } 966 967 if ($serverSorting && $limit > 0 && $limit === count($entries)) { 968 break; 969 } 970 } 971 } 972 } else { 973 $count += 1; 974 if (! $serverSorting || $offset === 0 || $offset < $count) { 975 $entries[ldap_get_dn($ds, $entry)] = $rows; 976 } 977 } 978 } else { 979 $count += 1; 980 if (! $serverSorting || $offset === 0 || $offset < $count) { 981 $entries[ldap_get_dn($ds, $entry)] = $this->cleanupAttributes( 982 ldap_get_attributes($ds, $entry), 983 $fields 984 ); 985 } 986 } 987 } while ((! $serverSorting || $limit === 0 || $limit !== count($entries)) 988 && ($entry = ldap_next_entry($ds, $entry)) 989 ); 990 991 if ($legacyControlHandling && false === @ldap_control_paged_result_response($ds, $results, $cookie)) { 992 // If the page size is greater than or equal to the sizeLimit value, the server should ignore the 993 // control as the request can be satisfied in a single page: https://www.ietf.org/rfc/rfc2696.txt 994 // This applies no matter whether paged search requests are permitted or not. You're done once you 995 // got everything you were out for. 996 if ($serverSorting && count($entries) !== $limit) { 997 // The server does not support pagination, but still returned a response by ignoring the 998 // pagedResultsControl. We output a warning to indicate that the pagination control was ignored. 999 Logger::warning( 1000 'Unable to request paged LDAP results. Does the server allow paged search requests?' 1001 ); 1002 } 1003 } 1004 1005 ldap_free_result($results); 1006 } while ($cookie && (! $serverSorting || $limit === 0 || count($entries) < $limit)); 1007 1008 if ($legacyControlHandling && $cookie) { 1009 // A sequence of paged search requests is abandoned by the client sending a search request containing a 1010 // pagedResultsControl with the size set to zero (0) and the cookie set to the last cookie returned by 1011 // the server: https://www.ietf.org/rfc/rfc2696.txt 1012 ldap_control_paged_result($ds, 0, false, $cookie); 1013 // Returns no entries, due to the page size 1014 ldap_search($ds, $query->getBase() ?: $this->getDn(), (string) $query); 1015 } 1016 1017 if (! $serverSorting) { 1018 if ($query->hasOrder()) { 1019 uasort($entries, array($query, 'compare')); 1020 } 1021 1022 if ($limit && $count > $limit) { 1023 $entries = array_splice($entries, $query->hasOffset() ? $query->getOffset() : 0, $limit); 1024 } 1025 } 1026 1027 return $entries; 1028 } 1029 1030 /** 1031 * Clean up the given attributes and return them as simple object 1032 * 1033 * Applies column aliases, aggregates/unfolds multi-value attributes 1034 * as array and sets null for each missing attribute. 1035 * 1036 * @param array $attributes 1037 * @param array $requestedFields 1038 * @param string $unfoldAttribute 1039 * 1040 * @return object|array An array in case the object has been unfolded 1041 */ 1042 public function cleanupAttributes($attributes, array $requestedFields, $unfoldAttribute = null) 1043 { 1044 // In case the result contains attributes with a differing case than the requested fields, it is 1045 // necessary to create another array to map attributes case insensitively to their requested counterparts. 1046 // This does also apply the virtual alias handling. (Since an LDAP server does not handle such) 1047 $loweredFieldMap = array(); 1048 foreach ($requestedFields as $alias => $name) { 1049 $loweredName = strtolower($name); 1050 if (isset($loweredFieldMap[$loweredName])) { 1051 if (! is_array($loweredFieldMap[$loweredName])) { 1052 $loweredFieldMap[$loweredName] = array($loweredFieldMap[$loweredName]); 1053 } 1054 1055 $loweredFieldMap[$loweredName][] = is_string($alias) ? $alias : $name; 1056 } else { 1057 $loweredFieldMap[$loweredName] = is_string($alias) ? $alias : $name; 1058 } 1059 } 1060 1061 $cleanedAttributes = array(); 1062 for ($i = 0; $i < $attributes['count']; $i++) { 1063 $attribute_name = $attributes[$i]; 1064 if ($attributes[$attribute_name]['count'] === 1) { 1065 $attribute_value = $attributes[$attribute_name][0]; 1066 } else { 1067 $attribute_value = array(); 1068 for ($j = 0; $j < $attributes[$attribute_name]['count']; $j++) { 1069 $attribute_value[] = $attributes[$attribute_name][$j]; 1070 } 1071 } 1072 1073 $requestedAttributeName = isset($loweredFieldMap[strtolower($attribute_name)]) 1074 ? $loweredFieldMap[strtolower($attribute_name)] 1075 : $attribute_name; 1076 if (is_array($requestedAttributeName)) { 1077 foreach ($requestedAttributeName as $requestedName) { 1078 $cleanedAttributes[$requestedName] = $attribute_value; 1079 } 1080 } else { 1081 $cleanedAttributes[$requestedAttributeName] = $attribute_value; 1082 } 1083 } 1084 1085 // The result may not contain all requested fields, so populate the cleaned 1086 // result with the missing fields and their value being set to null 1087 foreach ($requestedFields as $alias => $name) { 1088 if (! is_string($alias)) { 1089 $alias = $name; 1090 } 1091 1092 if (! array_key_exists($alias, $cleanedAttributes)) { 1093 $cleanedAttributes[$alias] = null; 1094 Logger::debug('LDAP query result does not provide the requested field "%s"', $name); 1095 } 1096 } 1097 1098 if ($unfoldAttribute !== null 1099 && isset($cleanedAttributes[$unfoldAttribute]) 1100 && is_array($cleanedAttributes[$unfoldAttribute]) 1101 ) { 1102 $siblings = array(); 1103 foreach ($loweredFieldMap as $loweredName => $requestedNames) { 1104 if (is_array($requestedNames) && in_array($unfoldAttribute, $requestedNames, true)) { 1105 $siblings = array_diff($requestedNames, array($unfoldAttribute)); 1106 break; 1107 } 1108 } 1109 1110 $values = $cleanedAttributes[$unfoldAttribute]; 1111 unset($cleanedAttributes[$unfoldAttribute]); 1112 $baseRow = (object) $cleanedAttributes; 1113 $rows = array(); 1114 foreach ($values as $value) { 1115 $row = clone $baseRow; 1116 $row->{$unfoldAttribute} = $value; 1117 foreach ($siblings as $sibling) { 1118 $row->{$sibling} = $value; 1119 } 1120 1121 $rows[] = $row; 1122 } 1123 1124 return $rows; 1125 } 1126 1127 return (object) $cleanedAttributes; 1128 } 1129 1130 /** 1131 * Encode the given array of sort rules as ASN.1 octet stream according to RFC 2891 1132 * 1133 * @param array $sortRules 1134 * 1135 * @return string Binary representation of the octet stream 1136 */ 1137 protected function encodeSortRules(array $sortRules) 1138 { 1139 $sequenceOf = ''; 1140 1141 foreach ($sortRules as $rule) { 1142 if ($rule[1] === Sortable::SORT_DESC) { 1143 $reversed = '8101ff'; 1144 } else { 1145 $reversed = ''; 1146 } 1147 1148 $attributeType = unpack('H*', $rule[0]); 1149 $attributeType = $attributeType[1]; 1150 $attributeOctets = strlen($attributeType) / 2; 1151 if ($attributeOctets >= 127) { 1152 // Use the indefinite form of the length octets (the long form would be another option) 1153 $attributeType = '0440' . $attributeType . '0000'; 1154 } else { 1155 $attributeType = '04' . str_pad(dechex($attributeOctets), 2, '0', STR_PAD_LEFT) . $attributeType; 1156 } 1157 1158 $sequence = $attributeType . $reversed; 1159 $sequenceOctects = strlen($sequence) / 2; 1160 if ($sequenceOctects >= 127) { 1161 $sequence = '3040' . $sequence . '0000'; 1162 } else { 1163 $sequence = '30' . str_pad(dechex($sequenceOctects), 2, '0', STR_PAD_LEFT) . $sequence; 1164 } 1165 1166 $sequenceOf .= $sequence; 1167 } 1168 1169 $sequenceOfOctets = strlen($sequenceOf) / 2; 1170 if ($sequenceOfOctets >= 127) { 1171 $sequenceOf = '3040' . $sequenceOf . '0000'; 1172 } else { 1173 $sequenceOf = '30' . str_pad(dechex($sequenceOfOctets), 2, '0', STR_PAD_LEFT) . $sequenceOf; 1174 } 1175 1176 return hex2bin($sequenceOf); 1177 } 1178 1179 /** 1180 * Prepare and establish a connection with the LDAP server 1181 * 1182 * @param Inspection $info Optional inspection to fill with diagnostic info 1183 * 1184 * @return resource A LDAP link identifier 1185 * 1186 * @throws LdapException In case the connection is not possible 1187 */ 1188 protected function prepareNewConnection(Inspection $info = null) 1189 { 1190 if (! isset($info)) { 1191 $info = new Inspection(''); 1192 } 1193 1194 $hostname = $this->normalizeHostname($this->hostname); 1195 1196 $ds = ldap_connect($hostname, $this->port); 1197 1198 // Set a proper timeout for each connection 1199 ldap_set_option($ds, LDAP_OPT_NETWORK_TIMEOUT, $this->timeout); 1200 1201 // Usage of ldap_rename, setting LDAP_OPT_REFERRALS to 0 or using STARTTLS requires LDAPv3. 1202 // If this does not work we're probably not in a PHP 5.3+ environment as it is VERY 1203 // unlikely that the server complains about it by itself prior to a bind request 1204 ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3); 1205 1206 // Not setting this results in "Operations error" on AD when using the whole domain as search base 1207 ldap_set_option($ds, LDAP_OPT_REFERRALS, 0); 1208 1209 if ($this->encryption === static::LDAPS) { 1210 $info->write('Connect using LDAPS'); 1211 } elseif ($this->encryption === static::STARTTLS) { 1212 $this->encrypted = true; 1213 $info->write('Connect using STARTTLS'); 1214 if (! ldap_start_tls($ds)) { 1215 throw new LdapException('LDAP STARTTLS failed: %s', ldap_error($ds)); 1216 } 1217 } elseif ($this->encryption !== static::LDAPS) { 1218 $this->encrypted = false; 1219 $info->write('Connect without encryption'); 1220 } 1221 1222 return $ds; 1223 } 1224 1225 /** 1226 * Perform a LDAP search and return the result 1227 * 1228 * @param LdapQuery $query 1229 * @param array $attributes An array of the required attributes 1230 * @param int $attrsonly Should be set to 1 if only attribute types are wanted 1231 * @param int $sizelimit Enables you to limit the count of entries fetched 1232 * @param int $timelimit Sets the number of seconds how long is spend on the search 1233 * @param int $deref 1234 * @param int $pageSize The page size to request (Only supported with PHP v7.3+) 1235 * 1236 * @return resource|bool A search result identifier or false on error 1237 * 1238 * @throws LogicException If the LDAP query search scope is unsupported 1239 */ 1240 public function ldapSearch( 1241 LdapQuery $query, 1242 array $attributes = null, 1243 $attrsonly = 0, 1244 $sizelimit = 0, 1245 $timelimit = 0, 1246 $deref = LDAP_DEREF_NEVER, 1247 $pageSize = null 1248 ) { 1249 $queryString = (string) $query; 1250 $baseDn = $query->getBase() ?: $this->getDn(); 1251 $scope = $query->getScope(); 1252 1253 if (Logger::getInstance()->getLevel() === Logger::DEBUG) { 1254 // We're checking the level by ourselves to avoid rendering the ldapsearch commandline for nothing 1255 $starttlsParam = $this->encryption === static::STARTTLS ? ' -ZZ' : ''; 1256 1257 $bindParams = ''; 1258 if ($this->bound) { 1259 $bindParams = ' -D "' . $this->bindDn . '"' . ($this->bindPw ? ' -W' : ''); 1260 } 1261 1262 if ($deref === LDAP_DEREF_NEVER) { 1263 $derefName = 'never'; 1264 } elseif ($deref === LDAP_DEREF_ALWAYS) { 1265 $derefName = 'always'; 1266 } elseif ($deref === LDAP_DEREF_SEARCHING) { 1267 $derefName = 'search'; 1268 } else { // $deref === LDAP_DEREF_FINDING 1269 $derefName = 'find'; 1270 } 1271 1272 Logger::debug("Issuing LDAP search. Use '%s' to reproduce.", sprintf( 1273 'ldapsearch -P 3%s -H "%s"%s -b "%s" -s "%s" -z %u -l %u -a "%s"%s%s%s', 1274 $starttlsParam, 1275 $this->normalizeHostname($this->hostname), 1276 $bindParams, 1277 $baseDn, 1278 $scope, 1279 $sizelimit, 1280 $timelimit, 1281 $derefName, 1282 $attrsonly ? ' -A' : '', 1283 $queryString ? ' "' . $queryString . '"' : '', 1284 $attributes ? ' "' . join('" "', $attributes) . '"' : '' 1285 )); 1286 } 1287 1288 switch ($scope) { 1289 case LdapQuery::SCOPE_SUB: 1290 $function = 'ldap_search'; 1291 break; 1292 case LdapQuery::SCOPE_ONE: 1293 $function = 'ldap_list'; 1294 break; 1295 case LdapQuery::SCOPE_BASE: 1296 $function = 'ldap_read'; 1297 break; 1298 default: 1299 throw new LogicException('LDAP scope %s not supported by ldapSearch', $scope); 1300 } 1301 1302 if ($pageSize !== null) { 1303 $serverctrls[] = [ 1304 'oid' => LDAP_CONTROL_PAGEDRESULTS, 1305 // Do not request the pagination control as a critical extension, as we want the 1306 // server to return results even if the paged search request cannot be satisfied 1307 'iscritical' => false, 1308 'value' => [ 1309 'size' => $pageSize, 1310 'cookie' => '' 1311 ] 1312 ]; 1313 1314 return @$function( 1315 $this->getConnection(), 1316 $baseDn, 1317 $queryString, 1318 $attributes, 1319 $attrsonly, 1320 $sizelimit, 1321 $timelimit, 1322 $deref, 1323 $serverctrls 1324 ); 1325 } else { 1326 return @$function( 1327 $this->getConnection(), 1328 $baseDn, 1329 $queryString, 1330 $attributes, 1331 $attrsonly, 1332 $sizelimit, 1333 $timelimit, 1334 $deref 1335 ); 1336 } 1337 } 1338 1339 /** 1340 * Create an LDAP entry 1341 * 1342 * @param string $dn The distinguished name to use 1343 * @param array $attributes The entry's attributes 1344 * 1345 * @return bool Whether the operation was successful 1346 */ 1347 public function addEntry($dn, array $attributes) 1348 { 1349 return ldap_add($this->getConnection(), $dn, $attributes); 1350 } 1351 1352 /** 1353 * Modify an LDAP entry 1354 * 1355 * @param string $dn The distinguished name to use 1356 * @param array $attributes The attributes to update the entry with 1357 * 1358 * @return bool Whether the operation was successful 1359 */ 1360 public function modifyEntry($dn, array $attributes) 1361 { 1362 return ldap_modify($this->getConnection(), $dn, $attributes); 1363 } 1364 1365 /** 1366 * Change the distinguished name of an LDAP entry 1367 * 1368 * @param string $dn The entry's current distinguished name 1369 * @param string $newRdn The new relative distinguished name 1370 * @param string $newParentDn The new parent or superior entry's distinguished name 1371 * 1372 * @return resource The resulting search result identifier 1373 * 1374 * @throws LdapException In case an error occured 1375 */ 1376 public function moveEntry($dn, $newRdn, $newParentDn) 1377 { 1378 $ds = $this->getConnection(); 1379 $result = ldap_rename($ds, $dn, $newRdn, $newParentDn, false); 1380 if ($result === false) { 1381 throw new LdapException('Could not move entry "%s" to "%s": %s', $dn, $newRdn, ldap_error($ds)); 1382 } 1383 1384 return $result; 1385 } 1386 1387 /** 1388 * Return the LDAP specific configuration directory with the given relative path being appended 1389 * 1390 * @param string $sub 1391 * 1392 * @return string 1393 */ 1394 protected function getConfigDir($sub = null) 1395 { 1396 $dir = Config::$configDir . '/ldap'; 1397 if ($sub !== null) { 1398 $dir .= '/' . $sub; 1399 } 1400 1401 return $dir; 1402 } 1403 1404 /** 1405 * Render and return a valid LDAP filter representation of the given filter 1406 * 1407 * @param Filter $filter 1408 * @param int $level 1409 * 1410 * @return string 1411 */ 1412 public function renderFilter(Filter $filter, $level = 0) 1413 { 1414 if ($filter->isExpression()) { 1415 /** @var $filter FilterExpression */ 1416 return $this->renderFilterExpression($filter); 1417 } 1418 1419 /** @var $filter FilterChain */ 1420 $parts = array(); 1421 foreach ($filter->filters() as $filterPart) { 1422 $part = $this->renderFilter($filterPart, $level + 1); 1423 if ($part) { 1424 $parts[] = $part; 1425 } 1426 } 1427 1428 if (empty($parts)) { 1429 return ''; 1430 } 1431 1432 $format = '%1$s(%2$s)'; 1433 if (count($parts) === 1 && ! $filter instanceof FilterNot) { 1434 $format = '%2$s'; 1435 } 1436 if ($level === 0) { 1437 $format = '(' . $format . ')'; 1438 } 1439 1440 return sprintf($format, $filter->getOperatorSymbol(), implode(')(', $parts)); 1441 } 1442 1443 /** 1444 * Render and return a valid LDAP filter expression of the given filter 1445 * 1446 * @param FilterExpression $filter 1447 * 1448 * @return string 1449 */ 1450 protected function renderFilterExpression(FilterExpression $filter) 1451 { 1452 $column = $filter->getColumn(); 1453 $sign = $filter->getSign(); 1454 $expression = $filter->getExpression(); 1455 $format = '%1$s%2$s%3$s'; 1456 1457 if ($expression === null || $expression === true) { 1458 $expression = '*'; 1459 } elseif (is_array($expression)) { 1460 $seqFormat = '|(%s)'; 1461 if ($sign === '!=') { 1462 $seqFormat = '!(' . $seqFormat . ')'; 1463 $sign = '='; 1464 } 1465 1466 $seqParts = array(); 1467 foreach ($expression as $expressionValue) { 1468 $seqParts[] = sprintf( 1469 $format, 1470 LdapUtils::quoteForSearch($column), 1471 $sign, 1472 LdapUtils::quoteForSearch($expressionValue, true) 1473 ); 1474 } 1475 1476 return sprintf($seqFormat, implode(')(', $seqParts)); 1477 } 1478 1479 if ($sign === '!=') { 1480 $format = '!(%1$s=%3$s)'; 1481 } 1482 1483 return sprintf( 1484 $format, 1485 LdapUtils::quoteForSearch($column), 1486 $sign, 1487 LdapUtils::quoteForSearch($expression, true) 1488 ); 1489 } 1490 1491 /** 1492 * Inspect if this LDAP Connection is working as expected 1493 * 1494 * Check if connection, bind and encryption is working as expected and get additional 1495 * information about the used 1496 * 1497 * @return Inspection Inspection result 1498 */ 1499 public function inspect() 1500 { 1501 $insp = new Inspection('Ldap Connection'); 1502 1503 // Try to connect to the server with the given connection parameters 1504 try { 1505 $ds = $this->prepareNewConnection($insp); 1506 } catch (Exception $e) { 1507 if ($this->encryption === 'starttls') { 1508 // The Exception does not return any proper error messages in case of certificate errors. Connecting 1509 // by STARTTLS will usually fail at this point when the certificate is unknown, 1510 // so at least try to give some hints. 1511 $insp->write('NOTE: There might be an issue with the chosen encryption. Ensure that the LDAP-Server ' . 1512 'supports STARTTLS and that the LDAP-Client is configured to accept its certificate.'); 1513 } 1514 return $insp->error($e->getMessage()); 1515 } 1516 1517 // Try a bind-command with the given user credentials, this must not fail 1518 $success = @ldap_bind($ds, $this->bindDn, $this->bindPw); 1519 $msg = sprintf( 1520 'LDAP bind (%s / %s) to %s', 1521 $this->bindDn, 1522 '***' /* $this->bindPw */, 1523 $this->normalizeHostname($this->hostname) 1524 ); 1525 if (! $success) { 1526 // ldap_error does not return any proper error messages in case of certificate errors. Connecting 1527 // by LDAPS will usually fail at this point when the certificate is unknown, so at least try to give 1528 // some hints. 1529 if ($this->encryption === 'ldaps') { 1530 $insp->write('NOTE: There might be an issue with the chosen encryption. Ensure that the LDAP-Server ' . 1531 ' supports LDAPS and that the LDAP-Client is configured to accept its certificate.'); 1532 } 1533 return $insp->error(sprintf('%s failed: %s', $msg, ldap_error($ds))); 1534 } 1535 $insp->write(sprintf($msg . ' successful')); 1536 1537 // Try to execute a schema discovery this may fail if schema discovery is not supported 1538 try { 1539 $cap = LdapCapabilities::discoverCapabilities($this); 1540 $discovery = new Inspection('Discovery Results'); 1541 $vendor = $cap->getVendor(); 1542 if (isset($vendor)) { 1543 $discovery->write($vendor); 1544 } 1545 $version = $cap->getVersion(); 1546 if (isset($version)) { 1547 $discovery->write($version); 1548 } 1549 $discovery->write('Supports STARTTLS: ' . ($cap->hasStartTls() ? 'True' : 'False')); 1550 $discovery->write('Default naming context: ' . $cap->getDefaultNamingContext()); 1551 $insp->write($discovery); 1552 } catch (Exception $e) { 1553 $insp->write('Schema discovery not possible: ' . $e->getMessage()); 1554 } 1555 return $insp; 1556 } 1557 1558 protected function normalizeHostname($hostname) 1559 { 1560 $scheme = $this->encryption === static::LDAPS ? 'ldaps://' : 'ldap://'; 1561 $normalizeHostname = function ($hostname) use ($scheme) { 1562 if (strpos($hostname, $scheme) === false) { 1563 $hostname = $scheme . $hostname; 1564 } 1565 1566 if (! preg_match('/:\d+$/', $hostname)) { 1567 $hostname .= ':' . $this->port; 1568 } 1569 1570 return $hostname; 1571 }; 1572 1573 $ldapUrls = explode(' ', $hostname); 1574 if (count($ldapUrls) > 1) { 1575 foreach ($ldapUrls as & $uri) { 1576 $uri = $normalizeHostname($uri); 1577 } 1578 1579 $hostname = implode(' ', $ldapUrls); 1580 } else { 1581 $hostname = $normalizeHostname($hostname); 1582 } 1583 1584 return $hostname; 1585 } 1586} 1587