Pārlūkot izejas kodu

Direct zone file edition through <textarea>

Miraty 2 gadi atpakaļ
vecāks
revīzija
e3f358a62c
12 mainītis faili ar 217 papildinājumiem un 34 dzēšanām
  1. 1 0
      README.md
  2. 3 0
      config.ini
  3. 8 1
      css/form.css
  4. 7 12
      css/main.css
  5. 1 1
      fn/common.php
  6. 22 3
      fn/ns.php
  7. 1 1
      form.ns.php
  8. 5 0
      pages.php
  9. 142 0
      pages/ns/edit.php
  10. 6 11
      pages/ns/print.php
  11. 14 3
      pages/ns/zone-add.php
  12. 7 2
      router.php

+ 1 - 0
README.md

@@ -23,6 +23,7 @@ Niver is a set of 3 network services:
 ### Nameserver (`ns`)
 ### Nameserver (`ns`)
 
 
 * Host a zone on the server
 * Host a zone on the server
+* Zone file edition through `<textarea>`
 * Dedicated forms to set/unset `A`, `AAAA`, `NS`, `TXT`, `CAA`, `SRV`, `MX`, `SRV`, `SSHFP`, `TLSA`, `CNAME`, `DNAME` and `LOC` records
 * Dedicated forms to set/unset `A`, `AAAA`, `NS`, `TXT`, `CAA`, `SRV`, `MX`, `SRV`, `SSHFP`, `TLSA`, `CNAME`, `DNAME` and `LOC` records
 * Display your records or the full zone file
 * Display your records or the full zone file
 
 

+ 3 - 0
config.ini

@@ -20,6 +20,9 @@ knot_zones_path = "/srv/niver/ns"
 servers[] = "ns1.niver.test."
 servers[] = "ns1.niver.test."
 servers[] = "ns2.niver.test."
 servers[] = "ns2.niver.test."
 kdig_path = "/usr/bin/kdig"
 kdig_path = "/usr/bin/kdig"
+kzonecheck_path = "/usr/bin/kzonecheck"
+; @ must be replaced by a dot
+public_soa_email = "hostmaster.niver.invalid."
 
 
 [ht]
 [ht]
 enabled = true
 enabled = true

+ 8 - 1
css/form.css

@@ -2,7 +2,7 @@ form {
 	text-align: center;
 	text-align: center;
 }
 }
 
 
-input, select {
+input, select, textarea {
 	border-radius: 0.5rem;
 	border-radius: 0.5rem;
 	font-size: 1rem;
 	font-size: 1rem;
 	margin: 0.3rem;
 	margin: 0.3rem;
@@ -52,3 +52,10 @@ fieldset {
 	justify-content: center;
 	justify-content: center;
 	border-color: var(--svc-color, --foreground-color);
 	border-color: var(--svc-color, --foreground-color);
 }
 }
+
+textarea {
+	background-color: var(--background-color);
+	color: var(--foreground-color);
+	width: 100%;
+	box-sizing: border-box;
+}

+ 7 - 12
css/main.css

@@ -33,20 +33,17 @@ h2 {
 	font-size: 1.3rem;
 	font-size: 1.3rem;
 }
 }
 
 
-header, main > *:not(table, pre), footer {
+header, main > *:not(table, pre, form), form > *:not(textarea), footer {
 	max-width: 40rem;
 	max-width: 40rem;
+	margin-left: auto;
+	margin-right: auto;
 }
 }
 
 
 main > nav {
 main > nav {
 	max-width: 30rem;
 	max-width: 30rem;
 }
 }
 
 
-header, main > *, footer {
-	margin-left: auto;
-	margin-right: auto;
-}
-
-header {
+header, footer {
 	text-align: center;
 	text-align: center;
 	margin-top: 0.8rem;
 	margin-top: 0.8rem;
 }
 }
