Browse Source

Add ns/sync and jobs/ns-syncs

Miraty 2 years ago
parent
commit
858d6e8d02
14 changed files with 178 additions and 11 deletions
  1. 12 0
      db/migrations/006-create-ns-syncs-table.sql
  2. 8 0
      db/schema.sql
  3. 3 1
      fn/auth.php
  4. 18 0
      fn/common.php
  5. 6 3
      fn/dns.php
  6. 2 0
      fn/ns.php
  7. 1 1
      init.php
  8. 42 0
      jobs/ns-sync.php
  9. 5 0
      pages.php
  10. 37 0
      pg-act/ns/sync.php
  11. 4 4
      pg-view/ht/keys.php
  12. 1 1
      pg-view/ns/form.ns.php
  13. 1 1
      pg-view/ns/print.php
  14. 38 0
      pg-view/ns/sync.php

+ 12 - 0
db/migrations/006-create-ns-syncs-table.sql

@@ -0,0 +1,12 @@
+BEGIN TRANSACTION;
+
+CREATE TABLE IF NOT EXISTS "ns-syncs" (
+	"username"    TEXT    NOT NULL,
+	"source"      TEXT    NOT NULL,
+	"destination" TEXT    NOT NULL UNIQUE,
+	UNIQUE("username", "source", "destination"),
+	FOREIGN KEY("username") REFERENCES "users"("id"),
+	FOREIGN KEY("destination") REFERENCES "zones"("zone")
+);
+
+COMMIT;

+ 8 - 0
db/schema.sql

@@ -42,6 +42,14 @@ CREATE TABLE IF NOT EXISTS "zones" (
 	PRIMARY KEY("zone"),
 	FOREIGN KEY("username") REFERENCES "users"("id")
 );
