1<?php 2## 3## Copyright 2013-2018 Opera Software AS 4## 5## Licensed under the Apache License, Version 2.0 (the "License"); 6## you may not use this file except in compliance with the License. 7## You may obtain a copy of the License at 8## 9## http://www.apache.org/licenses/LICENSE-2.0 10## 11## Unless required by applicable law or agreed to in writing, software 12## distributed under the License is distributed on an "AS IS" BASIS, 13## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14## See the License for the specific language governing permissions and 15## limitations under the License. 16## 17 18/** 19* Class for reading/writing to the list of Zone objects in the database. 20*/ 21class ZoneDirectory extends DBDirectory { 22 /** 23 * PowerDNS communication object 24 */ 25 private $powerdns; 26 /** 27 * Cache of zone data returned from PowerDNS 28 */ 29 private $powerdns_zones = null; 30 31 public function __construct() { 32 parent::__construct(); 33 global $powerdns; 34 $this->powerdns = $powerdns; 35 $this->cache_uid = array(); 36 } 37 38 /** 39 * Add a zone to the database. 40 * @param Zone $zone to be added 41 */ 42 public function add_zone(Zone $zone) { 43 $stmt = $this->database->prepare('INSERT INTO zone (pdns_id, name, serial, kind, account, dnssec) VALUES (?, ?, ?, ?, ?, ?)'); 44 $stmt->bindParam(1, $zone->pdns_id, PDO::PARAM_STR); 45 $stmt->bindParam(2, $zone->name, PDO::PARAM_STR); 46 $stmt->bindParam(3, $zone->serial, PDO::PARAM_INT); 47 $stmt->bindParam(4, $zone->kind, PDO::PARAM_STR); 48 $stmt->bindParam(5, $zone->account, PDO::PARAM_STR); 49 $stmt->bindParam(6, $zone->dnssec, PDO::PARAM_INT); 50 try { 51 $stmt->execute(); 52 $zone->id = $this->database->lastInsertId('zone_id_seq'); 53 } catch(PDOException $e) { 54 if($e->getCode() == 23505) { 55 // Zone already exists in the database 56 $stmt = $this->database->prepare('SELECT id FROM zone WHERE name = ?'); 57 $stmt->bindParam(1, $name, PDO::PARAM_STR); 58 $stmt->execute(); 59 if($row = $stmt->fetch(PDO::FETCH_ASSOC)) { 60 $zone->id = $row['id']; 61 } 62 } else { 63 throw $e; 64 } 65 } 66 } 67 68 /** 69 * Create a new zone in PowerDNS and add to the database. 70 * @param Zone $zone to be created 71 */ 72 public function create_zone($zone) { 73 global $config; 74 $data = new StdClass; 75 $data->name = $zone->name; 76 $data->kind = $zone->kind; 77 $data->nameservers = $zone->nameservers; 78 $data->rrsets = array(); 79 foreach($zone->list_resource_record_sets() as $rrset) { 80 $recordset = new StdClass; 81 $recordset->name = $rrset->name; 82 $recordset->type = $rrset->type; 83 $recordset->ttl = $rrset->ttl; 84 $recordset->records = array(); 85 $recordset->comments = array(); 86 foreach($rrset->list_resource_records() as $rr) { 87 $record = new StdClass; 88 $record->content = $rr->content; 89 $record->disabled = $rr->disabled; 90 $recordset->records[] = $record; 91 } 92 foreach($rrset->list_comments() as $c) { 93 $comment = new StdClass; 94 $comment->name = $c->name; 95 $comment->type = $c->type; 96 $comment->content = $c->content; 97 $comment->account = $c->account; 98 $comment->modified_at = $c->modified_at; 99 $recordset->comments[] = $comment; 100 } 101 $data->rrsets[] = $recordset; 102 } 103 $data->soa_edit_api = isset($config['powerdns']['soa_edit_api']) ? $config['powerdns']['soa_edit_api'] : 'INCEPTION-INCREMENT'; 104 $data->account = $zone->account; 105 $data->dnssec = (bool)$zone->dnssec; 106 $response = $this->powerdns->post('zones', $data); 107 $zone->pdns_id = $response->id; 108 $zone->serial = $response->serial; 109 $this->add_zone($zone); 110 $zone->send_notify(); 111 $this->git_tracked_export(array($zone), 'Zone '.$zone->name.' created via DNS UI'); 112 syslog_report(LOG_INFO, "zone={$zone->name};object=zone;action=add;status=succeeded"); 113 } 114 115 /** 116 * List all zones in PowerDNS and update list in database to match. 117 * @param array $include list of extra data to include in response 118 * @return array of Zone objects indexed by pdns_id 119 */ 120 public function list_zones($include = array()) { 121 $this->database->query('BEGIN WORK'); 122 $this->database->query('LOCK TABLE zone'); 123 $fields = array('zone.*'); 124 $joins = array(); 125 foreach($include as $field) { 126 switch($field) { 127 case 'pending_updates': 128 $fields[] = 'COUNT(pending_update.id) as pending_updates'; 129 $joins[] = 'LEFT JOIN pending_update ON pending_update.zone_id = zone.id'; 130 break; 131 } 132 } 133 $stmt = $this->database->prepare(' 134 SELECT '.implode(', ', $fields).' 135 FROM zone '.implode(" ", $joins).' 136 GROUP BY zone.id 137 ORDER BY zone.name 138 '); 139 $stmt->execute(); 140 $zones_by_pdns_id = array(); 141 $current_zones = array(); 142 while($row = $stmt->fetch(PDO::FETCH_ASSOC)) { 143 $zones_by_pdns_id[$row['pdns_id']] = new Zone($row['id'], $row); 144 } 145 if(is_null($this->powerdns_zones)) { 146 $this->powerdns_zones = $this->powerdns->get('zones'); 147 foreach($this->powerdns_zones as $pdns_zone) { 148 if(!isset($zones_by_pdns_id[$pdns_zone->id])) { 149 $zone = new Zone; 150 $zone->pdns_id = $pdns_zone->id; 151 $zone->name = $pdns_zone->name; 152 $zone->kind = $pdns_zone->kind; 153 $zone->serial = $pdns_zone->serial; 154 $zone->account = $pdns_zone->account; 155 $zone->dnssec = $pdns_zone->dnssec; 156 $this->add_zone($zone); 157 $zones_by_pdns_id[$zone->pdns_id] = $zone; 158 $current_zones[$zone->pdns_id] = true; 159 } else { 160 $fields = array('serial' => PDO::PARAM_INT, 'kind' => PDO::PARAM_STR, 'account' => PDO::PARAM_STR, 'dnssec' => PDO::PARAM_INT); 161 foreach($fields as $field => $type) { 162 if($zones_by_pdns_id[$pdns_zone->id]->{$field} != $pdns_zone->{$field}) { 163 $zones_by_pdns_id[$pdns_zone->id]->{$field} = $pdns_zone->{$field}; 164 $stmt = $this->database->prepare('UPDATE zone SET '.$field.' = ? WHERE id = ?'); 165 $stmt->bindParam(1, $pdns_zone->{$field}, $type); 166 $stmt->bindParam(2, $zones_by_pdns_id[$pdns_zone->id]->id, PDO::PARAM_INT); 167 $stmt->execute(); 168 } 169 } 170 $current_zones[$pdns_zone->id] = true; 171 if(!$zones_by_pdns_id[$pdns_zone->id]->active) { 172 $stmt = $this->database->prepare('UPDATE zone SET active = true WHERE id = ?'); 173 $stmt->bindParam(1, $zones_by_pdns_id[$pdns_zone->id]->id, PDO::PARAM_INT); 174 $stmt->execute(); 175 $zones_by_pdns_id[$pdns_zone->id]->active = true; 176 } 177 } 178 } 179 foreach($zones_by_pdns_id as $pdns_id => &$zone) { 180 if(!isset($current_zones[$zone->pdns_id]) && $zone->active) { 181 $stmt = $this->database->prepare('UPDATE zone SET active = false WHERE id = ?'); 182 $stmt->bindParam(1, $zone->id, PDO::PARAM_INT); 183 $stmt->execute(); 184 $zone->active = false; 185 } 186 if(!$zone->active) { 187 unset($zones_by_pdns_id[$pdns_id]); 188 } 189 } 190 } 191 $this->database->query('COMMIT WORK'); 192 return $zones_by_pdns_id; 193 } 194 195 /** 196 * Fetch the zone matching the specific name. 197 * @param string $name of zone to fetch 198 * @return Zone object 199 * @throws ZoneNotFound if no zone exists with that name in the database 200 */ 201 public function get_zone_by_name($name) { 202 $stmt = $this->database->prepare('SELECT * FROM zone WHERE name = ?'); 203 $stmt->bindParam(1, $name, PDO::PARAM_STR); 204 $stmt->execute(); 205 if($row = $stmt->fetch(PDO::FETCH_ASSOC)) { 206 $zone = new Zone($row['id'], $row); 207 } else { 208 throw new ZoneNotFound; 209 } 210 return $zone; 211 } 212 213 /** 214 * Fetch the list of values for the "account" metadata field across all zones. 215 * @return array of string values 216 */ 217 public function list_accounts() { 218 $stmt = $this->database->prepare('SELECT DISTINCT(account) AS account FROM zone ORDER BY account'); 219 $stmt->execute(); 220 $accounts = array(); 221 while($row = $stmt->fetch(PDO::FETCH_ASSOC)) { 222 $accounts[] = $row['account']; 223 } 224 return $accounts; 225 } 226 227 /** 228 * Check the list of zones to see if a suitable reverse zone exists for the forward record. 229 * @param string $name of DNS record 230 * @param string $type of DNS record 231 * @param string $address that DNS record points to 232 * @param array $revs_missing keep track of reverse zones that are missing 233 * @param array $revs_updated keep track of reverse zones that will be updated 234 */ 235 public function check_reverse_record_zone($name, $type, $address, &$revs_missing, &$revs_notify) { 236 global $zone_dir, $active_user; 237 238 if($type == 'A') { 239 $reverse_address = implode('.', array_reverse(explode('.', $address))).'.in-addr.arpa.'; 240 } elseif($type == 'AAAA') { 241 $address = ipv6_address_expand($address); 242 $reverse_address = implode('.', array_reverse(str_split(str_replace(':', '', $address)))).'.ip6.arpa.'; 243 } else { 244 return false; 245 } 246 $reverse_zone_name = $reverse_address; 247 // Find an appropriate reverse zone by starting with the full domain name, and 248 // removing subdomains until we find a match or run out of things to remove 249 do { 250 try { 251 $reverse_zone = $zone_dir->get_zone_by_name($reverse_zone_name); 252 // See if a record already exists for this IP 253 foreach($reverse_zone->list_resource_record_sets() as $rrset) { 254 if($rrset->name == $reverse_address) { 255 if($rrset->type == 'PTR') { 256 $alert = new UserAlert; 257 $alert->escaping = ESC_NONE; 258 $alert->content = 'Reverse record already exists for '.hesc($address).' in <a href="'.rrurl('/zones/'.urlencode(DNSZoneName::unqualify($reverse_zone->name))).'" class="alert-link">'.hesc(DNSZoneName::unqualify($reverse_zone->name)).'</a>. Not modifying existing PTR record from '.$rrset->merge_content_text().' to '.$name; 259 $alert->class = 'warning'; 260 $active_user->add_alert($alert); 261 return false; 262 } 263 if($rrset->type == 'CNAME') { 264 $rr = reset($rrset->list_resource_records()); 265 $alert = new UserAlert; 266 $alert->escaping = ESC_NONE; 267 $alert->content = 'Reverse record delegated to '.hesc($rr->content).' for '.hesc($address).' in <a href="'.rrurl('/zones/'.urlencode(DNSZoneName::unqualify($reverse_zone->name))).'" class="alert-link">'.hesc(DNSZoneName::unqualify($reverse_zone->name)).'</a>. Not creating PTR record for '.$name; 268 $alert->class = 'warning'; 269 $active_user->add_alert($alert); 270 return false; 271 } 272 } 273 } 274 // Add reverse zone to list of zones to send a notify for 275 $revs_notify[$reverse_zone->pdns_id] = $reverse_zone; 276 return true; 277 } catch(ZoneNotFound $e) { 278 } 279 } while($this->remove_subdomain($reverse_zone_name)); 280 $alert = new UserAlert; 281 $alert->content = "No suitable reverse zone could be found to place record for $address pointing to $name"; 282 $alert->class = 'warning'; 283 $active_user->add_alert($alert); 284 $revs_missing[$type][] = array('name' => $name, 'address' => $address); 285 return false; 286 } 287 288 /** 289 * Given a DNS name, remove the bottom-level subdomain from it. 290 * @param string $address DNS name 291 * @return bool true if any subdomain could be removed 292 */ 293 private function remove_subdomain(&$address) { 294 $dotpos = strpos($address, '.'); 295 if($dotpos === false) return false; 296 $address = substr($address, $dotpos + 1); 297 return true; 298 } 299 300 /** 301 * Export the listed zones to bind9 and add/commit to the git-tracked export 302 * @param array $zones to be exported and committed 303 * @param string $message commit message 304 */ 305 public function git_tracked_export(array $zones, $message) { 306 global $config, $active_user; 307 if($config['git_tracked_export']['enabled']) { 308 $original_dir = getcwd(); 309 if(chdir($config['git_tracked_export']['path'])) { 310 foreach($zones as $zone) { 311 $bind9_output = $zone->export_as_bind9_format(); 312 $outfile = urlencode(DNSZoneName::unqualify($zone->name)); 313 $fh = fopen($outfile, 'w'); 314 fwrite($fh, $bind9_output); 315 fclose($fh); 316 exec('LANG=en_US.UTF-8 git add '.escapeshellarg($outfile)); 317 } 318 exec('LANG=en_US.UTF-8 git commit --author '.escapeshellarg($active_user->name.' <'.$active_user->email.'>').' -m '.escapeshellarg($message)); 319 } 320 chdir($original_dir); 321 } 322 } 323 324 /** 325 * Remove the specified zone from the git-tracked export 326 * @param Zone $zone to be removed 327 * @param string $message commit message 328 */ 329 public function git_tracked_delete(Zone $zone, $message) { 330 global $config, $active_user; 331 if($config['git_tracked_export']['enabled']) { 332 $original_dir = getcwd(); 333 if(chdir($config['git_tracked_export']['path'])) { 334 $outfile = urlencode(DNSZoneName::unqualify($zone->name)); 335 exec('LANG=en_US.UTF-8 git rm '.escapeshellarg($outfile)); 336 exec('LANG=en_US.UTF-8 git commit --author '.escapeshellarg($active_user->name.' <'.$active_user->email.'>').' -m '.escapeshellarg($message)); 337 } 338 chdir($original_dir); 339 } 340 } 341} 342 343class ZoneNotFound extends RuntimeException {}; 344