[ 'friendlyName' => 'Hostname/IP', 'validators' => [ 'required' => 'required', ] ], 'port' => [ 'friendlyName' => 'Default Port', 'value' => '8081' ], 'ssl' => [ 'friendlyName' => 'SSL', 'type' => 'yesno', ], 'apikey' => [ 'friendlyName' => 'API Key', 'validators' => [ 'required' => 'required', ] ], 'domain_type' => [ 'friendlyName' => 'Domain Type', 'type' => 'select', 'options' => ['MASTER' => 'MASTER', 'SLAVE' => 'SLAVE', 'NATIVE' => 'NATIVE'], ], 'notify_slaves' => [ 'friendlyName' => 'Notify Slaves', 'type' => 'yesno', ], 'zone_account' => [ 'friendlyName' => 'Zone Account', ], 'increment_soa_serial'=>[ 'friendlyName' => 'Manually Increment SOA Serial', 'type' => 'yesno', ], 'default_ip' => [ 'friendlyName' => 'Default IP', 'validators' => [ 'required' => 'required', 'pattern' => Patterns::IP4_OR_IP6, ] ], ]; public $availableTypes = ['MINFO', 'A', 'AAAA', 'NS', 'MX', 'CNAME', 'TXT', 'SRV', 'DNAME', 'SPF', 'PTR', 'ALIAS', 'AFSDB', 'HINFO', 'LOC', 'NAPTR', 'RP', 'CAA', 'SOA', 'DNSKEY', 'DS', 'RRSIG', 'URI', 'TLSA', 'SMIMEA', 'CERT', 'SSHFP', 'NSEC3PARAM', 'NSEC3', 'NSEC']; private $zoneID = false; private function get( $function, $params = [], $customRequest = null ) { $headers[] = 'X-API-Key: ' . $this->config['apikey']; $port = $this->config['port'] ? : 8081; $url = $this->config['ssl'] ? 'https' : 'http'; $url .= '://' . $this->config['hostname'] . ':' . $port . '/api/v1/' . $function; $this->log('REQUEST: ' . $url); $ch = curl_init(); $chOptions = [ CURLOPT_URL => trim($url), CURLOPT_RETURNTRANSFER => 1, CURLOPT_SSL_VERIFYPEER => 0, CURLOPT_SSL_VERIFYHOST => 0, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => $headers ]; if ( $params ) { $body = json_encode($params); $body = str_replace( [ ':{', '},', '}}', '}],{' ], [ ':[{', '}],', '}]}', '},{' ], $body); $chOptions[CURLOPT_POST] = 1; $chOptions[CURLOPT_POSTFIELDS] = $body; } if ( $customRequest ) { $chOptions[CURLOPT_CUSTOMREQUEST] = $customRequest; } curl_setopt_array($ch, $chOptions); $out = curl_exec($ch); $this->log('RESPONSE: ' . $out); if ( strpos($out, '"error"') !== false) { $out = json_decode($out); throw new exceptions\DNSSubmoduleException(str_replace('\032', ' ', $out->error), dns\SubmoduleExceptionCodes::INVALID_PARAMETERS); } if ( strpos($out, 'Unauthorized') !== false ) { throw new exceptions\DNSSubmoduleException('Incorrect API Key', dns\SubmoduleExceptionCodes::CONNECTION_PROBLEM); } if ( curl_errno($ch) ) { throw new exceptions\DNSSubmoduleException('cURL Error: ' . curl_errno($ch) . ' - ' . curl_error($ch), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } $info = curl_getinfo($ch); if($info['http_code'] >= 300) { throw new exceptions\DNSSubmoduleException($this->parseHttpCodeError($info['http_code']), $info['http_code']); } curl_close($ch); return $out; } private function parseHttpCodeError($code) { switch($code) { case 400: $error = 'Bad request to the API'; break; case 404: $error = 'API not enabled or invalid URL'; break; default: $error = 'The server returned '.$code.' error'; } return $error; } public function testConnection() { $out = $this->get('servers'); } private function getServerID() { $out = json_decode($this->get('servers')); return $out[0]->id; } public function getZoneID() { if ( $this->zoneID !== false ) { return $this->zoneID; } $serverId = $this->getServerID(); try { $zone = json_decode($this->get("servers/$serverId/zones/" . $this->domain, [])); } catch (exceptions\DNSSubmoduleException $e) { if($e->getCode() == 404) { throw new exceptions\DNSSubmoduleException('Zone does not exists', dns\SubmoduleExceptionCodes::INVALID_PARAMETERS); } } if ( isset($zone->id) ) { $this->zoneID = (string)$zone->id; return (string)$zone->id; } throw new exceptions\DNSSubmoduleException('Zone does not exists', dns\SubmoduleExceptionCodes::INVALID_PARAMETERS); } public function zoneExists() { try { $this->getZoneID(); return true; } catch (exceptions\DNSSubmoduleException $e) { if ( $e->getCode() === dns\SubmoduleExceptionCodes::INVALID_PARAMETERS ) { return false; } throw $e; } } public function getRecords( $recordType = false ) { $serverId = $this->getServerID(); $zoneId = $this->getZoneID(); $rrsets = json_decode($this->get("servers/$serverId/zones/$zoneId", []), false)->rrsets; $out = []; $rrsets = array_filter($rrsets, [self::class, 'filterBrokenRRSets']); /** @var dns\submodules\PowerDNSv4\RRSet $rrset */ foreach ( $rrsets as $rrset ) { $type = $rrset->type; $name = $rrset->name; $ttl = $rrset->ttl; $recordClass = 'MGModule\\DNSManager2\\mgLibs\\custom\\dns\\record\\type\\' . $type; $recordAdapterClass = 'MGModule\\DNSManager2\\mgLibs\\custom\\dns\\submodules\\PowerDNSv4\\Adapters\\' . $type . 'Adapter'; if ( ($recordType && $recordType !== $type) || !class_exists($recordClass) || !in_array($type, $this->availableTypes, true) ) continue; /** @var dns\submodules\PowerDNSv4\RecordFromServer $recordFromServer */ foreach ( $rrset->records as $recordId => $recordFromServer ) { if ( class_exists($recordAdapterClass) && method_exists($recordAdapterClass, 'createRdata') ) { /** @var dns\submodules\PowerDNSv4\Adapters\AbstractPowerDNSv4Adapter $record */ $record = new $recordAdapterClass(); $record->name = IdnaHelper::idnaEncode($name); $record->type = $type; $record->line = implode('|', [$name, $type, $recordId]); $record->ttl = $ttl; $record->class = 'IN'; $record->createRdata(IdnaHelper::idnaDecode($recordFromServer->content)); } else { $record = new Record(); $record->name = IdnaHelper::idnaEncode($name); $record->type = $type; $record->line = implode('|', [$name, $type, $recordId]); $record->ttl = $ttl; $record->class = 'IN'; $additionalProps = explode(' ', IdnaHelper::idnaDecode($recordFromServer->content)); $record->rdata = new $recordClass(); foreach ( array_keys(get_object_vars($record->rdata)) as $index => $additionalProperty ) { $record->rdata->$additionalProperty = $additionalProps[$index]; } } $out[] = $record; } } usort($out, static function ( $a, $b ) { /** @var Record $a */ /** @var Record $b */ $recordAllignment = ['SOA', 'A', 'AAAA', 'NS', 'MX', 'TXT']; if ( !in_array($a->type, $recordAllignment, true) && !in_array($b->type, $recordAllignment, true) ) return 0; if ( !in_array($a->type, $recordAllignment, true) ) return 1; if ( !in_array($b->type, $recordAllignment, true) ) return -1; if ( $a->type === $b->type ) { return $a->name < $b->name ? -1 : 1; } return array_search($a->type, $recordAllignment, true) < array_search($b->type, $recordAllignment, true) ? -1 : 1; }); return $out; } /** * Generates Valid RRSet format for one record ( usefull when we can use replace function ) * * @param Record $record * @param string $changetype * * @return array */ private function recordToParamsArray( dns\record\Record $record, $changetype = 'REPLACE' ) { return [ 'rrsets' => [ 'name' => $record->name, 'type' => $record->type, 'ttl' => $record->ttl, 'changetype' => $changetype, 'comments' => [], 'records' => [$this->formatRecordToPowerDnsFormat($record)] ] ]; } /** * @param Record $record * * @throws exceptions\DNSSubmoduleException */ public function addRecord( dns\record\Record $record ) { $serverId = $this->getServerID(); $zoneId = $this->getZoneID(); $records = $this->getRecords(); $record->name = IdnaHelper::idnaEncode($record->nameToAbsolute($this->domain)); $records[] = $record; if( $this->config['increment_soa_serial'] === 'on' ) { $records = $this->incrementSOASerial($records); } $params = $this->recordsToParamArray($records); $params = $this->replaceTargetRRSetTTL($params, $record); $this->get("servers/$serverId/zones/$zoneId", $params, 'PATCH'); if ( $this->isSigned() ) { $this->rectify(); } $this->checkAndRunNotifySlaves($serverId, $zoneId); } public function editRecord( dns\record\Record $record ) { $serverId = $this->getServerID(); $zoneId = $this->getZoneID(); $record->nameToAbsolute($this->domain); $record->name = IdnaHelper::idnaEncode($record->name); $records = $this->getRecords(); $recordsBeforeUpdate = $records; /** @var Record $records */ foreach ( $records as $index => $recordBeforeUpdate ) { if ( $recordBeforeUpdate->line === $record->line ) { $records[$index] = $record; break; } } if( $this->config['increment_soa_serial'] === 'on' ) { $records = $this->incrementSOASerial($records); } $params = $this->recordsToParamArray($records); $params = $this->replaceTargetRRSetTTL($params, $record); $errorType = null; try { $this->removeRRsets($recordsBeforeUpdate); } catch( exceptions\DNSSubmoduleException $e ) { $errorType = self::ERR_WHILE_REMOVING; } try { $this->get("servers/$serverId/zones/$zoneId", $params, 'PATCH'); } catch( exceptions\DNSSubmoduleException $e ) { $errorType = $errorType ? : self::ERR_WHILE_CREATING; $this->restoreOldRecords($recordsBeforeUpdate,$errorType); } if ( $this->isSigned() ) { $this->rectify(); } } public function deleteRecord( dns\record\Record $record ) { if( $record->type === 'SOA' ) throw new exceptions\DNSSubmoduleException('You are not allowed to delete SOA record'); $serverId = $this->getServerID(); $zoneId = $this->getZoneID(); $record->nameToAbsolute($this->domain); $record->name = IdnaHelper::idnaEncode($record->name); $records = $this->getRecords(); $recordsBeforeUpdate = $records; /** @var Record $records */ foreach ( $records as $index => $recordBeforeUpdate ) { if ( $recordBeforeUpdate->line === $record->line ) { unset($records[$index]); break; } } $params = $this->recordsToParamArray($records); $errorType = null; try { $this->removeRRsets($recordsBeforeUpdate); } catch( exceptions\DNSSubmoduleException $e ) { $errorType = self::ERR_WHILE_REMOVING; } try { $this->get("servers/$serverId/zones/$zoneId", $params, 'PATCH'); } catch( exceptions\DNSSubmoduleException $e ) { $errorType = $errorType ? : self::ERR_WHILE_CREATING; $this->restoreOldRecords($recordsBeforeUpdate, $errorType); } if ( $this->isSigned() ) { $this->rectify(); } $this->checkAndRunNotifySlaves($serverId, $zoneId); } public function activateZone( $dnsZoneName = false ) { $serverId = $this->getServerID(); $params = [ 'name' => rtrim($this->domain, '.') . '.', 'kind' => $this->config['domain_type'], 'nameservers' => [], ]; if ( !empty($this->config['zone_account']) ) { $params['account'] = $this->config['zone_account']; } if ( $dnsZoneName ) { $params['name'] = rtrim($dnsZoneName, '.') . '.'; } return $this->get("servers/$serverId/zones", $params); } public function terminateZone() { $serverId = $this->getServerID(); $zoneId = $this->getZoneID(); $this->get("servers/$serverId/zones/$zoneId", [], 'DELETE'); } public function getZones() { $serverId = $this->getServerID(); $ret = json_decode($this->get("servers/$serverId/zones", [])); $out = []; foreach ( $ret as $zone ) { $masters = null; if ( !empty($zone->masters) ) { $masters = implode(', ', $zone->masters); } $out[substr((string)$zone->name, 0, -1)] = $masters; } return $out; } public function updateRDNS( $ip, $ttl = false, $value = false ) { $revDnsZoneName = dns\utils\ReverseDNSHelper::reverseZoneName($ip); $zoneId = $this->getRevDNSZoneID($revDnsZoneName); $serverId = $this->getServerID(); if ( !$zoneId ) { $this->activateZone($revDnsZoneName); $zoneId = $this->getRevDNSZoneID($revDnsZoneName); } //We don't allow to replace name of the rdns record so we can simply use patch $revRecord = $this->createPowerDnsPTRRecord($ip, $ttl, $value, $revDnsZoneName); $this->get("servers/$serverId/zones/$zoneId", $this->recordToParamsArray($revRecord), 'PATCH'); $this->checkAndRunNotifySlaves($serverId, $zoneId); } private function createPowerDnsPTRRecord( $ip, $ttl, $value, $dnsZoneName ) { $record = new Record(); $record->name = $this->getLastPartOfIp($ip, true) . rtrim($dnsZoneName, '.') . '.'; $record->ttl = $ttl; $record->type = 'PTR'; $record->rdata = new dns\record\type\PTR(); $record->rdata->ptrdname = $value; return $record; } public function removeRDNS( $ip ) { $revDnsZoneName = dns\utils\ReverseDNSHelper::reverseZoneName($ip); $zoneId = $this->getRevDNSZoneID($revDnsZoneName); if ( !$zoneId ) { return false; } $record = $this->getRDNSRecord($ip); if ( $record === false ) { return false; } $this->deleteRecord($record); return true; } private function getLastPartOfIp( $ip, $withDot = false ) { $ipLastPart = dns\utils\ReverseDNSHelper::reverseRecordName($ip); return $withDot ? $ipLastPart . '.' : $ipLastPart; } public function getRDNSRecord( $ip ) { $clonedZone = $this->getClone($ip); $records = $clonedZone->getRecords('PTR'); $revZoneName = $this->getLastPartOfIp($ip, true) . rtrim(dns\utils\ReverseDNSHelper::reverseZoneName($ip), '.') . '.'; foreach ( $records as $record ) { if ( $record->name === $revZoneName ) return $record; } return false; } private function getRevDNSZoneID( $zoneName ) { $zoneName = rtrim($zoneName, '.') . '.'; $serverId = $this->getServerID(); $out = json_decode($this->get("servers/$serverId/zones", [])); foreach ( $out as $zone ) { if ( $zone->name === $zoneName ) { $this->zoneID = (string)$zone->id; return (string)$zone->id; } } return false; } private function checkAndRunNotifySlaves( $serverID = null, $zoneID = null ) { if ( $serverID !== null && $zoneID !== null && $this->config['notify_slaves'] === 'on' ) { $this->get('servers/' . $serverID . '/zones/' . $zoneID . '/notify', [], 'PUT'); } } //DNS SEC public function getSignKeys() { $dnssec = new \MGModule\DNSManager2\mgLibs\custom\dns\dnssec\DnsSec(); $serverId = $this->getServerID(); $zoneId = $this->getZoneID(); $ret = json_decode($this->get("servers/$serverId/zones/$zoneId/cryptokeys", [])); foreach ( $ret as $record ) { foreach ( $record->ds as $dsRecord ) { $ex = explode(' ', $dsRecord); $ds = new dns\record\type\DS(); $ds->setKeytag($ex[0]); $ds->setAlgorithm($ex[1]); $ds->setDigestType($ex[2]); $ds->setDigest($ex[3]); $dnssec->addDs($ds); } switch ( strtolower($record->keytype) ) { case 'csk': $dnsKeyEx = explode(' ', $record->dnskey); $dnskey = new dns\record\type\DNSKEY(); $dnskey->setFlags($dnsKeyEx[0]); $dnskey->setProtocol($dnsKeyEx[1]); $dnskey->setAlgorithm($dnsKeyEx[2]); $dnskey->setPublicKey($dnsKeyEx[3]); $zoneKey = new dns\dnssec\CSK(); $zoneKey->setId($record->id); $zoneKey->setBits($record->bits); $zoneKey->setDnsKey($dnskey); $dnssec->addKey($zoneKey); break; case 'ksk': case 'zsk': $dnsKeyEx = explode(' ', $record->dnskey); $dnskey = new dns\record\type\DNSKEY(); $dnskey->setFlags($dnsKeyEx[0]); $dnskey->setProtocol($dnsKeyEx[1]); $dnskey->setAlgorithm($dnsKeyEx[2]); $dnskey->setPublicKey($dnsKeyEx[3]); $dnskey2 = new dns\record\type\DNSKEY(); $dnskey2->setFlags($dnsKeyEx[0]); $dnskey2->setProtocol($dnsKeyEx[1]); $dnskey2->setAlgorithm($dnsKeyEx[2]); $ksk = new dns\dnssec\KSK(); $ksk->setId($record->id); $ksk->setBits($record->bits); $ksk->setDnsKey($dnskey); $zsk = new dns\dnssec\ZSK(); $zsk->setId($record->id); $zsk->setBits($record->bits); $zsk->setDnsKey($dnskey2); $dnssec->addKey($ksk); $dnssec->addKey($zsk); break; } } return $dnssec; } /** * Sign DNS zone */ public function sign() { $this->changeStatus(true); } /** * Unsign DNS zone */ public function unsign() { $this->changeStatus(false); } private function changeStatus( $status = true, $dnsZoneName = null ) { $serverId = $this->getServerID(); $zoneId = $this->getZoneID(); $params = [ 'dnssec' => (boolean)$status, ]; if ( $dnsZoneName ) { $params['name'] = rtrim($dnsZoneName, '.') . '.'; } $this->get("servers/$serverId/zones/$zoneId", $params, 'PUT'); } /** * Rectify DNS zone */ public function rectify() { $serverId = $this->getServerID(); $zoneId = $this->getZoneID(); $this->get("servers/$serverId/zones/$zoneId/rectify", [], 'PUT'); } /** * */ public function isSigned() { try { $serverId = $this->getServerID(); $zoneId = $this->getZoneID(); return json_decode($this->get("servers/$serverId/zones/$zoneId", []))->dnssec; } catch ( Exception $ex) { return false; } } /** * Creates rrsets structure from Records array * @param array $records * @param string $changeType * @return mixed */ private function recordsToParamArray( $records, $changeType = 'REPLACE') { $names = $this->getUniqueNames($records); $types = $this->getUniqueTypes($records); if( $changeType === 'DELETE' ) { $types = array_diff($types, ['SOA']); } $out = ['rrsets' => []]; foreach ( $names as $name ) { foreach ( $types as $type ) { $rrset = new RRSet(); $rrset->name = $name; $rrset->type = $type; $rrset->ttl = $this->getRRSetTTL($name, $type, $records); $rrset->changetype = $changeType; $rrset->comments = []; $rrset->records = $this->getRRsetForNameAndType($name, $type, $records); if ( $rrset->records ) { $out['rrsets'][] = $rrset; } } } return $out; } /** * @param array $records * @return array */ private function getUniqueNames( $records ) { $names = []; /** @var Record $record */ foreach ( $records as $record ) { $names[] = $record->name; } return array_unique($names); } /** * @param array $records * @return array */ private function getUniqueTypes( $records ) { $types = []; /** @var Record $record */ foreach ( $records as $record ) { $types[] = $record->type; } return array_unique($types); } /** * @param $name * @param $type * @param $records * @return array */ private function getRRsetForNameAndType( $name, $type, $records ) { $out = []; foreach ( $records as $record ) { if ( $record->name === $name && $record->type === $type ) { $out[] = $this->formatRecordToPowerDnsFormat($record); } } return $out; } /** * @param Record $record * @return array */ private function formatRecordToPowerDnsFormat( Record $record ) { $recordAdapterClass = 'MGModule\\DNSManager2\\mgLibs\\custom\\dns\\submodules\\PowerDNSv4\\Adapters\\' . $record->type . 'Adapter'; if ( class_exists($recordAdapterClass) && method_exists($recordAdapterClass, 'parseContentToApiFormat') ) { $content = (new $recordAdapterClass)->parseContentToApiFormat($record->rdata); } else { $content = str_replace("\t",' ',$record->rdata->toString()); } return ['content' => $content, 'disabled' => false]; } /** * Function removes all RRsets from server * It's easier when we remove all and then add all since we can't update single record cuz REPLACE duplicates record * @param $records * @return bool * @throws exceptions\DNSSubmoduleException */ private function removeRRsets( $records ) { $serverId = $this->getServerID(); $zoneId = $this->getZoneID(); $removeParams = $this->recordsToParamArray($records, 'DELETE'); $this->get("servers/$serverId/zones/$zoneId", $removeParams, 'PATCH'); if( count($this->getRecords()) ) { throw new exceptions\DNSSubmoduleException(self::ERR_WHILE_REMOVING); } return true; } /** * @param string $name * @param string $type * @param array $records * * @return int */ private function getRRSetTTL( $name, $type, array $records ) { foreach( $records as $record ) { if( $record->type === $type && $record->name === $name ) { return $record->ttl; } } return 3600; } /** * @param array $params * @param Record $record * * @return mixed */ private function replaceTargetRRSetTTL( $params, Record $record ) { /*** @var RRSet $rrset */ foreach( $params['rrsets'] as $index => $rrset ) { if( $rrset->name === $record->name && $rrset->type === $record->type ) { $params['rrsets'][$index]->ttl = $record->ttl ?: 3600; return $params; } } return $params; } /** * @param array $oldRecords * @param string $errorType * * @throws exceptions\DNSSubmoduleException */ private function restoreOldRecords( $oldRecords, $errorType = self::ERR_WHILE_CREATING ) { $serverId = $this->getServerID(); $zoneId = $this->getZoneID(); $errors = []; foreach( $oldRecords as $oldRecord ) { try { $this->get("servers/$serverId/zones/$zoneId", $this->recordToParamsArray($oldRecord), 'PATCH'); } catch( exceptions\DNSSubmoduleException $e ) { $errors[] = $e->getMessage(); } } if( count($errors) ) { $msg = $errorType === self::ERR_WHILE_REMOVING ? 'Zone corrupted contact administrator. Affected records: ' : 'Couldn\'t create records: '; throw new exceptions\DNSSubmoduleException($msg . implode('
', $errors)); } } /** * @param RRSet $rrset * * @return bool */ private function filterBrokenRRSets( $rrset ) { $domainToCheck = rtrim(IdnaHelper::idnaEncode($this->domain), ' .') . '.'; return (bool)preg_match('/' . preg_quote($domainToCheck, '/') . '$/', $rrset->name); } /** * @param array $records * * @return array */ public function incrementSOASerial( array $records ) { /** @var Record $record */ foreach( $records as &$record ) { if( $record->type === 'SOA' ) { $record->rdata->serial++; return $records; } } } }