+CREATE TABLE IF NOT EXISTS "ns-syncs" (
+	"username"    TEXT    NOT NULL,
+	"source"      TEXT    NOT NULL,
+	"destination" TEXT    NOT NULL UNIQUE,
+	UNIQUE("username", "source", "destination"),
+	FOREIGN KEY("username") REFERENCES "users"("id"),
+	FOREIGN KEY("destination") REFERENCES "zones"("zone")
+);
 CREATE TABLE IF NOT EXISTS "sites" (
 	"username"      TEXT    NOT NULL,
 	"site_dir"      TEXT    NOT NULL,

+ 3 - 1
fn/auth.php

@@ -98,9 +98,11 @@ function authDeleteUser(string $user_id): void {
 		foreach (query('select', 'registry', ['username' => $user_id], 'domain') as $domain)
 			regDeleteDomain($domain, $user_id);
 
-	if (in_array('ns', $user_services, true))
+	if (in_array('ns', $user_services, true)) {
+		query('delete', 'ns-syncs', ['username' => $user_id]);
 		foreach (query('select', 'zones', ['username' => $user_id], 'zone') as $zone)
 			nsDeleteZone($zone, $user_id);
+	}
 
 	if (in_array('ht', $user_services, true)) {
 		foreach (query('select', 'sites', ['username' => $user_id]) as $site)

+ 18 - 0
fn/common.php

@@ -21,6 +21,24 @@ function exescape(array $args, array &$output = NULL, int &$result_code = NULL):
 	return $result_code;
 }
 
+class KdigException extends Exception {};
+function kdig(string $name, string $type, string $server = NULL): array {
+	exescape([
+		CONF['dns']['kdig_path'],
+		'+json',
+		'+timeout=5',
+		'+retry=0',
+		'-q',
+		$name,
+		'-t',
+		$type,
+		...(isset($server) ? ['@' . $server] : []),
+	], $output, $code);
+	if ($code !== 0)
+		throw new KdigException();
+	return json_decode(implode(LF, $output), true, flags: JSON_THROW_ON_ERROR);
+}
+
 function insert(string $table, array $values): void {
 	$query = 'INSERT INTO "' . $table . '"(';
 

+ 6 - 3
fn/dns.php

@@ -1,16 +1,19 @@
 <?php
 
-function parseZoneFile(string $zone_content, array $types, bool|string $filter_domain = false): array {
+function parseZoneFile(string $zone_content, array $types, bool|string $filter_domain = false, bool $filter_include_subdomains = true): array {
 	$parsed_zone_content = [];
 	foreach (explode(LF, $zone_content) as $zone_line) {
 		if ($zone_line === '' OR str_starts_with($zone_line, ';'))
 			continue; // Ignore empty lines and comments
 		$elements = preg_split('/[\t ]+/', $zone_line, 4);
-		if ($filter_domain !== false AND !str_ends_with($elements[0], $filter_domain))
+		if ($filter_domain !== false AND match ($filter_include_subdomains) {
+			true => !str_ends_with($elements[0], $filter_domain),
+			false => $elements[0] !== $filter_domain,
+		})
 			continue; // Ignore records for other domains
 		if (!in_array($elements[2], $types, true))
 			continue; // Ignore records generated by Knot
-		array_push($parsed_zone_content, array_map('htmlspecialchars', $elements));
+		array_push($parsed_zone_content, $elements);
 	}
 	return $parsed_zone_content;
 }

+ 2 - 0
fn/ns.php

@@ -17,6 +17,8 @@ const ALLOWED_TYPES = ['AAAA', 'A', 'TXT', 'SRV', 'MX', 'SVCB', 'HTTPS', 'NS', '
 
 const ZONE_MAX_CHARACTERS = 10000;
 
+const SYNC_TTL = 3600;
+
 function nsParseCommonRequirements(): array {
 	nsCheckZonePossession($_POST['zone']);
 

+ 1 - 1
init.php

@@ -7,7 +7,7 @@ set_error_handler(function ($level, $message, $file = '', $line = 0) {
 set_exception_handler(function ($e) {
 	error_log((string) $e);
 	http_response_code(500);
-	echo '<h1>Error</h1>An error occured.';
+	echo '<h1>Error</h1><p>An error occured.<p>';
 });
 register_shutdown_function(function () { // Also catch fatal errors
 	if (($error = error_get_last()) !== NULL)

+ 42 - 0
jobs/ns-sync.php

@@ -0,0 +1,42 @@
+<?php
+require 'init.php';
+
+foreach (query('select', 'ns-syncs') as $sync) {
+	$zone_raw = file_get_contents(CONF['ns']['knot_zones_path'] . '/' . $sync['destination'] . 'zone');
+	if ($zone_raw === false)
+		output(403, 'Unable to read zone file.');
+
+	foreach (['AAAA', 'A', 'SRV', 'CAA'] as $type) {
+		// Get source/distant records
+		try {
+			$results = kdig(name: $sync['source'], type: $type);
+		} catch (KdigException) {
+			error_log($sync['source'] . ' resolution failed.' . LF);
+			break;
+		}
+		if ($results['AD'] !== 1) {
+			error_log($sync['source'] . ' skipped: not signed using DNSSEC.' . LF);
+			continue;
+		}
+		$source_records = array_column($results['answerRRs'] ?? [], 'rdata' . $type);
+
+		// Get destination/local records
+		$dest_records = array_column(parseZoneFile($zone_raw, [$type], $sync['destination'], false), 3);
+
+		// Add source records that are not yet in destination
+		foreach (array_diff($source_records, $dest_records) as $value_to_add)
+			knotcZoneExec($sync['destination'], [
+				$sync['destination'],
+				SYNC_TTL,
+				$type,
+				$value_to_add,
+			], 'add');
+		// Delete destination records that are not part of source anymore
+		foreach (array_diff($dest_records, $source_records) as $value_to_delete)
+			knotcZoneExec($sync['destination'], [
+				$sync['destination'],
+				$type,
+				$value_to_delete,
+			], 'delete');
+	}
+}

+ 5 - 0
pages.php

@@ -163,6 +163,11 @@ define('PAGES', [
 			'description' => _('Store geographic coordinates'),
 			'tokens_account_cost' => 120,
 		],
+		'sync' => [
+			'title' => sprintf(_('Synchronized records')),
+			'description' => _('Regularly fetch distant record value and update it to a local zone'),
+			'tokens_account_cost' => 900,
+		],
 	],
 	'ht' => [
 		'index' => [

+ 37 - 0
pg-act/ns/sync.php

@@ -0,0 +1,37 @@
+<?php
+
+$el_nb = count($_POST['syncs']);
+if ($el_nb < 1 OR $el_nb > 8)
+	output(403, 'Wrong elements number.');
+
+foreach ($_POST['syncs'] as $i => &$sync) {
+	if (($sync['source'] ?? '') === '') {
+		unset($_POST['syncs'][$i]);
+		continue;
+	}
+	$sync['source'] = formatAbsoluteDomain($sync['source']);
+	nsCheckZonePossession($sync['destination']);
+}
+$syncs = array_values($_POST['syncs']);
+
+rateLimit();
+
+try {
+	DB->beginTransaction();
+
+	query('delete', 'ns-syncs', ['username' => $_SESSION['id']]);
+
+	foreach ($syncs as $sync)
+		insert('ns-syncs', [
+			'username' => $_SESSION['id'],
+			'source' => $sync['source'],
+			'destination' => $sync['destination'],
+		]);
+
+	DB->commit();
+} catch (Exception $e) {
+	DB->rollback();
+	output(500, 'Database error.', [$e->getMessage()]);
+}
+
+output(200, _('Synchronized records updated.'));

+ 4 - 4
pg-view/ht/keys.php

@@ -16,12 +16,12 @@ foreach (array_slice(array_merge(query('select', 'ssh-keys', ['username' => $_SE
 	<fieldset>
 		<legend><?= ($ssh_key['key'] === '') ? _('Add new SSH key access') : _('SSH key access') ?></legend>
 		<div>
-			<label for="public-key"><?= _('Public key') ?></label><br>
-			<code>ssh-ed15519 <input pattern="<?= ED25519_PUBKEY_REGEX ?>" placeholder="AAAAC3NzaC1lZDI1NTE5AAAAI<?= substr(base64_encode(random_bytes(32)), 0, 43) ?>" id="public-key" name="keys[<?= $i ?>][public-key]" value="<?= $ssh_key['key'] ?>" type="text"></code>
+			<label for="public-key<?= $i ?>"><?= _('Public key') ?></label><br>
+			<code>ssh-ed15519 <input pattern="<?= ED25519_PUBKEY_REGEX ?>" placeholder="AAAAC3NzaC1lZDI1NTE5AAAAI<?= substr(base64_encode(random_bytes(32)), 0, 43) ?>" id="public-key<?= $i ?>" name="keys[<?= $i ?>][public-key]" value="<?= $ssh_key['key'] ?>" type="text"></code>
 		</div>
 		<div>
-			<label for="dir"><?= _('Allowed directory') ?></label><br>
-			<input list="dirs" placeholder="/" value="<?= htmlspecialchars($ssh_key['directory']) ?>" id="dir" name="keys[<?= $i ?>][dir]" type="text">
+			<label for="dir<?= $i ?>"><?= _('Allowed directory') ?></label><br>
+			<input list="dirs" placeholder="/" value="<?= htmlspecialchars($ssh_key['directory']) ?>" id="dir<?= $i ?>" name="keys[<?= $i ?>][dir]" type="text">
 		</div>
 	</fieldset>
 <?php

+ 1 - 1
pg-view/ns/form.ns.php

@@ -15,7 +15,7 @@
 			<label for="zone"><?= _('Zone') ?></label>
 			<br>
 			<select required="" name="zone" id="zone">
-				<option value="" disabled="" selected="">-</option>
+				<option value="" disabled="" selected=""></option>
 <?php
 foreach (nsListUserZones() as $zone)
 	echo "<option value='" . $zone . "'>" . $zone . "</option>";

+ 1 - 1
pg-view/ns/print.php

@@ -38,7 +38,7 @@ if (isset($data['zone-table'])) { ?>
 	foreach ($data['zone-table'] as $zone_line) {
 		echo '	<tr>' . LF;
 		foreach ($zone_line as $element)
-			echo '		<td><code>' . $element . '</code></td>' . LF;
+			echo '		<td><code>' . htmlspecialchars($element) . '</code></td>' . LF;
 		echo '	</tr>' . LF;
 	}
 }

+ 38 - 0
pg-view/ns/sync.php

@@ -0,0 +1,38 @@
+<p>
+	<?= sprintf(_('AAAA, A and CAA records are regularly copied from the source domain to the target domain. Their TTLs are set to %s seconds.'), SYNC_TTL) ?>
+</p>
+<p>
+	<?= _('Source domains that are not signed with DNSSEC are not synchronized. Synchronizations that remain broken may be deleted.') ?>
+</p>
+<p>
+	<?= _('This is meant to be used for apex domains, where a CNAME record is not allowed. For non-apex domains, CNAME records should be used instead.') ?>
+</p>
+
+<form method="post">
+<?php
+foreach (array_slice(array_merge(query('select', 'ns-syncs', ['username' => $_SESSION['id'] ?? '']), [['source' => '', 'destination' => '—']]), 0, 8) as $i => $sync) {
+?>
+	<fieldset>
+		<legend><?= ($sync['source'] === '') ? _('Add new domain records to be synchronized') : _('Synchronized record') ?></legend>
+		<div>
+			<label for="source<?= $i ?>"><?= _('Source domain') ?></label><br>
+			<input placeholder="provider.<?= PLACEHOLDER_DOMAIN ?>." id="source<?= $i ?>" name="syncs[<?= $i ?>][source]" value="<?= $sync['source'] ?>" type="text">
+		</div>
+		<div>
+			<label for="destination<?= $i ?>"><?= _('Target domain') ?></label>
+			<br>
+			<select required="" name="syncs[<?= $i ?>][destination]" id="destination<?= $i ?>">
+				<option <?= (($sync['destination'] === '') ? 'value="" disabled=""' : 'value="' . $sync['destination'] . '"') ?> selected=""><?= $sync['destination'] ?></option>
+<?php
+foreach (nsListUserZones() as $zone)
+	echo "<option value='" . $zone . "'>" . $zone . "</option>";
+?>
+
+			</select>
+		</div>
+	</fieldset>
+<?php
+}
+?>
+	<input type="submit" value="<?= _('Update') ?>">
+</form>