[ 'friendlyName' => 'Hostname/IP', 'placeholder' => 'example.com', 'validators' => [ 'required' => 'required', ] ], 'username' => [ 'friendlyName' => 'Username', 'validators' => [ 'required' => 'required', ] ], 'password' => [ 'friendlyName' => 'User Password', 'type' => 'password', 'validators' => [ 'required' => 'required' ], ], 'default_ip' => [ 'friendlyName' => 'Default IP', 'placeholder' => '10.10.10.10', 'validators' => [ 'required' => 'required', 'pattern' => Patterns::IP4_OR_IP6, ] ], 'rsa' => [ 'friendlyName' => 'RSA Private Key', 'type' => 'textarea', ], 'ssl' => [ 'friendlyName' => 'Enable SSL', 'type' => 'yesno', ], 'pathtoconfig' => [ 'friendlyName' => 'Path To Bind9 Config Directory', 'placeholder' => '/etc/bind/', 'validators' => [ 'required' => 'required', ] ], 'pathtobackup' => [ 'friendlyName' => 'Path To Backup Directory', 'placeholder' => '/etc/bind/backups/', 'validators' => [ 'required' => 'required', ] ], 'rname' => [ 'friendlyName' => 'Admin Email (RNAME)', 'placeholder' => 'admin.example.com.', 'validators' => [ 'required' => 'required' ], ], 'refresh' => [ 'friendlyName' => 'Refresh', 'placeholder' => '900', 'validators' => [ 'required' => 'required' ], ], 'retry' => [ 'friendlyName' => 'Retry', 'placeholder' => '600', 'validators' => [ 'required' => 'required' ], ], 'expire' => [ 'friendlyName' => 'Expire', 'placeholder' => '86400', 'validators' => [ 'required' => 'required' ], ], 'notify_slaves' => [ 'friendlyName' => 'Notify Slaves', 'type' => 'yesno', ], 'master_ip' => [ 'friendlyName' => 'Master IP', 'placeholder' => '10.10.10.10', 'help' => 'Required when notify slaves are active' ], 'slaves' => [ 'friendlyName' => 'Slaves IP List', 'type' => 'textarea', 'placeholder' => '10.10.10.4, 10.10.10.5, 10.10.10.6' ], 'customSettings' => [ 'friendlyName' => 'Additional Settings', 'type' => 'textarea', 'help' => 'It will be placed in named.conf.local as plain text make sure your format is valid', 'placeholder' => " max-journal-size 50k;\n min-retry-time 100;" ] ]; public $availableTypes = ['A', 'AAAA', 'CAA', 'DS', 'HINFO', 'NS', 'MX', 'CNAME', 'DNAME', 'TXT', 'SRV', 'AFSDB', 'NAPTR', 'RP', 'SOA']; private $soa = []; /** * @var SSH2 */ private $ssh2; /** * @var SFTP */ private $sftp; /** * @var string */ private $bindConfigFile; /** * @var string */ private $zoneFilePath; /** * @var string */ private $zoneFile; /** * @var array */ private $records; /** * @var int */ private $ttl; /** * Test connection with Server * @throws DNSSubmoduleException if fails to connect */ public function testConnection() { if ( !$this->ssh2 ) $this->establishSSH2Connection(); if ( !$this->fileExists($this->parsePathToConfig()) ) { throw new DNSSubmoduleException('File: ' . $this->parsePathToConfig() . ' doesn\'t exists', SubmoduleExceptionCodes::CONNECTION_PROBLEM); } if ( !$this->directoryExists($this->parsePathToConfig(true) . 'zones') && !$this->createDirectory($this->parsePathToConfig(true) . 'zones') ) { throw new DNSSubmoduleException('This module requires writable directory: ' . $this->parsePathToConfig(true) . 'zones'); } if ( !$this->directoryExists($this->config['pathtobackup']) ) { throw new DNSSubmoduleException('Directory: ' . $this->config['pathtobackup'] . ' doesn\'t exists', SubmoduleExceptionCodes::CONNECTION_PROBLEM); } $tempFileName = $this->parsePathToConfig(true) . uniqid('dnsm2temp', false); if ( $this->upload($tempFileName, 'Lorem ipsum') ) { $this->removeFile($tempFileName); } else { throw new DNSSubmoduleException('Account you specified don\'t have read/write prmissions in bind directory', SubmoduleExceptionCodes::CONNECTION_PROBLEM); } return true; } /** * Gets records from server * @param bool $recordTypeFilter * @return array * @throws DNSSubmoduleException */ public function getRecords( $recordTypeFilter = false ) { $this->records = $this->records ? : $this->parseFileToStructure($recordTypeFilter); return $this->records; } /** * Adds a record to server * @param dns\record\Record $record * @return bool * @throws DNSSubmoduleException * @throws DNSSubmoduleHiddenException */ public function addRecord( dns\record\Record $record ) { //Since addRecord can be called in loop we need to download last good configuration every run //So when somenone will try to create wrong record the function will revert file one step back instead of to point when loop started $this->updateZoneVariables(); //We just add this to structure and parse it later so no need to do it multiple times since it will give same result if ( !$this->records ) { $this->records = $this->parseFileToStructure(); } if(($record->type === 'SOA') && ($soaRecordPositon = array_search('SOA', array_column($this->records, 'type'), true)) !== false) { $record->line = $this->records[$soaRecordPositon]->line; return $this->editRecord($record); } $record->name = $this->replaceNameToBind9Record($record->name); if ( !$this->validateName($record->name) ) { throw new DNSSubmoduleHiddenException('The Record ' . $record->name . ' name is invalid and can\'t be saved'); } //Before adding we should check if the record is valid $record->rdata->validate(); $this->records[] = $record; $this->backupConfig($this->bindConfigFile); $this->backupZone($this->zoneFile); $this->incrementSOASerial(); $this->upload($this->zoneFilePath, $this->parseStructureToFile()); $zoneValidation = $this->checkZoneChanges(); if ( $zoneValidation !== true ) { $this->upload($this->zoneFilePath, $this->zoneFile); throw new DNSSubmoduleHiddenException($record->__toString() .' -> '.$zoneValidation); } $this->reloadZone(); return false; } /** * Edits record by matching it with line * @param dns\record\Record $record * @return bool * @throws DNSSubmoduleException * @throws DNSSubmoduleHiddenException */ public function editRecord( dns\record\Record $record ) { if ( !$this->zoneFile ) { $this->updateZoneVariables(); } $record->name = $this->replaceNameToBind9Record($record->name); if ( !$this->validateName($record->name) ) { throw new DNSSubmoduleHiddenException('Invalid Record Name: ' . $record->name); } //Validate data sent by user $record->rdata->validate(); if ( !$this->records ) { $this->records = $this->parseFileToStructure(); } /** @var dns\record\Record $structRecord */ foreach ( $this->records as $index => $structRecord ) { if ( (int)$structRecord->line === (int)$record->line ) { $record->ttl = $record->ttl ? : 14400; $this->records[$index] = $record; } } $this->backupConfig($this->bindConfigFile); $this->backupZone($this->zoneFile); $this->incrementSOASerial(); if ( !$this->upload($this->zoneFilePath, $this->parseStructureToFile()) ) throw new DNSSubmoduleException('We were unable to edit record: ' . $record->type . ' in file: ' . $this->zoneFilePath); $zoneValidation = $this->checkZoneChanges(); if ( $zoneValidation !== true ) { $this->upload($this->zoneFilePath, $this->zoneFile); throw new DNSSubmoduleException($zoneValidation); } $this->reloadZone(); return true; } /** * Removes record by it's line * @param dns\record\Record $record * @throws DNSSubmoduleException */ public function deleteRecord( dns\record\Record $record ) { if ( !$this->zoneFilePath ) { $this->updateZoneVariables(); } if ( !$this->records ) { $this->records = $this->parseFileToStructure(); } /** @var dns\record\Record $structRecord */ foreach ( $this->records as $index => $structRecord ) { if ( (int)$structRecord->line === (int)$record->line ) { unset($this->records[$index]); } } $this->backupConfig($this->bindConfigFile); $this->backupZone($this->zoneFile); $this->incrementSOASerial(); $this->upload($this->zoneFilePath, $this->parseStructureToFile()); $this->reloadZone(); } /** * Checks if zone exists * @return bool * @throws DNSSubmoduleException */ public function zoneExists() { return array_key_exists($this->getDomainWithoutDot(), $this->getZones()); } public function activateZone() { $configFile = $this->downloadFile($this->parsePathToConfig()); $zoneFileName = $this->parsePathToConfig(true); $zoneFileName .= 'zones/db.' . $this->getDomainWithoutDot(); //Get template config file and parse $path = __DIR__ . '/bind9/sampleZoneMasterConfigFile.txt'; $zoneConfigString = file_get_contents($path); if ( $this->config['notify_slaves'] === 'on' && trim($this->config['slaves'])) { $notifyStatus = 'yes'; $slaveIpsArray = explode(',', $this->config['slaves']); $slaveIps = implode(';', $slaveIpsArray) . ((count($slaveIpsArray)) ? ';' : ''); } else { $notifyStatus = 'no'; $slaveIps = ''; } $zoneConfigString = str_replace( [ '{$domain}', '{$file}', '{$notify}', '{$slaveips}', '{$clientCustom}' ], [ $this->getDomainWithoutDot(), $zoneFileName, $notifyStatus, $slaveIps, $this->config['customSettings'] ], $zoneConfigString); $configFile .= $zoneConfigString; //Get template zone file and parse $path = __DIR__ . '/bind9/sampleZoneFile.txt'; $zoneFileString = file_get_contents($path); $ipReplacement = filter_var($this->ip) ? $this->ip : $this->config['default_ip']; $zoneFileString = str_replace( [ '{$domain}', '{$ttl}', '{$mname}', '{$rname}', '{$refresh}', '{$retry}', '{$expire}', '{$ip}' ], [ $this->getDomainReplacement(), 14400, $this->getDomainReplacement($this->server->getNameservers(1)->name), $this->config['rname'], $this->config['refresh'], $this->config['retry'], $this->config['expire'], $ipReplacement ], $zoneFileString); $this->backupConfig($configFile); $pathToConfig = $this->parsePathToConfig(); if ( !$this->upload($pathToConfig, $configFile) ) throw new DNSSubmoduleException('We couldn\'t create file: ' . $pathToConfig); if ( !$this->upload($zoneFileName, $zoneFileString) ) throw new DNSSubmoduleException('We couldn\'t create file: ' . $zoneFileName); $this->reloadBind9(); $this->synchronizeSlaves($this->getDomainWithoutDot()); } /** * @param string $domain * @param string $type * @return bool * @throws DNSSubmoduleException */ protected function synchronizeSlaves( $domain, $type = 'Activate' ) { if(!trim($this->config['slaves'])) return; $slaves = explode(',', $this->config['slaves']); foreach ( $slaves as $slaveIp ) { $slaveIp = trim($slaveIp); if(!filter_var($slaveIp,FILTER_VALIDATE_IP)) { continue; } $this->establishSFTPConnection(trim($slaveIp)); $this->establishSSH2Connection(trim($slaveIp)); $configFile = $this->downloadFile($this->parsePathToConfig()); $zoneFileName = 'db.' . $domain; if ( $type === 'Activate' ) { $path = __DIR__ . '/bind9/sampleZoneSlaveConfigFile.txt'; $zoneConfigString = file_get_contents($path); $zoneConfigString = str_replace( [ '{$domain}', '{$masterIp}', '{$file}' ], [ $domain, $this->config['master_ip'], $zoneFileName ], $zoneConfigString); $configFile .= $zoneConfigString; $this->upload($this->parsePathToConfig(), $configFile); } elseif ( $type === 'Terminate' ) { $regexDomain = '/zone\s+"' . preg_quote($domain, '/') . '\.*"\s+.+?\n};/msi'; $configFile = preg_replace($regexDomain, '', $configFile, 1); $this->upload($this->parsePathToConfig(), $configFile); } $this->reloadBind9(); } $this->sftp = null; $this->ssh2 = null; return true; } /** * Removes zone * @return bool * @throws DNSSubmoduleException */ public function terminateZone() { $this->updateZoneVariables(); if ( !$this->zoneFilePath ) { throw new DNSSubmoduleException('Missing file property in bind9 zone config file'); } //Remove from main file $regexDomain = '/zone\s+"' . preg_quote($this->getDomainWithoutDot(), '/') . '\.*"\s+.+?\n};/msi'; $configFile = preg_replace($regexDomain, '', $this->bindConfigFile, 1); $this->backupConfig($configFile); $this->backupZone($this->zoneFile); //Remove zone file if ( !$this->removeFile($this->zoneFilePath) ) { throw new DNSSubmoduleException('Can\'t delete zone file wrong file or no permissions'); } //Remove zone from config $pathToConfig = $this->parsePathToConfig(); if ( !$this->upload($pathToConfig, $configFile) ) throw new DNSSubmoduleException('We couldn\'t remove zone from config file in path: ' . $pathToConfig); $this->reloadZone(); return $this->synchronizeSlaves($this->getDomainWithoutDot(), 'Terminate'); } /** * Gets Zones * @return array * @throws DNSSubmoduleException */ public function getZones() { $zoneFile = $this->downloadFile($this->parsePathToConfig()); $values = explode("\n", $zoneFile); $out = []; foreach ( $values as $nol => $value ) { if ( preg_match('/in-addr\.arpa/', $value) ) { continue; } if ( !preg_match('/zone\s"(.+)"/', $value, $domain) ) { continue; } if ( !filter_var($domain[1], FILTER_VALIDATE_DOMAIN) ) { continue; } $domain[1] = rtrim($domain[1], '.'); $out[$domain[1]] = ''; } return $out; } /** * Uploads string to file specified in first parameter to server * * @param string $fileLoc Location of the remote file * @param string $stringFile String with \n sepearation to be placed in file * * @return bool * @throws DNSSubmoduleException if can't connect to host */ private function upload( $fileLoc, $stringFile ) { if ( !$this->sftp ) $this->establishSFTPConnection(); return $this->sftp->put($fileLoc, $stringFile, SFTP::SOURCE_STRING); } /** * Reloads the zone on remote * @return boolean * @throws DNSSubmoduleException */ private function reloadZone() { if ( !$this->ssh2 ) $this->establishSSH2Connection(); return $this->ssh2->exec('rndc reload ' . $this->getDomainWithoutDot()); } /** * Reloads bind 9 (required in zone activation) * @return string * @throws DNSSubmoduleException */ private function reloadBind9() { if ( !$this->ssh2 ) $this->establishSSH2Connection(); return $this->ssh2->exec('systemctl reload bind9'); } /** * Checks if file exists on remote * @param string $file file * @return bool * @throws DNSSubmoduleException if couldn't connect */ private function fileExists( $file ) { if ( !$this->sftp ) $this->establishSFTPConnection(); return ($this->sftp->get($file) !== false); } /** * Removes file from remote * @param string $fileLocation Location of the file to be removed * * @return bool * @throws DNSSubmoduleException when he can't find file or have no permissions */ private function removeFile( $fileLocation ) { if ( !$this->sftp ) $this->establishSFTPConnection(); return $this->sftp->delete($fileLocation); } /** * Gets file from server * @param string $file downlaods file from remote * @return mixed * @throws DNSSubmoduleException */ private function downloadFile( $file ) { if ( !$this->sftp ) $this->establishSFTPConnection(); return $this->sftp->get($file); } /** * Checks if file exists on remote * @param string $dir absolute dir * @return bool * @throws DNSSubmoduleException */ private function directoryExists( $dir ) { if ( !$this->sftp ) $this->establishSFTPConnection(); return $this->sftp->chdir($dir); } /** * Creates Directory * @param string $dir * @return bool depending on result * @throws DNSSubmoduleException if couldn't connect to remote */ private function createDirectory( $dir ) { if ( !$this->sftp ) $this->establishSFTPConnection(); return $this->sftp->mkdir($dir); } /** * Backups the zone file to backup/zones/ directory * @param $data * @return bool * @throws DNSSubmoduleException */ private function backupZone( $data ) { if ( !$this->sftp ) $this->establishSFTPConnection(); $backupDir = rtrim($this->config['pathtobackup'], '/') . '/zones/'; if ( !$this->directoryExists($backupDir) ) { $this->createDirectory($backupDir); } $domain = str_replace('.', '', $this->getDomainWithoutDot()); $backupDir .= $domain . '/'; if ( !$this->directoryExists($backupDir) ) { $this->createDirectory($backupDir); } $filename = date('YmdHis') . '.txt'; return $this->upload($backupDir . $filename, $data); } /** * Backups the config file to backup/config * @param $data * @return bool * @throws DNSSubmoduleException */ private function backupConfig( $data ) { if ( !$this->sftp ) $this->establishSFTPConnection(); $backupDir = rtrim($this->config['pathtobackup'], '/') . '/config/'; if ( !$this->directoryExists($backupDir) && !$this->createDirectory($backupDir) ) { throw new DNSSubmoduleException('We couldn\'t create diretory for backup config'); } $filename = date('YmdHis') . '.txt'; return $this->upload($backupDir . $filename, $data); } /** * Parses the config path given by user to valid one * @param bool $dir Specifies should function return the dir name or file name * @return string */ public function parsePathToConfig( $dir = false ) { $path = $this->config['pathtoconfig']; if ( $dir ) return rtrim(str_replace('named.conf.local', '', $path), '/') . '/'; //If there is no slash at the end and it's not config file we add slash if ( substr($path, -1, 1) !== '/' && strpos($path, 'named.conf.local') === false ) { $path .= '/'; } //If there is no name of config at the end we add the default if ( strpos($path, 'named.conf.local') === false ) { $path .= 'named.conf.local'; } return $path; } /** * Adds to array of soa properties property which will be later used for creating record * * @param mixed $soaProp Property to be addded * @throws DNSSubmoduleException if there is to many elements in array */ private function addSOAInformation( $soaProp ) { $this->soa[] = $soaProp; if ( count($this->soa) > 12 ) { throw new DNSSubmoduleException('There is problably missing ")" in your SOA record definition'); } } /** * Creates SOA record from properties previously addded to $this->soa * * @param int|string $defTtl Default ttl of zone if not specified soa ttl will be set to 14400 * * @return dns\record\Record * @throws DNSSubmoduleException */ public function createSOAFromArray( $defTtl = 14400 ) { $recordTypeIndex = $this->getIndexOfRecordType($this->soa, 'SOA'); $record = new dns\record\Record(); $record->name = $this->soa[0]; $record->type = 'SOA'; $ttlAndClass = $this->getTTLAndClassFromValues($recordTypeIndex, $this->soa, $defTtl); $record->ttl = $ttlAndClass['ttl']; $record->class = $ttlAndClass['class']; //Make sure we have name|class|ttl to ommit later checking for it if ( $recordTypeIndex === 2 ) { array_splice($this->soa, 2, 1, [$record->class, $record->ttl]); } $soaRecord = new dns\record\type\SOA(); $soaRecord->mname = $this->soa[4]; $soaRecord->rname = $this->soa[5]; $soaRecord->serial = TimeUnitsAliassesHelper::convertToSeconds($this->soa[6]); $soaRecord->refresh = TimeUnitsAliassesHelper::convertToSeconds($this->soa[7]); $soaRecord->retry = TimeUnitsAliassesHelper::convertToSeconds($this->soa[8]); $soaRecord->expire = TimeUnitsAliassesHelper::convertToSeconds($this->soa[9]); $soaRecord->minimum = TimeUnitsAliassesHelper::convertToSeconds($this->soa[10]); $record->rdata = $soaRecord; $record->customData = $record->rdlength = ''; $record->line = $this->soa['line']; return $record; } /** * Gets location of zone file from main config of bind * @param string $bindConfigFile bindConfigFile in string downloaded from bind server * @param string $zoneName Name of zone to search * * @return string * @throws DNSSubmoduleException if can't find zone or when there is no path specified */ private function getZoneFileLocationFrombindConfigFile( $bindConfigFile, $zoneName ) { $preg = '/zone\s+"' . preg_quote($zoneName, '/') . '\.*".+?\n};/msi'; if ( !preg_match($preg, $bindConfigFile, $matches) ) { throw new DNSSubmoduleException('We couldn\'t find ' . $zoneName . ' in your bindconfig file'); } if ( !preg_match('/file\s"(.+?)"/mi', $matches[0], $zonePath) ) { throw new DNSSubmoduleException('We couldn\'t find path in your ' . $zoneName . ' config section in your bind config file'); } return $zonePath[1]; } /** * Convert Time aliasses to normal int seconds * @param $line * * @return string|string[]|null * @throws Exception */ private function convertTime( $line ) { $values = preg_split('/\s+/', $line); foreach ( $values as &$value ) { if ( preg_match('/(\d+)([MHDW])/i', $value, $match) ) { $replacement = TimeUnitsAliassesHelper::convertToSeconds($match[0]); $value = preg_replace('/(\d+)([MHDW])/i', $replacement, $value); } } return implode(' ', $values); } /** * Replaces @ and whitespaces to domain names with dot attached * @param string $line * @return string */ private function replaceName( $line ) { //Explode line to array $values = preg_split('/\s+/', $line); //Record name is blank/whitespace/@ so we remove place there zone name if ( $values[0] === '@' || !trim($values[0]) ) { $values[0] = $this->getDomainReplacement(); $line = implode(' ', $values); } return $line; } /** * Creates record name with @ if record name matches domain and trims domain from the end * @param string $name * @return string */ private function replaceNameToBind9Record( $name ) { $name = trim(preg_replace('/\.?' . preg_quote(IdnaHelper::idnaDecode($this->getDomainWithoutDot())) . '\.+$/', '', $name, 1)); return $name === '' ? '@' : $name; } /** * Filters the records by their type * @param array $lines * @param string $recordTypeFilter * @return array */ private function filterRecords( $lines, $recordTypeFilter ) { foreach ( $lines as $nol => $line ) { //Explode line to array $values = preg_split('/\s+/', $line); //Get record type from array $recordType = $this->getRecordType($values); //If filter is set and record doesn't match we remove it if ( $recordType !== $recordTypeFilter ) { unset($lines[$nol]); continue; } } return $lines; } /** * Removes everything in line after ";" * @param string $line * @return false|string */ private function removeComments( $line ) { return (($index = strpos($line, ';')) !== false) ? substr($line, 0, $index) : $line; } /** * Returns the index of type property in record * @param $values * @param $recordType * @return false|int|string */ private function getIndexOfRecordType( $values, $recordType ) { return array_search($recordType, $values, false); } /** Returns record type * @param array $values exploded line by \s * @return bool */ private function getRecordType( $values ) { foreach ( $values as $value ) { if ( in_array($value, $this->availableTypes, false) ) { return $value; } } return false; } /** * Function looks for $TTL in file * @param array $values exploded zone file by "\n" * @return int|null */ private function findDefaultTtl( array $values ) { $ttl = null; foreach ( $values as $line ) { if ( !preg_match('/\$TTL\s+(\d+)/', $line, $match) ) { continue; } $ttl = (int)$match[1]; break; } return $ttl; } /** Gets SOA record from zone file * @param array $lines * @param int $ttl * @return dns\record\Record|null * @throws DNSSubmoduleException */ private function getSoaRecord( array $lines, $ttl = 14400 ) { $out = null; foreach ( $lines as $nol => $line ) { $values = preg_split('/\s+/', $line); $recordType = $this->getRecordType($values); if ( $recordType === 'SOA' ) { //Check if SOA is multiline $multilineSoa = strpos($line, '(') !== false && strpos($line, ')') === false; $this->soa['line'] = (int)$nol; break; } } if ( !isset($this->soa['line']) ) return $out; if ( isset($multilineSoa) ) { $lines[$this->soa['line']] = $this->replaceName($lines[$this->soa['line']]); if ( $multilineSoa ) { //We don't know length of SOA so we go from it's beggining to the end of the file foreach ( range($this->soa['line'], count($lines) - 1) as $i ) { //Spliting by whitespace $values = preg_split('/\s+/', $lines[$i]); //We add all values from line if for some reason someone specified more than one foreach ( $values as $soaProp ) { if ( ($soaProp === '(') || !trim($soaProp) ) { continue; } if ( $soaProp !== ')' ) { $this->addSOAInformation($soaProp); } else { $out = $this->createSOAFromArray($ttl); } } //We found end of the soa we can leave if ( strpos($lines[$i], ')') !== false ) { break; } } } else { //Spliting by whitespace $values = preg_split('/\s+/', $lines[$this->soa['line']]); //We add all values from line foreach ( $values as $soaProp ) { if ( $soaProp !== '(' && $soaProp !== ')' && $soaProp && $soaProp !== ' ' ) { $this->addSOAInformation($soaProp); } } $out = $this->createSOAFromArray($ttl); } } return $out; } /** Removes comments, empty lines,$ORIGIN and $TTL lines and converts time aliases to seconds * @param array $lines * @return array * @throws Exception when can't convert time */ private function setAndRemoveUsefullValues( $lines ) { foreach ( $lines as $nol => $line ) { if ( !trim($line) ) { unset($lines[$nol]); continue; } $lines[$nol] = $this->removeComments($line); if ( strpos($line, '$ORIGIN') !== false ) { unset($lines[$nol]); continue; } if ( preg_match('/\$TTL\s+(\d+)/', $line, $match) ) { $this->ttl = (int)$match[1]; unset($lines[$nol]); continue; } //All time aliasses to seconds $lines[$nol] = $this->convertTime($lines[$nol]); } return $lines; } /** * Generate the bind9 file from structure * @return string */ private function parseStructureToFile() { $recordsArray = [ '$ORIGIN ' . $this->getDomainReplacement(), '$TTL ' . $this->ttl ]; usort($this->records, static function ( $a, $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; }); /** @var dns\record\Record $record */ foreach ( $this->records as $record ) { $record->name = $this->replaceNameToBind9Record($record->name); $recordArray = [ $record->name, $record->class, $record->ttl, $record->type ]; $recordsArray[] = implode(' ', $recordArray) . ' ' . $record->rdata->toString(); } return implode("\n", $recordsArray) . "\n"; } /** * Removes soa from file checking for it beeing multiline * * @param array $lines file sp * @param int $offset the location of soa in lines * @return array */ private function removeSoaFromLine( array $lines, $offset = 0 ) { $multilineSoa = (strpos($lines[$offset], '(') !== false) && (strpos($lines[$offset], ')') === false); if ( $multilineSoa ) { //Removes lines until finds closing round bracket foreach ( range($offset, count($lines) - 1) as $i ) { $templn = $lines[$i]; unset($lines[$i]); if ( strpos($templn, ')') !== false ) { break; } } } else { unset($lines[$offset]); } return $lines; } /** * Function checks if there is $ORIGIN in file and matches zone name * Not used cause of the $ORIGIN beeing not required in file * @param $lines * @return mixed|null * @throws DNSSubmoduleException */ private function validateFile( $lines ) { $name = null; foreach ( $lines as $line ) { if ( !preg_match('/\$ORIGIN\s+(.+)/', $line, $match) ) { continue; } $name = $match[1]; break; } if ( !$name ) { throw new DNSSubmoduleException('Missing $ORIGIN in your file'); } if ( $name !== $this->getDomainWithoutDot() && $name !== $this->getDomainReplacement() ) { throw new DNSSubmoduleException('$ORIGIN in file doesn\'t match the zone name'); } return $name; } /** * Since it's ain't easy task here is function for it * @param int $indexOfRecordType * @param array $values * @param int $defaultTTL * @param string $defaultClass * @return array * @throws DNSSubmoduleException */ private function getTTLAndClassFromValues( $indexOfRecordType, array $values, $defaultTTL, $defaultClass = 'IN' ) { $out = []; //We have to check where is ttl and class cause they can be shuffled or one of them can be ommited if ( $indexOfRecordType === 3 ) { if ( is_numeric(trim($values[2])) ) { $out['class'] = $values[1]; $out['ttl'] = (int)$values[2]; } else { $out['class'] = $values[2]; $out['ttl'] = (int)$values[1]; } } elseif ( $indexOfRecordType === 2 ) { //If there is only 2 we will have to check which one if ( is_numeric(trim($values[1])) ) { $out['ttl'] = (int)$values[1]; $out['class'] = $defaultClass; } else { $out['class'] = $values[1]; $out['ttl'] = (int)$defaultTTL; } } else { throw new DNSSubmoduleException('Error while processing your record: ' . $values[0] . ' your record values are in wrong order'); } return $out; } /** * Validates the domain name if there is no dot it wasn't the propper domain name * @param $name * @return bool */ private function validateName( $name ) { return substr($name, -1, 1) !== '.'; } /** * @param array $lines lines of file * @param int $line number of line to edit * @param int $counter specifies offset from which start removing files * @return array with unset lines * @throws DNSSubmoduleException if we couldn't find the ")" in file */ private function clearMultiline( $lines, $line, $counter = 0 ) { //Make sure to don't look for lines that are not in file $linesToEndOfFile = count($lines) - $line; while ( $counter < $linesToEndOfFile ) { if ( $counter > 10 ) { throw new DNSSubmoduleException('Missing closing round bracket in zone file in SOA record you tried to edit'); } //We have to save it somewhere before deleting it $currentLine = $lines[$line + $counter]; unset($lines[$line + $counter]); if ( strpos($currentLine, ')') !== false ) { break; } $counter++; } return $lines; } /** * For matching and replacing purposes returns domain name with dot attached * @param null|string $domain If provided function will append dot to parameter domain instead of global zone * @return string */ private function getDomainReplacement( $domain = null ) { $targetDomain = $domain ? : $this->domain; return rtrim($targetDomain, '.') . '.'; } /** * For matching purposes removes dot (if exist) from the end of the domain * @param null|string $domain If provided function will trim dot from parameter domain instead of global zone * @return string */ private function getDomainWithoutDot( $domain = null ) { $targetDomain = $domain ? : $this->domain; return rtrim($targetDomain, '.'); } /** * Sets path to zone and config and downloads file * @return bool * @throws DNSSubmoduleException */ private function updateZoneVariables() { $this->bindConfigFile = $this->downloadFile($this->parsePathToConfig()); $this->zoneFilePath = $this->getZoneFileLocationFrombindConfigFile($this->bindConfigFile, $this->getDomainWithoutDot()); $this->zoneFile = $this->downloadFile($this->zoneFilePath); return true; } /**Creates SSH2 instance * @param null $ip * @return SSH2 * @throws DNSSubmoduleException */ private function establishSSH2Connection( $ip = null ) { $connctIp = $ip ? : $this->config['hostname']; $this->ssh2 = new SSH2($connctIp); if ( $this->config['rsa'] ) { $auth = new RSA(); $auth->loadKey($this->config['rsa']); } else { $auth = $this->config['password']; } if ( !$this->ssh2->login($this->config['username'], $auth) ) { throw new DNSSubmoduleException('Wrong authentication details'); } return $this->ssh2; } /** Creates SFTP Instance * @param null $ip * @return SFTP * @throws DNSSubmoduleException */ private function establishSFTPConnection( $ip = null ) { $connctIp = $ip ? : $this->config['hostname']; $this->sftp = new SFTP($connctIp); if ( !$this->sftp->login($this->config['username'], $this->config['password']) ) { throw new DNSSubmoduleException('We couldn\'t upload changes to your server check permissions for given account'); } return $this->sftp; } /** * @param $recordTypeFilter * @return array * @throws DNSSubmoduleException */ private function parseFileToStructure( $recordTypeFilter = false ) { $this->updateZoneVariables(); $lines = explode("\n", $this->zoneFile); $out = []; // $this->validateFile($lines); $lines = $this->setAndRemoveUsefullValues($lines); $this->ttl = $this->ttl ? : 14400; $soa = $this->getSoaRecord($lines, $this->ttl); //If SOA in file we add it to out records and remove it so it makes it easier to parse file later (since soa can be multiline) if ( $soa ) { $out[] = $soa; $lines = $this->removeSoaFromLine($lines, $soa->line); } //If multiple SOA records we throw exception if ( $this->getSoaRecord($lines) ) { throw new DNSSubmoduleException('You can have only one SOA record in file'); } //If filter records types is set we filter any other out if ( $recordTypeFilter ) { $lines = $this->filterRecords($lines, $recordTypeFilter); } //Everything else in file should be record foreach ( $lines as $nol => $line ) { if ( !trim($line) ) continue; //Replace @/blank/whitespace in first val for valid domain names $line = $this->replaceName($line); $values = preg_split('/\s+/', $line); $recordType = $this->getRecordType($values); $indexOfRecordType = $this->getIndexOfRecordType($values, $recordType); //If record type is not supported if ( !$recordType ) { throw new DNSSubmoduleException('We don\'t support this record type in line: ' . ($nol + 1)); } $record = new dns\record\Record(); $record->name = $values[0]; $record->line = $nol; $record->type = $values[$indexOfRecordType]; $record->customData = $record->rdlength = ''; $ttlandclass = $this->getTTLAndClassFromValues($indexOfRecordType, $values, $this->ttl); $record->class = $ttlandclass['class']; $record->ttl = $ttlandclass['ttl']; //We split string by whitespace but txt record type has spaces in one of the params so we have to merge them if ( $recordType === 'TXT' ) { preg_match('/".+"/', $line, $match); $values[$indexOfRecordType + 1] = str_replace("\t", ' ', $match[0]); } $className = 'MGModule\DNSManager2\mgLibs\custom\dns\record\type\\' . $recordType; $additionalVars = array_keys(get_class_vars($className)); $recordTypeClass = new $className; foreach ( $additionalVars as $index => $prop ) { $recordTypeClass->$prop = $values[$indexOfRecordType + 1 + $index]; } $record->rdata = $recordTypeClass; $out[] = $record; } return $out; } /** * Since Bin9 requires to update this value every time */ private function incrementSOASerial() { foreach ( $this->records as &$structRecord ) { if ( $structRecord->type === 'SOA' ) { $structRecord->rdata->serial++; } } return $this; } /** * Runs named-checkzone on domain * @return string * @throws DNSSubmoduleException */ private function checkZoneChanges() { $this->establishSSH2Connection(); $pathToZone = $this->zoneFilePath; $result = $this->ssh2->exec("named-checkzone {$this->getDomainWithoutDot()} {$pathToZone}"); if ( $this->ssh2->getExitStatus() ) { return $result; } return true; } }