@@ -77,18 +74,16 @@ code {
 }
 }
 
 
 a {
 a {
-	text-decoration: underline var(--svc-color) 0.2em;
 	color: var(--foreground-color);
 	color: var(--foreground-color);
+	text-decoration: underline var(--svc-color) 0.2em;
 }
 }
 
 
 a:hover {
 a:hover {
-	text-decoration: underline var(--svc-color) 0.25em;
-	color: var(--foreground-color);
+	text-decoration-thickness: 0.25em;
 }
 }
 
 
 a:active {
 a:active {
-	text-decoration: underline var(--svc-color) 0.35em;
-	color: var(--foreground-color);
+	text-decoration-thickness: 0.35em;
 }
 }
 
 
 a[rel=help]:before {
 a[rel=help]:before {

+ 1 - 1
fn/common.php

@@ -1,6 +1,6 @@
 <?php
 <?php
 
 
-$final_message = null;
+$final_message = NULL;
 
 
 function output($code, $msg = '', $logs = ['']) {
 function output($code, $msg = '', $logs = ['']) {
 	global $final_message;
 	global $final_message;

+ 22 - 3
fn/ns.php

@@ -1,5 +1,22 @@
 <?php
 <?php
 
 
+define('SOA_VALUES', [
+	'ttl' => 10800,
+	'email' => CONF['ns']['public_soa_email'],
+	'refresh' => 10800,
+	'retry' => 3600,
+	'expire' => 3628800,
+	'negative' => 10800,
+]);
+
+define('MIN_TTL', 300);
+define('DEFAULT_TTL', 10800);
+define('MAX_TTL', 1728000);
+
+define('ALLOWED_TYPES', ['AAAA', 'A', 'TXT', 'SRV', 'MX', 'SVCB', 'HTTPS', 'NS', 'DS', 'CAA', 'CNAME', 'DNAME', 'LOC', 'SSHFP', 'TLSA']);
+
+define('ZONE_MAX_CHARACTERS', 10000);
+
 function nsCommonRequirements() {
 function nsCommonRequirements() {
 	return (isset($_POST['action'])
 	return (isset($_POST['action'])
 		AND isset($_POST['zone'])
 		AND isset($_POST['zone'])
@@ -12,15 +29,17 @@ function nsCommonRequirements() {
 function nsParseCommonRequirements() {
 function nsParseCommonRequirements() {
 	nsCheckZonePossession($_POST['zone']);
 	nsCheckZonePossession($_POST['zone']);
 
 
-	if (($_POST['subdomain'] === "") OR ($_POST['subdomain'] === "@"))
+	if (($_POST['subdomain'] === '') OR ($_POST['subdomain'] === '@'))
 		$values['domain'] = $_POST['zone'];
 		$values['domain'] = $_POST['zone'];
 	else
 	else
 		$values['domain'] = formatAbsoluteDomain(formatEndWithDot($_POST['subdomain']) . $_POST['zone']);
 		$values['domain'] = formatAbsoluteDomain(formatEndWithDot($_POST['subdomain']) . $_POST['zone']);
 
 
 	$values['ttl'] = $_POST['ttl-value'] * $_POST['ttl-multiplier'];
 	$values['ttl'] = $_POST['ttl-value'] * $_POST['ttl-multiplier'];
 
 
-	if (!($values['ttl'] >= 300 AND $values['ttl'] <= 432000))
-		output(403, 'Le TTL doit être compris entre 5 minutes et 5 jours (entre 300 et 432000 secondes).');
+	if ($values['ttl'] < MIN_TTL)
+		output(403, 'Les TTLs inférieurs à ' . MIN_TTL . ' secondes ne sont pas autorisés.');
+	if ($values['ttl'] > MAX_TTL)
+		output(403, 'Les TTLs supérieurs à ' . MAX_TTL . ' secondes ne sont pas autorisés.');
 
 
 	return $values;
 	return $values;
 }
 }

+ 1 - 1
form.ns.php

@@ -32,7 +32,7 @@ if (isset($_SESSION['username']))
 		<div>
 		<div>
 			<label for="ttl-value">Valeur</label>
 			<label for="ttl-value">Valeur</label>
 			<br>
 			<br>
-			<input required="" id="ttl-value" list="ttls" name="ttl-value" size="6" type="number" min="1" max="432000" value="10800" placeholder="10800">
+			<input required="" id="ttl-value" list="ttls" name="ttl-value" size="6" type="number" min="1" max="432000" value="<?= DEFAULT_TTL ?>" placeholder="<?= DEFAULT_TTL ?>">
 			<datalist id="ttls">
 			<datalist id="ttls">
 				<option value="900">
 				<option value="900">
 				<option value="1800">
 				<option value="1800">

+ 5 - 0
pages.php

@@ -80,6 +80,11 @@ define('PAGES', [
 			'title' => 'Afficher les données',
 			'title' => 'Afficher les données',
 			'description' => 'Afficher le contenu de la zone',
 			'description' => 'Afficher le contenu de la zone',
 		],
 		],
+		'edit' => [
+			'title' => 'Modifier une zone',
+			'description' => 'Éditer un fichier de zone',
+			'tokens_account_cost' => 300,
+		],
 		'ip' => [
 		'ip' => [
 			'title' => 'Enregistrements A et AAAA',
 			'title' => 'Enregistrements A et AAAA',
 			'description' => 'Indiquer l\'adresse IP d\'un domaine',
 			'description' => 'Indiquer l\'adresse IP d\'un domaine',

+ 142 - 0
pages/ns/edit.php

@@ -0,0 +1,142 @@
+<?php
+
+if (processForm() AND isset($_POST['zone-content'])) { // Update zone
+	nsCheckZonePossession($_POST['zone']);
+
+	// Get current SOA record
+	$current_zone_content = file_get_contents(CONF['ns']['knot_zones_path'] . '/' . $_POST['zone'] . 'zone');
+	if ($current_zone_content === false)
+		output(500, 'Unable to read zone file.');
+	if (preg_match('/^(?<soa>' . preg_quote($_POST['zone']) . '[\t ]+[0-9]{1,16}[\t ]+SOA[\t ]+.+)$/m', $current_zone_content, $matches) !== 1)
+		output(500, 'Unable to get current serial from zone file.');
+
+	// Generate new zone content
+	$new_zone_content = $matches['soa'] . "\n";
+	if (strlen($_POST['zone-content']) > ZONE_MAX_CHARACTERS)
+		output(403, 'La zone n\'est pas autorisée à dépasser ' . ZONE_MAX_CHARACTERS . ' caractères.');
+	foreach (explode("\r\n", $_POST['zone-content']) as $line) {
+		if ($line === '') continue;
+		if (preg_match('/^(?<domain>[a-z0-9@._-]+)(?:[\t ]+(?<ttl>[0-9]{1,16}))?(?:[\t ]+IN)?[\t ]+(?<type>[A-Z]{1,16})[\t ]+(?<value>.+)$/', $line, $matches) !== 1)
+			output(403, 'La zone est mal formatée (selon Niver).');
+		if (in_array($matches['type'], ALLOWED_TYPES, true) !== true)
+			output(403, 'Le type <code>' . $matches['type'] . '</code> n\'est pas autorisé.');
+		if ($matches['ttl'] !== '' AND $matches['ttl'] < MIN_TTL)
+			output(403, 'Les TTLs inférieurs à ' . MIN_TTL . ' secondes ne sont pas autorisés.');
+		if ($matches['ttl'] !== '' AND $matches['ttl'] > MAX_TTL)
+			output(403, 'Les TTLs supérieurs à ' . MAX_TTL . ' secondes ne sont pas autorisés.');
+		$new_zone_content .= $matches['domain'] . ' ' . (($matches['ttl'] === '') ? DEFAULT_TTL : $matches['ttl']) . ' ' . $matches['type'] . ' ' . $matches['value'] . "\n";
+	}
+
+	// Send the zone content to kzonecheck's stdin
+	$process = proc_open(CONF['ns']['kzonecheck_path'] . ' --origin ' . $_POST['zone'] . ' --dnssec off -', [0 => ['pipe', 'r']], $pipes);
+	if (is_resource($process) !== true)
+		output(500, 'Can\'t spawn kzonecheck.');
+	fwrite($pipes[0], $new_zone_content);
+	fclose($pipes[0]);
+	if (proc_close($process) !== 0)
+		output(403, 'Le contenu de zone envoyé n\'est pas valide (selon <code>kzonecheck</code>).');
+
+	ratelimit();
+
+	exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 10 zone-freeze ' . $_POST['zone'], $output, $return_code);
+	if ($return_code !== 0)
+		output(500, 'Failed to freeze zone file.', $output);
+
+	exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 10 zone-flush ' . $_POST['zone'], $output, $return_code);
+	if ($return_code !== 0)
+		output(500, 'Failed to flush zone file.', $output);
+
+	if (file_put_contents(CONF['ns']['knot_zones_path'] . '/' . $_POST['zone'] . 'zone', $new_zone_content) === false)
+		output(500, 'Failed to write zone file.');
+
+	exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 10 zone-reload ' . $_POST['zone'], $output, $return_code);
+	if ($return_code !== 0)
+		output(500, 'Failed to reload zone file.', $output);
+
+	exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 10 zone-thaw ' . $_POST['zone'], $output, $return_code);
+	if ($return_code !== 0)
+		output(500, 'Failed to thaw zone file.', $output);
+
+	usleep(1000000);
+
+	output(200, 'La zone a été mise à jour.');
+}
+
+?>
+
+<form method="post">
+	<label for="zone">Zone à modifier</label>
+	<br>
+	<select required="" name="zone" id="zone">
+		<option value="" disabled="" selected="">-</option>
+<?php
+if (isset($_SESSION['username']))
+	foreach (nsListUserZones($_SESSION['username']) as $zone)
+		echo '		<option value="' . $zone . '">' . $zone . '</option>' . "\n";
+?>
+	</select>
+	<br>
+	<input type="submit" value="Afficher">
+</form>
+
+<?php
+
+if (processForm()) { // Display zone
+	nsCheckZonePossession($_POST['zone']);
+
+	$zone_content = file_get_contents(CONF['ns']['knot_zones_path'] . '/' . $_POST['zone'] . 'zone');
+	if ($zone_content === false)
+		output(500, 'Unable to read zone file.');
+
+	$displayed_zone_content = '';
+	foreach(explode("\n", $zone_content) as $zone_line) {
+		if (empty($zone_line) OR str_starts_with($zone_line, ';'))
+			continue;
+		if (preg_match('/^(?:(?:[a-z0-9_-]{1,63}\.){1,127})?' . preg_quote($_POST['zone'], '/') . '[\t ]+[0-9]{1,8}[\t ]+(?<type>[A-Z]{1,16})[\t ]+.+$/', $zone_line, $matches)) {
+			if (in_array($matches['type'], ALLOWED_TYPES, true) !== true)
+				continue;
+			$displayed_zone_content .= $zone_line . "\n";
+		}
+	}
+	$displayed_zone_content .= "\n";
+
+?>
+<form method="post">
+	<input type="hidden" name="zone" value="<?= $_POST['zone'] ?>">
+
+	<label for="zone-content">Nouveau contenu de la zone <code><strong><?= $_POST['zone'] ?></strong></code></label>
+	<textarea id="zone-content" name="zone-content" wrap="off" rows="<?= substr_count($displayed_zone_content, "\n") + 1 ?>"><?= htmlspecialchars($displayed_zone_content) ?></textarea>
+	<br>
+	<input type="submit" value="Remplacer">
+</form>
+
+<?php
+
+}
+
+global $final_message;
+displayFinalMessage();
+
+?>
+
+<h2>Valeurs par défaut</h2>
+
+<p>Si le TTL est omis, il sera définit à <code><?= DEFAULT_TTL ?></code> secondes.</p>
+
+<p>La précision de la classe (<code>IN</code>) est facultative.</p>
+
+<h2>Valeurs autorisées</h2>
+
+<p>La zone n'est pas autorisée à dépasser <?= ZONE_MAX_CHARACTERS ?> caractères.</p>
+
+<p>Les TTLs ne sont autorisés qu'entre <code><?= MIN_TTL ?></code> et <code><?= MAX_TTL ?></code> secondes.</p>
+
+<p>Les seuls types dont l'édition est autorisée sont :</p>
+
+<ul>
+<?php
+	foreach (ALLOWED_TYPES as $allowed_type)
+		echo '	<li><code>' . $allowed_type . '</code></li>';
+
+?>
+</ul>

+ 6 - 11
pages/ns/print.php

@@ -50,7 +50,7 @@ if (processForm()) {
 			if (str_starts_with($zoneLine, ';')) continue; // Ignore comments
 			if (str_starts_with($zoneLine, ';')) continue; // Ignore comments
 			if (empty($zoneLine)) continue;
 			if (empty($zoneLine)) continue;
 			$elements = preg_split("#[\t ]+#", $zoneLine, 4);
 			$elements = preg_split("#[\t ]+#", $zoneLine, 4);
-			if (!in_array($elements[2], ['CAA', 'A', 'AAAA', 'MX', 'NS', 'SRV', 'SSHFP', 'TLSA', 'TXT'], true)) continue; // Ignore records generated by Knot
+			if (!in_array($elements[2], ALLOWED_TYPES, true)) continue; // Ignore records generated by Knot
 			echo '	<tr>';
 			echo '	<tr>';
 			foreach ($elements as $element)
 			foreach ($elements as $element)
 				echo '		<td><code>' . htmlspecialchars($element) . '</code></td>';
 				echo '		<td><code>' . htmlspecialchars($element) . '</code></td>';
@@ -61,15 +61,10 @@ if (processForm()) {
 
 
 	if ($_POST['print'] === 'ds') {
 	if ($_POST['print'] === 'ds') {
 
 
-		$found = preg_match("#\n" . preg_quote($_POST['zone']) . "\s+0\s+CDS\s+([0-9]{1,5})\s+([0-9]{1,2})\s+([0-9])\s+([0-9A-F]{64})\n#", $zoneContent, $matches);
+		$found = preg_match('/^' . preg_quote($_POST['zone']) . '[\t ]+0[\t ]+CDS[\t ]+(?<tag>[0-9]{1,5})[\t ]+(?<algo>[0-9]{1,2})[\t ]+(?<digest_type>[0-9])[\t ]+(?<digest>[0-9A-F]{64})$/m', $zoneContent, $matches);
 		if ($found !== 1)
 		if ($found !== 1)
 			output(500, 'Unable to get public key record from zone file.');
 			output(500, 'Unable to get public key record from zone file.');
 
 
-		$tag = $matches[1];
-		$algo = $matches[2];
-		$digestType = $matches[3];
-		$digest = $matches[4];
-
 ?>
 ?>
 
 
 <dl>
 <dl>
@@ -79,19 +74,19 @@ if (processForm()) {
 	</dd>
 	</dd>
 	<dt>Tag</dt>
 	<dt>Tag</dt>
 	<dd>
 	<dd>
-		<code><?= $tag ?></code>
+		<code><?= $matches['tag'] ?></code>
 	</dd>
 	</dd>
 	<dt>Algorithme</dt>
 	<dt>Algorithme</dt>
 	<dd>
 	<dd>
-		<code><?= $algo ?></code><?php if ($algo === "15") echo " (Ed25519)"; ?>
+		<code><?= $matches['algo'] ?></code><?php if ($matches['algo'] === '15') echo ' (Ed25519)'; ?>
 	</dd>
 	</dd>
 	<dt>Type de condensat</dt>
 	<dt>Type de condensat</dt>
 	<dd>
 	<dd>
-		<code><?= $digestType ?></code><?php if ($digestType === "2") echo " (SHA-256)"; ?>
+		<code><?= $matches['digest_type'] ?></code><?php if ($matches['digest_type'] === '2') echo ' (SHA-256)'; ?>
 	</dd>
 	</dd>
 	<dt>Condensat</dt>
 	<dt>Condensat</dt>
 	<dd>
 	<dd>
-		<code><?= $digest ?></code>
+		<code><?= $matches['digest'] ?></code>
 	</dd>
 	</dd>
 </dl>
 </dl>
 
 

+ 14 - 3
pages/ns/zone-add.php

@@ -13,10 +13,10 @@ if (processForm()) {
 		checkAbsoluteDomainFormat($parentAuthoritative);
 		checkAbsoluteDomainFormat($parentAuthoritative);
 
 
 	exec(CONF['ns']['kdig_path'] . ' ' . $_POST['domain'] . ' NS @' . $parentAuthoritatives[0] . ' +noidn', $results);
 	exec(CONF['ns']['kdig_path'] . ' ' . $_POST['domain'] . ' NS @' . $parentAuthoritatives[0] . ' +noidn', $results);
-	if (preg_match('/\n' . preg_quote($_POST['domain'], '/') . '[\t ]+[0-9]{1,8}[\t ]+IN[\t ]+NS[\t ]+([0-9a-f]{8})-([0-9a-f]{32})\.auth-owner.+\n/', implode("\n", $results), $matches) !== 1)
+	if (preg_match('/^' . preg_quote($_POST['domain'], '/') . '[\t ]+[0-9]{1,8}[\t ]+IN[\t ]+NS[\t ]+(?<salt>[0-9a-f]{8})-(?<hash>[0-9a-f]{32})\.auth-owner.+$/m', implode("\n", $results), $matches) !== 1)
 		output(403, 'Enregistrement d\'authentification introuvable');
 		output(403, 'Enregistrement d\'authentification introuvable');
 
 
-	checkAuthToken($matches[1], $matches[2]);
+	checkAuthToken($matches['salt'], $matches['hash']);
 
 
 	rateLimit();
 	rateLimit();
 
 
@@ -26,7 +26,18 @@ if (processForm()) {
 	]);
 	]);
 
 
 	$knotZonePath = CONF['ns']['knot_zones_path'] . "/" . $_POST['domain'] . "zone";
 	$knotZonePath = CONF['ns']['knot_zones_path'] . "/" . $_POST['domain'] . "zone";
-	$knotZone = $_POST['domain'] . ' 3600 SOA ' . CONF['ns']['servers'][0] . ' admin.niver.test. 1 21600 7200 3628800 3600' . "\n";
+	$knotZone = implode(' ', [
+		$_POST['domain'],
+		SOA_VALUES['ttl'],
+		'SOA',
+		CONF['ns']['servers'][0],
+		SOA_VALUES['email'],
+		1,
+		SOA_VALUES['refresh'],
+		SOA_VALUES['retry'],
+		SOA_VALUES['expire'],
+		SOA_VALUES['negative'],
+	]) . "\n";
 	foreach (CONF['ns']['servers'] as $server)
 	foreach (CONF['ns']['servers'] as $server)
 		$knotZone .= $_POST['domain'] . ' 86400 NS ' . $server . "\n";
 		$knotZone .= $_POST['domain'] . ' 86400 NS ' . $server . "\n";
 	if (is_int(file_put_contents($knotZonePath, $knotZone)) !== true)
 	if (is_int(file_put_contents($knotZonePath, $knotZone)) !== true)

+ 7 - 2
router.php

@@ -117,11 +117,16 @@ if (in_array(SERVICE, ['reg', 'ns', 'ht']) AND CONF[SERVICE]['enabled'] !== true
 if (empty($_POST) === false AND (isset($_SERVER['HTTP_SEC_FETCH_SITE']) !== true OR $_SERVER['HTTP_SEC_FETCH_SITE'] !== "same-origin"))
 if (empty($_POST) === false AND (isset($_SERVER['HTTP_SEC_FETCH_SITE']) !== true OR $_SERVER['HTTP_SEC_FETCH_SITE'] !== "same-origin"))
 	output(403, 'Anti-<abbr title="Cross-Site Request Forgery">CSRF</abbr> verification failed ! (Wrong or unset <code>Sec-Fetch-Site</code> HTTP header)');
 	output(403, 'Anti-<abbr title="Cross-Site Request Forgery">CSRF</abbr> verification failed ! (Wrong or unset <code>Sec-Fetch-Site</code> HTTP header)');
 
 
+function displayFinalMessage() {
+	global $final_message;
+	echo $final_message ?? '';
+	$final_message = NULL;
+}
+
 function executePage() {
 function executePage() {
 	require "pages/" . PAGE_ADDRESS . ".php";
 	require "pages/" . PAGE_ADDRESS . ".php";
 
 
-	global $final_message;
-	echo $final_message ?? '';
+	displayFinalMessage();
 ?>
 ?>
 		</main>
 		</main>
 		<footer>
 		<footer>