diff --git a/.gitignore b/.gitignore index 24297de..a305ade 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ includes/config.inc.php +nsedit.sublime* diff --git a/README.md b/README.md index 622d294..6614dda 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,10 @@ Features ======== * Import BIND- or AXFR-style dumps of your existing zones * Add/remove zones and records +* Clone zones * Show the DNSsec details of a zone -* Multiple user support +* Multiple user support +* Allow logging of all actions in NSEdit, including exporting the log in JSON-format * [experimental] nsedit API, to create zones from another system User support @@ -32,7 +34,7 @@ Requirements * php sqlite3 * php curl * php with openssl support -* PowerDNS with the experimental JSON-api enabled (3.4.0 should do. For Pdns > 4.0.0 you ***NEED*** v1.0 of NSEdit) +* PowerDNS with the JSON-api enabled. Version 4.0.0 or greater Installing ========== @@ -41,8 +43,8 @@ Installing - Run git clone in the directory where you want to run nsedit from : ```git clone https://github.com/tuxis-ie/nsedit.git``` - - Select tag v0.9 or skip this if you want to run from master - : ```git checkout tags/v0.9``` + - Select tag v1.0 or skip this if you want to run from master + : ```git checkout tags/v1.0``` * Via releases - Download the zip-file from [Releases](https://github.com/tuxis-ie/nsedit/releases) diff --git a/img/delete.png b/img/delete.png new file mode 100644 index 0000000..f4c24db Binary files /dev/null and b/img/delete.png differ diff --git a/img/delete_inverted.png b/img/delete_inverted.png new file mode 100644 index 0000000..0b05aeb Binary files /dev/null and b/img/delete_inverted.png differ diff --git a/includes/class/ApiHandler.php b/includes/class/ApiHandler.php new file mode 100644 index 0000000..15efef8 --- /dev/null +++ b/includes/class/ApiHandler.php @@ -0,0 +1,124 @@ +headers = Array(); + $this->hostname = $apiip; + $this->port = $apiport; + $this->auth = $apipass; + $this->proto = $apiproto; + $this->sslverify = $apisslverify; + $this->curlh = curl_init(); + $this->method = 'GET'; + $this->content = FALSE; + $this->apiurl = ''; + } + + public function addheader($field, $content) { + $this->headers[$field] = $content; + } + + private function authheaders() { + $this->addheader('X-API-Key', $this->auth); + } + + private function apiurl() { + $tmp = new ApiHandler(); + + $tmp->url = '/api'; + $tmp->go(); + + if ($tmp->json[0]['version'] <= 1) { + $this->apiurl = $tmp->json[0]['url']; + } else { + throw new Exception("Unsupported API version"); + } + + } + + private function curlopts() { + $this->authheaders(); + $this->addheader('Accept', 'application/json'); + + curl_reset($this->curlh); + curl_setopt($this->curlh, CURLOPT_HTTPHEADER, Array()); + curl_setopt($this->curlh, CURLOPT_RETURNTRANSFER, 1); + + if (strcasecmp($this->proto, 'https')) { + curl_setopt($this->curlh, CURLOPT_SSL_VERIFYPEER, $this->sslverify); + } + + $setheaders = Array(); + + foreach ($this->headers as $k => $v) { + array_push($setheaders, join(": ", Array($k, $v))); + } + curl_setopt($this->curlh, CURLOPT_HTTPHEADER, $setheaders); + } + + private function baseurl() { + return $this->proto.'://'.$this->hostname.':'.$this->port.$this->apiurl; + } + + private function go() { + $this->curlopts(); + + if ($this->content) { + $this->addheader('Content-Type', 'application/json'); + curl_setopt($this->curlh, CURLOPT_POST, 1); + curl_setopt($this->curlh, CURLOPT_POSTFIELDS, $this->content); + } + + switch ($this->method) { + case 'POST': + curl_setopt($this->curlh, CURLOPT_POST, 1); + break; + case 'GET': + curl_setopt($this->curlh, CURLOPT_POST, 0); + break; + case 'DELETE': + case 'PATCH': + case 'PUT': + curl_setopt($this->curlh, CURLOPT_CUSTOMREQUEST, $this->method); + break; + } + + curl_setopt($this->curlh, CURLOPT_URL, $this->baseurl().$this->url); + + //print "Here we go:\n"; + //print "Request: ".$this->method.' '.$this->baseurl().$this->url."\n"; + //if ($this->content != '') { + // print "Content: ".$this->content."\n"; + //} + + $return = curl_exec($this->curlh); + $code = curl_getinfo($this->curlh, CURLINFO_HTTP_CODE); + $json = json_decode($return, 1); + + if (isset($json['error'])) { + throw new Exception("API Error $code: ".$json['error']); + } elseif ($code < 200 || $code >= 300) { + if ($code == 401) { + throw new Exception("Authentication failed. Have you configured your authmethod correct?"); + } + throw new Exception("Curl Error: $code ".curl_error($this->curlh)); + } + + $this->json = $json; + } + + public function call() { + if (substr($this->url, 0, 1) == '/') { + $this->apiurl(); + } else { + $this->apiurl = '/'; + } + + $this->go(); + } +} + diff --git a/includes/class/PdnsApi.php b/includes/class/PdnsApi.php new file mode 100644 index 0000000..25a1581 --- /dev/null +++ b/includes/class/PdnsApi.php @@ -0,0 +1,125 @@ +http = new ApiHandler(); + } + + public function listzones($q = FALSE) { + $api = clone $this->http; + $api->method = 'GET'; + if ($q) { + $api->url = "/servers/localhost/search-data?q=*".$q."*&max=25"; + $api->call(); + $ret = Array(); + $seen = Array(); + + foreach ($api->json as $result) { + if (isset($seen[$result['zone_id']])) { + continue; + } + $zone = $this->loadzone($result['zone_id']); + unset($zone['rrsets']); + array_push($ret, $zone); + $seen[$result['zone_id']] = 1; + } + + return $ret; + } + $api->url = "/servers/localhost/zones"; + $api->call(); + + return $api->json; + } + + public function loadzone($zoneid) { + $api = clone $this->http; + $api->method = 'GET'; + $api->url = "/servers/localhost/zones/$zoneid"; + $api->call(); + + return $api->json; + } + + public function exportzone($zoneid) { + $api = clone $this->http; + $api->method = 'GET'; + $api->url = "/servers/localhost/zones/$zoneid/export"; + $api->call(); + + return $api->json; + } + + public function savezone($zone) { + $api = clone $this->http; + // We have to split up RRSets and Zoneinfo. + // First, update the zone + + $zonedata = $zone; + unset($zonedata['id']); + unset($zonedata['url']); + unset($zonedata['rrsets']); + + if (!isset($zone['serial']) or gettype($zone['serial']) != 'integer') { + $api->method = 'POST'; + $api->url = '/servers/localhost/zones'; + $api->content = json_encode($zonedata); + $api->call(); + + return $api->json; + } + $api->method = 'PUT'; + $api->url = $zone['url']; + $api->content = json_encode($zonedata); + $api->call(); + + // Then, update the rrsets + if (count($zone['rrsets']) > 0) { + $api->method = 'PATCH'; + $api->content = json_encode(Array('rrsets' => $zone['rrsets'])); + $api->call(); + } + + return $this->loadzone($zone['id']); + } + + public function deletezone($zoneid) { + $api = clone $this->http; + $api->method = 'DELETE'; + $api->url = "/servers/localhost/zones/$zoneid"; + $api->call(); + + return $api->json; + } + + public function getzonekeys($zoneid) { + $ret = array(); + $api = clone $this->http; + $api->method = 'GET'; + $api->url = "/servers/localhost/zones/$zoneid/cryptokeys"; + + $api->call(); + + foreach ($api->json as $key) { + if (!isset($key['active'])) + continue; + + $key['dstxt'] = $zoneid . ' IN DNSKEY '.$key['dnskey']."\n\n"; + + if (isset($key['ds'])) { + foreach ($key['ds'] as $ds) { + $key['dstxt'] .= $zoneid . ' IN DS '.$ds."\n"; + } + unset($key['ds']); + } + array_push($ret, $key); + } + + return $ret; + } + +} + +?> diff --git a/includes/class/Zone.php b/includes/class/Zone.php new file mode 100644 index 0000000..4dc2e83 --- /dev/null +++ b/includes/class/Zone.php @@ -0,0 +1,334 @@ +id = ''; + $this->name = ''; + $this->kind = ''; + $this->url = ''; + $this->serial = ''; + $this->dnssec = ''; + $this->soa_edit = ''; + $this->soa_edit_api = ''; + $this->keyinfo = ''; + $this->account = ''; + $this->zone = FALSE; + $this->nameservers = Array(); + $this->rrsets = Array(); + $this->masters = Array(); + } + + public function parse($data) { + $this->setId($data['id']); + $this->setName($data['name']); + $this->setKind($data['kind']); + $this->setDnssec($data['dnssec']); + $this->setAccount($data['account']); + $this->setSerial($data['serial']); + $this->url = $data['url']; + if (isset($data['soa_edit'])) + $this->setSoaEdit($data['soa_edit']); + if (isset($data['soa_edit_api'])) + $this->setSoaEditApi($data['soa_edit_api']); + + foreach ($data['masters'] as $master) { + $this->addMaster($master); + } + + if (isset($data['rrsets'])) { + foreach ($data['rrsets'] as $rrset) { + $toadd = new RRSet($rrset['name'], $rrset['type']); + foreach ($rrset['comments'] as $comment) { + $toadd->addComment($comment['content'], $comment['account'], $comment['modified_at']); + } + foreach ($rrset['records'] as $record) { + $toadd->addRecord($record['content'], $record['disabled']); + } + $toadd->setTtl($rrset['ttl']); + array_push($this->rrsets, $toadd); + } + } + } + + public function importData($data) { + $this->zone = $data; + } + + public function setKeyinfo($info) { + $this->keyinfo = $info; + } + + public function addNameserver($nameserver) { + foreach ($this->nameservers as $ns) { + if ($nameserver == $ns) { + throw new Exception("We already have this as a nameserver"); + } + } + array_push($this->nameservers, $nameserver); + + } + + public function setSerial($serial) { + $this->serial = $serial; + } + + public function setSoaEdit($soaedit) { + $this->soa_edit = $soaedit; + } + + public function setSoaEditApi($soaeditapi) { + $this->soa_edit_api = $soaeditapi; + } + public function setName($name) { + $this->name = $name; + } + + public function setKind($kind) { + $this->kind = $kind; + } + + public function setAccount($account) { + $this->account = $account; + } + + public function setDnssec($dnssec) { + $this->dnssec = $dnssec; + } + + public function setId($id) { + $this->id = $id; + } + + public function addMaster($ip) { + foreach ($this->masters as $master) { + if ($ip == $master) { + throw new Exception("We already have this as a master"); + } + } + array_push($this->masters, $ip); + } + + public function eraseMasters() { + $this->masters = Array(); + } + + public function addRRSet($name, $type, $content, $disabled = FALSE, $ttl = 3600, $setptr = FALSE) { + if ($this->getRRSet($name, $type) !== FALSE) { + throw new Exception("This rrset already exists."); + } + $rrset = new RRSet($name, $type, $content, $disabled, $ttl, $setptr); + array_push($this->rrsets, $rrset); + } + + public function addRecord($name, $type, $content, $disabled = FALSE, $ttl = 3600, $setptr = FALSE) { + $rrset = $this->getRRSet($name, $type); + + if ($rrset) { + $rrset->addRecord($content, $disabled, $setptr); + } else { + $this->addRRSet($name, $type, $content, $disabled, $ttl, $setptr); + } + + return $this->getRecord($name, $type, $content); + } + + public function getRecord($name, $type, $content) { + $rrset = $this->getRRSet($name, $type); + foreach ($rrset->exportRecords() as $record) { + if ($record['content'] == $content) { + $record['name'] = $rrset->name; + $record['ttl'] = $rrset->ttl; + $record['type'] = $rrset->type; + $id = json_encode($record); + $record['id'] = $id; + return $record; + } + } + + } + + public function getRRSet($name, $type) { + foreach ($this->rrsets as $rrset) { + if ($rrset->name == $name and $rrset->type == $type) { + return $rrset; + } + } + + return FALSE; + } + + public function rrsets2records() { + $ret = Array(); + + foreach ($this->rrsets as $rrset) { + foreach ($rrset->exportRecords() as $record) { + $record['name'] = $rrset->name; + $record['ttl'] = $rrset->ttl; + $record['type'] = $rrset->type; + $id = json_encode($record); + $record['id'] = $id; + array_push($ret, $record); + } + } + + return $ret; + } + + public function export() { + $ret = Array(); + $ret['account'] = $this->account; + $ret['nameservers'] = $this->nameservers; + $ret['kind'] = $this->kind; + $ret['name'] = $this->name; + $ret['soa_edit'] = $this->soa_edit; + $ret['soa_edit_api'] = $this->soa_edit_api; + if ($this->zone) { + $ret['zone'] = $this->zone; + return $ret; + } + + $ret['dnssec'] = $this->dnssec; + if ($this->dnssec) { + $ret['keyinfo'] = $this->keyinfo; + } + $ret['id'] = $this->id; + $ret['masters'] = $this->masters; + $ret['rrsets'] = $this->exportRRSets(); + $ret['serial'] = $this->serial; + $ret['url'] = $this->url; + + return $ret; + } + + private function exportRRSets() { + $ret = Array(); + foreach ($this->rrsets as $rrset) { + array_push($ret, $rrset->export()); + } + + return $ret; + } +} + +class RRSet { + public function __construct($name = '', $type = '', $content = '', $disabled = FALSE, $ttl = 3600, $setptr = FALSE) { + $this->name = $name; + $this->type = $type; + $this->ttl = $ttl; + $this->changetype = 'REPLACE'; + $this->records = Array(); + $this->comments = Array(); + + if (isset($content) and $content != '') { + $this->addRecord($content, $disabled, $setptr); + } + } + + public function delete() { + $this->changetype = 'DELETE'; + } + + public function setTtl($ttl) { + $this->ttl = $ttl; + } + + public function setName($name) { + $this->name = $name; + } + + public function addRecord($content, $disabled = FALSE, $setptr = FALSE) { + foreach ($this->records as $record) { + if ($record->content == $content) { + throw Exception("Record already exists"); + } + } + + $record = new Record($content, $disabled, $setptr); + array_push($this->records, $record); + } + + public function deleteRecord($content) { + foreach ($this->records as $idx => $record) { + if ($record->content == $content) { + unset($this->records[$idx]); + } + } + } + public function addComment($content, $account, $modified_at = FALSE) { + $comment = new Comment($content, $account, $modified_at); + array_push($this->comments, $comment); + } + + public function export() { + $ret = Array(); + $ret['comments'] = $this->exportComments(); + $ret['name'] = $this->name; + $ret['records'] = $this->exportRecords(); + if ($this->changetype != 'DELETE') { + $ret['ttl'] = $this->ttl; + } + $ret['type'] = $this->type; + $ret['changetype'] = $this->changetype; + return $ret; + } + + public function exportRecords() { + $ret = Array(); + foreach ($this->records as $record) { + if ($this->type != "A" and $this->type != "AAAA") { + $record->setptr = FALSE; + } + array_push($ret, $record->export()); + } + + return $ret; + } + + public function exportComments() { + $ret = Array(); + foreach ($this->comments as $comment) { + array_push($ret, $comment->export()); + } + + return $ret; + } + +} + +class Record { + public function __construct($content, $disabled = FALSE, $setptr = FALSE) { + $this->content = $content; + $this->disabled = $disabled; + $this->setptr = $setptr; + } + + public function export() { + $ret; + + $ret['content'] = $this->content; + $ret['disabled'] = ( bool ) $this->disabled; + if ($this->setptr) { + $ret['set-ptr'] = ( bool ) TRUE; + } + + return $ret; + } +} + +class Comment { + public function __construct($content, $account, $modified_at) { + $this->content = $content; + $this->account = $account; + $this->modified_at = $modified_at; + } + + public function export() { + $ret; + + $ret['content'] = $this->content; + $ret['account'] = $this->account; + $ret['modified_at'] = $this->modified_at; + } +} + +?> diff --git a/includes/config.inc.php-dist b/includes/config.inc.php-dist index 093d9b0..51f0bdd 100644 --- a/includes/config.inc.php-dist +++ b/includes/config.inc.php-dist @@ -1,24 +1,14 @@ doc/apiconf.txt'; $blocklogin = TRUE; } -if (!preg_match('/^[01]$/', $apivers)) { - $errormsg = "The value for \$apivers is incorrect your config"; - $blocklogin = TRUE; -} - -if (!isset($authmethod) or !preg_match('/^(xapikey|userpass|auto)$/', $authmethod)) { - $errormsg = "The value for \$authmethod is incorrect in your config"; - $blocklogin = TRUE; -} - if (!isset($apiproto) or !preg_match('/^http(s)?$/', $apiproto)) { $errormsg = "The value for \$apiproto is incorrect in your config. Did you configure it?"; $blocklogin = TRUE; @@ -51,12 +41,6 @@ if (!isset($logo) or empty($logo)) { /* No need to change stuf below */ -if ($apivers == 0) { - $apipath = ""; -} elseif ($apivers == 1) { - $apipath = "/api/v1"; -} - if (function_exists('curl_init') === FALSE) { $errormsg = "You need PHP Curl to run nsedit"; $blocklogin = TRUE; @@ -144,6 +128,7 @@ function do_db_auth($u, $p) { $db->close(); if ($userinfo and $userinfo['password'] and (crypt($p, $userinfo['password']) === $userinfo['password'])) { + writelog('Succesful login.'); return TRUE; } @@ -167,6 +152,11 @@ function add_user($username, $isadmin = FALSE, $password = '') { $ret = $q->execute(); $db->close(); + if ($isadmin) { + writelog("Added user $username as admin."); + } else { + writelog("Added user $username."); + } return $ret; } @@ -182,11 +172,13 @@ function update_user($username, $isadmin, $password) { $q = $db->prepare('UPDATE users SET isadmin = ?, password = ? WHERE emailaddress = ?'); $q->bindValue(1, (int)(bool)$isadmin, SQLITE3_INTEGER); $q->bindValue(2, $password, SQLITE3_TEXT); - $q->bindValue(3, $username, SQLITE3_TEXT); + $q->bindValue(3, $username, SQLITE3_TEXT); + writelog("Updating password and/or settings for $username. Admin: ".(int)(bool)$isadmin); } else { $q = $db->prepare('UPDATE users SET isadmin = ? WHERE emailaddress = ?'); $q->bindValue(1, (int)(bool)$isadmin, SQLITE3_INTEGER); $q->bindValue(2, $username, SQLITE3_TEXT); + writelog("Updating settings for $username. Admin: ".(int)(bool)$isadmin); } $ret = $q->execute(); $db->close(); @@ -194,13 +186,14 @@ function update_user($username, $isadmin, $password) { return $ret; } -function delete_user($id) { +function delete_user($username) { $db = get_db(); $q = $db->prepare('DELETE FROM users WHERE id = ?'); $q->bindValue(1, $id, SQLITE3_INTEGER); $ret = $q->execute(); $db->close(); + writelog("Deleted user $username."); return $ret; } @@ -258,7 +251,55 @@ function user_template_names() { return $templatenames; } +function getlogs() { + global $logging; + if ($logging !== TRUE) + return; + $db = get_db(); + $r = $db->query('SELECT * FROM logs ORDER BY timestamp DESC'); + $ret = array(); + while ($row = $r->fetchArray(SQLITE3_ASSOC)) { + array_push($ret, $row); + } + + return $ret; +} + +function clearlogs() { + global $logging; + if ($logging !== TRUE) + return; + + $db = get_db(); + $q = $db->query('DELETE FROM logs;'); + $db->close(); + writelog("Logtable truncated."); +} + +function writelog($line) { + global $logging; + if ($logging !== TRUE) + return; + + try { + $db = get_db(); + $q = $db->prepare('CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY, + user TEXT NOT NULL, + log TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP);'); + $ret = $q->execute(); + + $q = $db->prepare('INSERT INTO logs (user, log) VALUES (:user, :log)'); + $q->bindValue(':user', get_sess_user(), SQLITE3_TEXT); + $q->bindValue(':log', $line, SQLITE3_TEXT); + $q->execute(); + $db->close(); + } catch (Exception $e) { + return jtable_respond(null, 'error', $e->getMessage()); + } +} /* This function was taken from https://gist.github.com/rsky/5104756 to make it available on older php versions. Thanks! */ diff --git a/index.php b/index.php index 90da2ee..e56d501 100644 --- a/index.php +++ b/index.php @@ -113,6 +113,9 @@ if ($blocklogin === TRUE) {
+