diff --git a/backend/src/controllers/Domains.php b/backend/src/controllers/Domains.php index fcf4fa7..cacc780 100644 --- a/backend/src/controllers/Domains.php +++ b/backend/src/controllers/Domains.php @@ -156,4 +156,50 @@ class Domains return $res->withJson(['error' => 'Domain is not a slave zone'], 405); } } + + public function putSoa(Request $req, Response $res, array $args) + { + $userId = $req->getAttribute('userId'); + $domainId = $args['domainId']; + + $ac = new \Operations\AccessControl($this->c); + if (!$ac->canAccessDomain($userId, $domainId)) { + $this->logger->info('Non admin user tries to get domain without permission.'); + return $res->withJson(['error' => 'You have no permissions for this domain.'], 403); + } + + $body = $req->getParsedBody(); + + if (!array_key_exists('primary', $body) || + !array_key_exists('email', $body) || + !array_key_exists('refresh', $body) || + !array_key_exists('retry', $body) || + !array_key_exists('expire', $body) || + !array_key_exists('ttl', $body)) { + $this->logger->debug('One of the required fields is missing'); + return $res->withJson(['error' => 'One of the required fields is missing'], 422); + } + + $soa = new \Operations\Soa($this->c); + + try { + $soa->setSoa( + intval($domainId), + $body['email'], + $body['primary'], + intval($body['refresh']), + intval($body['retry']), + intval($body['expire']), + intval($body['ttl']) + ); + + return $res->withStatus(204); + } catch (\Exceptions\NotFoundException $e) { + $this->logger->warning('Trying to set soa for not existing domain.', ['domainId' => $domainId]); + return $res->withJson(['error' => 'No domain found for id ' . $domainId], 404); + } catch (\Exceptions\SemanticException $e) { + $this->logger->warning('Trying to set soa for slave domain.', ['domainId' => $domainId]); + return $res->withJson(['error' => 'SOA can not be set for slave domains'], 405); + } + } } diff --git a/backend/src/operations/Soa.php b/backend/src/operations/Soa.php new file mode 100644 index 0000000..080b426 --- /dev/null +++ b/backend/src/operations/Soa.php @@ -0,0 +1,188 @@ +logger = $c->logger; + $this->db = $c->db; + $this->c = $c; + } + + /** + * Get a list of domains according to filter criteria + * + * @param $domainId Domain to update soa + * @param $mail Mail of zone master + * @param $primary The primary nameserver + * @param $refresh The refresh interval + * @param $retry The retry interval + * @param $expire The expire timeframe + * @param $ttl The zone ttl + * + * @return void + * + * @throws NotFoundException If the given domain does not exist + */ + public function setSoa(int $domainId, string $mail, string $primary, int $refresh, int $retry, int $expire, int $ttl) + { + $this->db->beginTransaction(); + + $query = $this->db->prepare('SELECT id,name,type FROM domains WHERE id=:id'); + $query->bindValue(':id', $domainId); + $query->execute(); + $record = $query->fetch(); + + if ($record === false) { + $this->db->rollBack(); + throw new \Exceptions\NotFoundException(); + } elseif ($record['type'] === 'SLAVE') { + $this->db->rollBack(); + throw new \Exceptions\SemanticException(); + } else { + $domainName = $record['name']; + } + + //Generate soa content string without serial + $soaArray = [ + $primary, + $this->fromEmail($mail), + 'serial', + $refresh, + $retry, + $expire, + $ttl + ]; + + $query = $this->db->prepare('SELECT content FROM records WHERE domain_id=:id AND type=\'SOA\''); + $query->bindValue(':id', $domainId); + $query->execute(); + + $content = $query->fetch(); + + if ($content === false) { //No soa exists yet + $soaArray[2] = strval($this->calculateSerial(0)); + $soaString = implode(' ', $soaArray); + $changeDate = strval(time()); + + $query = $this->db->prepare(' + INSERT INTO records (domain_id, name, type, content, ttl, change_date) + VALUES (:domainId, :name, \'SOA\', :content, :ttl, :changeDate) + '); + $query->bindValue(':domainId', $domainId, \PDO::PARAM_INT); + $query->bindValue(':name', $domainName, \PDO::PARAM_STR); + $query->bindValue(':content', $soaString, \PDO::PARAM_STR); + $query->bindValue(':ttl', $ttl, \PDO::PARAM_STR); + $query->bindValue(':changeDate', $changeDate, \PDO::PARAM_INT); + $query->execute(); + } else { + $oldSerial = intval(explode(' ', $content['content'])[2]); + + $soaArray[2] = strval($this->calculateSerial($oldSerial)); + $soaString = implode(' ', $soaArray); + $changeDate = strval(time()); + + $query = $this->db->prepare('UPDATE records SET content=:content, ttl=:ttl, + change_date=:changeDate WHERE domain_id=:domainId AND type=\'SOA\''); + $query->bindValue(':domainId', $domainId, \PDO::PARAM_INT); + $query->bindValue(':content', $soaString, \PDO::PARAM_STR); + $query->bindValue(':ttl', $ttl, \PDO::PARAM_STR); + $query->bindValue(':changeDate', $changeDate, \PDO::PARAM_INT); + $query->execute(); + } + + $this->db->commit(); + } + + /** + * Increases the serial number of the given domain to the next required. + * + * If domain has no present soa record this method does nothing. + * + * @param $domainId Domain to update + * + * @return void + */ + public function updateSerial(int $domainId) : void + { + $query = $this->db->prepare('SELECT content FROM records WHERE domain_id=:id AND type=\'SOA\''); + $query->bindValue(':id', $domainId); + $query->execute(); + $content = $query->fetch(); + + if ($content === false) { + $this->logger->warning('Trying to update serial of domain without soa set it first', ['domainId' => $domainId]); + return; + } + + $soaArray = explode(' ', $content['content']); + $soaArray[2] = strval($this->calculateSerial(intval($soaArray[2]))); + $soaString = implode(' ', $soaArray); + + $query = $this->db->prepare('UPDATE records SET content=:content WHERE domain_id=:domainId AND type=\'SOA\''); + $query->bindValue(':content', $soaString, \PDO::PARAM_STR); + $query->bindValue(':domainId', $domainId, \PDO::PARAM_INT); + $query->execute(); + } + + /** + * Calculate new serial from old + * + * @param $oldSerial Old serial number + * + * @return int New serial number + */ + private function calculateSerial(int $oldSerial) : int + { + $time = new \DateTime(null, new \DateTimeZone('UTC')); + $currentTime = intval($time->format('Ymd')) * 100; + + return \max($oldSerial + 1, $currentTime); + } + + /** + * Convert email to soa mail string + * + * @param $email Email address + * + * @return string Soa email address + */ + private function fromEmail(string $email) + { + $parts = explode('@', $email); + $parts[0] = str_replace('.', '\.', $parts[0]); + $parts[] = ''; + return rtrim(implode(".", $parts), "."); + } + + /** + * Convert soa mail to mail string + * + * @param $soaMail Soa email address + * + * @return string Email address + */ + private function toEmail(string $soaEmail) + { + $tmp = preg_replace('/([^\\\\])\\./', '\\1@', $soaEmail, 1); + $tmp = preg_replace('/\\\\\\./', ".", $tmp); + $tmp = preg_replace('/\\.$/', "", $tmp); + return $tmp; + } +} diff --git a/backend/src/public/index.php b/backend/src/public/index.php index fcd1434..4b97a8c 100644 --- a/backend/src/public/index.php +++ b/backend/src/public/index.php @@ -32,6 +32,8 @@ $app->group('/v1', function () { $this->delete('/domains/{domainId}', '\Controllers\Domains:delete'); $this->get('/domains/{domainId}', '\Controllers\Domains:getSingle'); $this->put('/domains/{domainId}', '\Controllers\Domains:put'); + + $this->put('/domains/{domainId}/soa', '\Controllers\Domains:putSoa'); })->add('\Middlewares\Authentication'); }); diff --git a/backend/test/test.sh b/backend/test/test.sh index 29989a9..3f62d8a 100755 --- a/backend/test/test.sh +++ b/backend/test/test.sh @@ -14,7 +14,7 @@ return [ 'dbname' => '$DBNAME' ], 'logging' => [ - 'level' => 'warning', + 'level' => 'error', 'path' => '../../test/logfile.log' ], 'authentication' => [ diff --git a/backend/test/tests/domain-soa.js b/backend/test/tests/domain-soa.js new file mode 100644 index 0000000..3f52d43 --- /dev/null +++ b/backend/test/tests/domain-soa.js @@ -0,0 +1,67 @@ +const test = require('../testlib'); + +test.run(async function () { + await test('admin', async function (assert, req) { + //Try to set soa for non exitent domain + var res = await req({ + url: '/domains/100/soa', + method: 'put', + data: { + primary: 'ns1.example.com', + email: 'hostmaster@example.com', + refresh: 3600, + retry: 900, + expire: 604800, + ttl: 86400 + } + }); + + assert.equal(res.status, 404, 'Updating SOA for not existing domain should fail'); + + //Try to set soa for slave domain + var res = await req({ + url: '/domains/2/soa', + method: 'put', + data: { + primary: 'ns1.example.com', + email: 'hostmaster@example.com', + refresh: 3600, + retry: 900, + expire: 604800, + ttl: 86400 + } + }); + + assert.equal(res.status, 405, 'Updating SOA for slave domain should fail'); + + //Try to set soa with missing fields + var res = await req({ + url: '/domains/2/soa', + method: 'put', + data: { + primary: 'ns1.example.com', + retry: 900, + expire: 604800, + ttl: 86400 + } + }); + + assert.equal(res.status, 422, 'Updating SOA with missing fields should fail.'); + + //Set soa for zone without one + var res = await req({ + url: '/domains/1/soa', + method: 'put', + data: { + primary: 'ns1.example.com', + email: 'hostmaster@example.com', + refresh: 3600, + retry: 900, + expire: 604800, + ttl: 86400 + } + }); + + assert.equal(res.status, 204, 'Updating SOA for Zone without one should succeed.'); + }); +}); \ No newline at end of file diff --git a/backend/test/tests/domains-get.js b/backend/test/tests/domains-get.js index cd2c57a..5fd3530 100644 --- a/backend/test/tests/domains-get.js +++ b/backend/test/tests/domains-get.js @@ -2,7 +2,7 @@ const test = require('../testlib'); const cartesianProduct = require('cartesian-product'); test.run(async function () { - test('admin', async function (assert, req) { + await test('admin', async function (assert, req) { //GET /domains?page=5&pagesize=10&query=foo&sort=id-asc,name-desc,type-asc,records-asc&type=MASTER //Test sorting in all combinations diff --git a/backend/test/tests/session.js b/backend/test/tests/session.js index 257ed6e..b49d49a 100644 --- a/backend/test/tests/session.js +++ b/backend/test/tests/session.js @@ -1,7 +1,7 @@ const test = require('../testlib'); test.run(async function () { - test('admin', async function (assert, req) { + await test('admin', async function (assert, req) { //Try to login with invalid username and password var res = await req({ url: '/sessions',