Direct zone file edition through <textarea>

This commit is contained in:
Miraty 2022-11-20 01:05:03 +01:00
parent 95095ee6ee
commit e3f358a62c
12 changed files with 217 additions and 34 deletions

View file

@ -23,6 +23,7 @@ Niver is a set of 3 network services:
### Nameserver (`ns`)
* 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
* Display your records or the full zone file

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,22 @@
<?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() {
return (isset($_POST['action'])
AND isset($_POST['zone'])
@ -12,15 +29,17 @@ function nsCommonRequirements() {
function nsParseCommonRequirements() {
nsCheckZonePossession($_POST['zone']);
if (($_POST['subdomain'] === "") OR ($_POST['subdomain'] === "@"))
if (($_POST['subdomain'] === '') OR ($_POST['subdomain'] === '@'))
$values['domain'] = $_POST['zone'];
else
$values['domain'] = formatAbsoluteDomain(formatEndWithDot($_POST['subdomain']) . $_POST['zone']);
$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;
}

View file

@ -32,7 +32,7 @@ if (isset($_SESSION['username']))
<div>
<label for="ttl-value">Valeur</label>
<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">
<option value="900">
<option value="1800">

View file

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

142
pages/ns/edit.php Normal file
View file

@ -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>

View file

@ -50,7 +50,7 @@ if (processForm()) {
if (str_starts_with($zoneLine, ';')) continue; // Ignore comments
if (empty($zoneLine)) continue;
$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>';
foreach ($elements as $element)
echo ' <td><code>' . htmlspecialchars($element) . '</code></td>';
@ -61,15 +61,10 @@ if (processForm()) {
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)
output(500, 'Unable to get public key record from zone file.');
$tag = $matches[1];
$algo = $matches[2];
$digestType = $matches[3];
$digest = $matches[4];
?>
<dl>
@ -79,19 +74,19 @@ if (processForm()) {
</dd>
<dt>Tag</dt>
<dd>
<code><?= $tag ?></code>
<code><?= $matches['tag'] ?></code>
</dd>
<dt>Algorithme</dt>
<dd>
<code><?= $algo ?></code><?php if ($algo === "15") echo " (Ed25519)"; ?>
<code><?= $matches['algo'] ?></code><?php if ($matches['algo'] === '15') echo ' (Ed25519)'; ?>
</dd>
<dt>Type de condensat</dt>
<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>
<dt>Condensat</dt>
<dd>
<code><?= $digest ?></code>
<code><?= $matches['digest'] ?></code>
</dd>
</dl>

View file

@ -13,10 +13,10 @@ if (processForm()) {
checkAbsoluteDomainFormat($parentAuthoritative);
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');
checkAuthToken($matches[1], $matches[2]);
checkAuthToken($matches['salt'], $matches['hash']);
rateLimit();
@ -26,7 +26,18 @@ if (processForm()) {
]);
$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)
$knotZone .= $_POST['domain'] . ' 86400 NS ' . $server . "\n";
if (is_int(file_put_contents($knotZonePath, $knotZone)) !== true)

View file

@ -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"))
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() {
require "pages/" . PAGE_ADDRESS . ".php";
global $final_message;
echo $final_message ?? '';
displayFinalMessage();
?>
</main>
<footer>