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