[ 'friendlyName' => 'Access Key Id', 'validators' => [ 'required' => 'required' ] ], 'secretAccessKey' => [ 'friendlyName' => 'Secret Access Key', 'type' => 'password', 'validators' => [ 'required' => 'required' ] ], 'region' => [ 'friendlyName' => 'Region' ], 'delegation_set' => [ 'friendlyName' => 'Delegation Set Id', 'validators' => [ ] ], 'soa_edit' => [ 'friendlyName' => 'Allow To Edit SOA Records', 'type' => 'yesno' ], 'delete_aws_ns' => [ 'friendlyName' => 'Delete AWS NS Records After Zone Creation', 'type' => 'yesno' ], 'use_white_label_nameservers' => [ 'friendlyName' => 'Using White Label Nameservers', 'type' => 'yesno', 'help' => 'Check this box if you are using White Label Nameservers' ], ]; public $availableTypes = ['A', 'AAAA', 'ALIAS', 'CNAME', 'MX', 'NAPTR', 'NS', 'PTR', 'SOA', 'SPF', 'SRV', 'TXT']; /** @var AWSRoute53API */ private $connection; public function testConnection() { if(!extension_loaded('SimpleXML')) { throw new DNSSubmoduleException('This server requires SimpleXML PHP extension', dns\SubmoduleExceptionCodes::CONNECTION_PROBLEM); } $this->loadConnectionInstance(); /** @var AWSRoute53ResponseInterface $zones */ $zones = $this->connection->testConnection(); if($zones->getResponseType() === 'error') { throw new DNSSubmoduleException($zones->getResponseMessage(), dns\SubmoduleExceptionCodes::CONNECTION_PROBLEM); } return true; } public function getNameServers( $index = false ) { if($this->config['use_white_label_nameservers'] === 'on') { return (array)parent::getNameServers(); } $this->loadConnectionInstance(); /** @var AWSRoute53ResponseInterface $zones */ $zones = $this->connection->listZonesByName($this->domain); if( $zones->getResponseType() === 'error' ) { throw new DNSSubmoduleException($zones->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } $zone = AwsRouteHelpers\AWSRoute53ResponseParseHelper::findZoneOnZoneList($zones->getParsedResponseBody(), $this->domain); if( !$zone ) { throw new DNSSubmoduleException('Zone name is not valid!', dns\SubmoduleExceptionCodes::COMMAND_ERROR); } $hostedZone = json_decode(json_encode($this->connection->getHostedZoneById($zone)->getParsedResponseBody()), true); return (array)$hostedZone['DelegationSet']['NameServers']['NameServer']; } public function getRecords($recordType = false) { $this->loadConnectionInstance(); $zone = $this->getZone(); /** @var AWSRoute53ResponseInterface $records */ $records = $this->connection->listRecords($zone); if($records->getResponseType() === 'error') { throw new DNSSubmoduleException($records->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } $availableTypes = $recordType ? [$recordType] : $this->availableTypes; $records = AwsRouteHelpers\AWSRoute53ResponseParseHelper::prepareRecordList($records->getParsedResponseBody(), $availableTypes, $this->config['soa_edit'] === 'on'); return array_map(function( dns\record\Record $record ) { return $record; }, $records); } public function addRecord(dns\record\Record $record) { $this->loadConnectionInstance(); $zone = $this->getZone(); $rrSets = $this->buildRRSets($this->getRecords()); //synchronization purposes - modifying record names to match AWS standards - shouldn't change workflow of Route53 module $record->name = $this->prepareNameForSynchro($record->name, $this->domain); if($record->type == 'TXT') { $this->explodeTxtRdata($record); } if( ($index = $this->findMatchingRecordSet($rrSets, $record)) !== false ) { /** @var dns\record\RRSet $rrSet */ $rrSet = $rrSets[$index]; $rrSet->setTtl($record->ttl); $rrSet->pushRecord($record); } else { $rrSet = new dns\record\RRSet($record->name, $record->type, $record->ttl, [$record]); } $response = $this->connection->updateRRSet($rrSet, $zone,$this->gatAliasTypeIfRequired($record)); if( $response->getResponseType() === 'error' ) { throw new DNSSubmoduleException($response->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } $this->resetConnection(); return true; } public function editRecord(dns\record\Record $record) { $this->loadConnectionInstance(); $record->name = IdnaHelper::idnaEncode($record->name); $zone = $this->getZone(); $rrSets = $this->buildRRSets($this->getRecords()); //Record RRSet based on line property if( ($recordToDeleteIndex = $this->findRecordInRecordSets($rrSets, $record)) !== false ) { //RRSet Changed if( $rrSets[$recordToDeleteIndex]->getName() !== $record->name ) { //RRSet exist so we just push if( ($recordToAddIntex = $this->findMatchingRecordSet($rrSets, $record)) !== false ) { $rrsetToAddRecord = $rrSets[$recordToAddIntex]; $rrsetToAddRecord->pushRecord($record); $rrsetToAddRecord->setTtl($record->ttl); } else { $rrsetToAddRecord = new dns\record\RRSet($record->name, $record->type, $record->ttl, [$record]); } $response = $this->connection->updateRRSet($rrsetToAddRecord,$zone,$this->gatAliasTypeIfRequired($record)); if($response->getResponseType() === 'error') { throw new DNSSubmoduleException($response->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } if( $rrSets[$recordToDeleteIndex]->countRecords() <= 1 ) { $response = $this->connection->removeRRSet($rrSets[$recordToDeleteIndex], $zone,$this->gatAliasTypeIfRequired($record)); } else { $rrSets[$recordToDeleteIndex]->removeRecord($record); $response = $this->connection->updateRRSet($rrSets[$recordToDeleteIndex], $zone,$this->gatAliasTypeIfRequired($record)); } } else { //RRSet Not Changed $rrSets[$recordToDeleteIndex]->replaceRecord($record); $rrSets[$recordToDeleteIndex]->setTtl($record->ttl); $response = $this->connection->updateRRSet($rrSets[$recordToDeleteIndex], $zone,$this->gatAliasTypeIfRequired($record)); } if($response->getResponseType() === 'error') { throw new DNSSubmoduleException($response->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } } else { throw new DNSSubmoduleException('Record Not Found on server', dns\SubmoduleExceptionCodes::INVALID_PARAMETERS); } $this->resetConnection(); return true; } public function deleteRecord(dns\record\Record $record) { $record->nameToAbsolute($this->domain); $record->name = IdnaHelper::idnaEncode($record->name); $this->loadConnectionInstance(); if(strpos($record->name, $this->domain) === false) { $record->name .= '.' . $this->domain; } $zone = $this->getZone(); $rrSets = $this->buildRRSets($this->getRecords()); if( ($index = $this->findMatchingRecordSet($rrSets, $record)) !== false ) { if( $rrSets[$index]->countRecords() <= 1 ) { $response = $this->connection->removeRRSet($rrSets[$index],$zone,$this->gatAliasTypeIfRequired($record)); } else { $rrSets[$index]->removeRecord($record); $response =$this->connection->updateRRSet($rrSets[$index],$zone,$this->gatAliasTypeIfRequired($record)); } if($response->getResponseType() === 'error') { throw new DNSSubmoduleException($response->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } } $this->resetConnection(); return true; } public function zoneExists() { $this->loadConnectionInstance(); /** @var AWSRoute53ResponseInterface $zones */ $zones = $this->connection->listZonesByName($this->domain); if($zones->getResponseType() === 'error') { throw new DNSSubmoduleException($zones->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } $zone = AwsRouteHelpers\AWSRoute53ResponseParseHelper::findZoneOnZoneList($zones->getParsedResponseBody(), $this->domain); return $zone ? true : false; } public function activateZone() { $this->loadConnectionInstance(); $this->log('ACTIVATE ZONE'); if($this->zoneExists()) { throw new DNSSubmoduleException('Domain name already exists!', dns\SubmoduleExceptionCodes::COMMAND_ERROR); } if(empty($this->domain)) { throw new DNSSubmoduleException('Domain name is not valid!', dns\SubmoduleExceptionCodes::INVALID_PARAMETERS); } /** @var AWSRoute53ResponseInterface $zone */ $zone = $this->connection->createZone($this->domain, $this->config['delegation_set']); if($zone->getResponseType() === 'error') { throw new DNSSubmoduleException($zone->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } $this->resetConnection(); return true; } public function terminateZone() { $records = $this->getRecords(); foreach($records as $record) { try { $this->deleteRecord($record); } catch(DNSSubmoduleException $exc) { } } $this->loadConnectionInstance(); /** @var AWSRoute53ResponseInterface $zones */ $zones = $this->connection->listZonesByName($this->domain); if($zones->getResponseType() === 'error') { throw new DNSSubmoduleException($zones->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } $zonesId = AwsRouteHelpers\AWSRoute53ResponseParseHelper::findZoneOnZoneList($zones->getParsedResponseBody(), $this->domain); if($zonesId === false) { throw new DNSSubmoduleException('Zone name is not valid!', dns\SubmoduleExceptionCodes::COMMAND_ERROR); } /** @var AWSRoute53ResponseInterface $deleted */ $deleted = $this->connection->deleteHostedZone($zonesId); if($deleted->getResponseType() === 'error') { throw new DNSSubmoduleException($deleted->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } $this->resetConnection(); return true; } public function getZones() { $zones = []; $client = \Aws\Route53\Route53Client::factory([ 'credentials' => [ 'key' => $this->config['accessKeyId'], 'secret' => $this->config['secretAccessKey'], ], 'version' => '2013-04-01', 'region' => $this->config['region'] ]); do { $params = ['MaxItems' => 100]; if( isset($result) ) { $params['Marker'] = $result->get('NextMarker'); } $result = $client->listHostedZones($params); $zones[] = array_column($result->get('HostedZones'), 'Name'); } while( $result->hasKey('NextMarker') ); $zones = array_merge(...$zones); return array_combine($zones, array_fill(0,count($zones),'')); } private function loadConnectionInstance() { if(!$this->connection) { $responseHandler = new AwsRouteHelpers\AWSRoute53Response(); $requestHandler = new AwsRouteHelpers\AWSRoute53Request( $responseHandler, $this->config['accessKeyId'], $this->config['secretAccessKey'], $this->config['region'] ); $apiHandler = new AwsRouteHelpers\AWSRoute53API($requestHandler); $this->connection = $apiHandler; } } public function updateRDNS($ip, $ttl = false, $value = false) { $revDnsZoneName = dns\utils\ReverseDNSHelper::reverseZoneName($ip); $zoneId = $this->getRevDNSZoneID($revDnsZoneName); if(!$zoneId) { $zoneId = $this->createRevDnsZone($revDnsZoneName); } $revRecord = dns\utils\ReverseDNSHelper::createPTRRecord($ip, $ttl, $value); $revRecord->name .= '.'.$revDnsZoneName; /** @var AWSRoute53ResponseInterface $aRecord */ $aRecord = $this->connection->updateRecord($zoneId, $revRecord); if($aRecord->getResponseType() === 'error') { throw new DNSSubmoduleException($aRecord->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } $this->resetConnection(); return true; } public function removeRDNS($ip) { $revDnsZoneName = dns\utils\ReverseDNSHelper::reverseZoneName($ip); $zoneId = $this->getRevDNSZoneID($revDnsZoneName); if(!$zoneId) { return true; } /** @var AWSRoute53ResponseInterface $records */ $records = $this->connection->listRecords($zoneId); if($records->getResponseType() === 'error') { throw new DNSSubmoduleException($records->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } $recordPrev = dns\utils\ReverseDNSHelper::reverseRecordName($ip); $recordName = $recordPrev.'.'.$revDnsZoneName; $recordToRemove = AwsRouteHelpers\AWSRoute53ResponseParseHelper::findPtrRecordByName($records->getParsedResponseBody(), $recordName); if(!$recordToRemove) { return true; } /** @var AWSRoute53ResponseInterface $aRecord */ $aRecord = $this->connection->deleteRecord($zoneId, $recordToRemove); if($aRecord->getResponseType() === 'error') { throw new DNSSubmoduleException($aRecord->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } $this->resetConnection(); return true; } public function getRDNSRecord($ip) { $revDnsZoneName = dns\utils\ReverseDNSHelper::reverseZoneName($ip); $zoneId = $this->getRevDNSZoneID($revDnsZoneName); if(!$zoneId) { return []; } /** @var AWSRoute53ResponseInterface $records */ $records = $this->connection->listRecords($zoneId); if($records->getResponseType() === 'error') { throw new DNSSubmoduleException($records->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } $this->resetConnection(); return AwsRouteHelpers\AWSRoute53ResponseParseHelper::prepareRecordList($records->getParsedResponseBody(), $this->availableTypes, $this->config['soa_edit'] === 'on'); } private function getRevDNSZoneID($zoneName) { $this->loadConnectionInstance(); /** @var AWSRoute53ResponseInterface $zones */ $zones = $this->connection->listZonesByName($zoneName); if($zones->getResponseType() === 'error') { throw new DNSSubmoduleException($zones->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } $zone = AwsRouteHelpers\AWSRoute53ResponseParseHelper::findZoneOnZoneList($zones->getParsedResponseBody(), $zoneName); return $zone ? : false; } private function createRevDnsZone($revDnsZoneName) { /** @var AWSRoute53ResponseInterface $zone */ $zone = $this->connection->createZone($revDnsZoneName); if($zone->getResponseType() === 'error') { throw new DNSSubmoduleException($zone->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } return AwsRouteHelpers\AWSRoute53ResponseParseHelper::getZoneIdFromCreateConfirmation($zone->getParsedResponseBody()); } private function gatAliasTypeIfRequired($record) { if($record->type !== 'ALIAS') { return false; } $dNamePos = stripos($record->name, trim($this->domain, '.')); if(!$dNamePos && $dNamePos !== 0) { $record->name .= '.'.$this->domain; } $deafultType = 'A'; $recordsList = $this->getRecords(); foreach($recordsList as $rec) { $trimedName = trim($rec->name, '.'); if(($rec->name === $record->rdata->target || $trimedName === $record->rdata->target ) && $rec->type === 'AAAA') { return 'AAAA'; } } return $deafultType; } public function convertInputFormData(&$input) { AwsRouteHelpers\AWSRoute53ResponseParseHelper::convertInputFormData($input); } /** * @param dns\record\Record $record * @return bool|dns\record\Record * @throws DNSSubmoduleException */ public function matchSetForRecord( dns\record\Record $record) { $cRecord = clone $record; $allowedTypes = ['MX', 'A', 'AAAA', 'NS']; if(!in_array($cRecord->type, $allowedTypes, true) ) { return false; } $basicRdata = $cRecord->rdata->toString(); $cRecord->createRDATAObject($cRecord->type); $cRecord->rdata = null; $recordList = $this->getRecords(); foreach($recordList as $rec) { if($cRecord->type === $rec->type && trim($cRecord->name, '.') === trim($rec->name, '.')) { if($rec->rdata->toString() === $basicRdata) { continue; } AwsRouteHelpers\AWSRoute53ResponseParseHelper::mergeHostsRecordsForRdata($cRecord, $rec, $cRecord->type); } } return $cRecord->rdata !== null ? $cRecord : false; } public function removeDefaultServerRecords($defaultModuleRecords) { if($this->config['delete_aws_ns'] !== 'on') { return false; } $cRecords = $this->getRecords(); foreach($cRecords as $cRecord) { if($cRecord->type !== 'NS') { continue; } $found = false; foreach($defaultModuleRecords as $dRecord) { if($this->areRecordsEqual($cRecord, $dRecord)) { $found = true; break; } } if($found === false) { $this->deleteRecord($cRecord); } } return true; } private function areRecordsEqual( dns\record\Record $rec1, dns\record\Record $rec2) { $recAname = rtrim($rec1->name,'.'); $recBname = rtrim($rec2->name,'.'); return $recAname === $recBname && $rec1->type === $rec2->type && $rec1->ttl === $rec2->ttl && $rec1->rdata->toString() === $rec2->rdata->toString(); } private function getZone() { /** @var AWSRoute53ResponseInterface $zones */ $zones = $this->connection->listZonesByName($this->domain); if( $zones->getResponseType() === 'error' ) { throw new DNSSubmoduleException($zones->getResponseMessage(), dns\SubmoduleExceptionCodes::COMMAND_ERROR); } $zone = AwsRouteHelpers\AWSRoute53ResponseParseHelper::findZoneOnZoneList($zones->getParsedResponseBody(), $this->domain); if( !$zone ) { throw new DNSSubmoduleException('Zone name is not valid!', dns\SubmoduleExceptionCodes::COMMAND_ERROR); } return $zone; } public function buildRRSets( array $records ): array { $rrsets = []; /** @var dns\record\Record $record */ foreach($records as $record) { if( ($index = $this->findMatchingRecordSet($rrsets, $record)) !== false ) { $rrsets[$index]->pushRecord($record); } else { $rrsets[] = new dns\record\RRSet($record->name, $record->type, $record->ttl, [$record]); } } return $rrsets; } public function findRecordInRecordSets($rrsets,dns\record\Record $record) { /** @var dns\record\RRSet $rrset */ foreach($rrsets as $index => $rrset) { if($rrset->recordExists($record)) { return $index; } } return false; } private function findMatchingRecordSet( array $rrsets, dns\record\Record $record ) { /** @var dns\record\RRSet $rrset */ foreach($rrsets as $index => $rrset) { if($rrset->isRecordMatchingRRset($record)) { return $index; } } return false; } private function resetConnection() { $this->connection = null; } private function explodeTxtRdata(&$record) { //AWS doesn't alllow TXT rdata record to have more than 255 chars length - need to split it to a few strings $rdataSplitted = str_split(trim($record->rdata->txtdata, '"'), 255); $record->rdata->txtdata = ''; foreach ($rdataSplitted as $rdataPart) { $record->rdata->txtdata .= '"' . $rdataPart . '"'; } } function prepareNameForSynchro($recordName, $domain) { $startsWithDot = substr($recordName, 0, 1) == '.'; $endsWithDot = substr($recordName, -1) == '.'; if ($startsWithDot) { $recordName = substr($recordName, 1); } if (!$endsWithDot) { $recordName = $recordName . (empty($recordName) ? '' : '.') . $domain . '.'; } return IdnaHelper::idnaEncode($recordName); } }