Browse Source

Merge pull request 'dev' (#9) from dev into main

Reviewed-on: https://code.antopie.org/servnest/servnest/pulls/9
Miraty 1 năm trước cách đây
mục cha
commit
95f376ab00
100 tập tin đã thay đổi với 1576 bổ sung806 xóa
  1. 12 0
      db/migrations/006-create-ns-syncs-table.sql
  2. 7 0
      db/schema.sql
  3. 18 16
      fn/auth.php
  4. 29 11
      fn/common.php
  5. 18 15
      fn/dns.php
  6. 9 9
      fn/ht.php
  7. 19 15
      fn/ns.php
  8. 64 9
      fn/reg.php
  9. 3 3
      init.php
  10. 57 17
      jobs/check.php
  11. 2 2
      jobs/delete-old-testing.php
  12. 42 0
      jobs/ns-sync.php
  13. 57 0
      jobs/reg-cds.php
  14. 61 0
      jobs/reg-csync.php
  15. 363 255
      locales/fr/C/LC_MESSAGES/messages.po
  16. 359 251
      locales/messages.pot
  17. 11 1
      pages.php
  18. 1 1
      pg-act/auth/approval.php
  19. 1 1
      pg-act/auth/login.php
  20. 0 3
      pg-act/auth/logout.php
  21. 1 1
      pg-act/auth/password.php
  22. 1 1
      pg-act/auth/register.php
  23. 1 1
      pg-act/auth/unregister.php
  24. 1 1
      pg-act/auth/username.php
  25. 1 1
      pg-act/ht/add-dns.php
  26. 1 1
      pg-act/ht/add-onion.php
  27. 1 1
      pg-act/ht/add-subdomain.php
  28. 1 1
      pg-act/ht/add-subpath.php
  29. 1 1
      pg-act/ht/del.php
  30. 1 1
      pg-act/ht/keys.php
  31. 1 1
      pg-act/ns/caa.php
  32. 1 1
      pg-act/ns/cname.php
  33. 1 1
      pg-act/ns/dname.php
  34. 34 33
      pg-act/ns/edit.php
  35. 1 1
      pg-act/ns/ip.php
  36. 1 1
      pg-act/ns/loc.php
  37. 1 1
      pg-act/ns/mx.php
  38. 1 1
      pg-act/ns/ns.php
  39. 1 1
      pg-act/ns/print.php
  40. 1 1
      pg-act/ns/srv.php
  41. 1 1
      pg-act/ns/sshfp.php
  42. 41 0
      pg-act/ns/sync.php
  43. 1 1
      pg-act/ns/tlsa.php
  44. 1 1
      pg-act/ns/txt.php
  45. 8 7
      pg-act/ns/zone-add.php
  46. 1 1
      pg-act/ns/zone-del.php
  47. 6 24
      pg-act/reg/ds.php
  48. 97 0
      pg-act/reg/edit.php
  49. 7 8
      pg-act/reg/glue.php
  50. 5 7
      pg-act/reg/ns.php
  51. 1 1
      pg-act/reg/print.php
  52. 2 2
      pg-act/reg/register.php
  53. 1 1
      pg-act/reg/transfer.php
  54. 1 1
      pg-act/reg/unregister.php
  55. 1 0
      pg-view/auth/approval.php
  56. 1 0
      pg-view/auth/index.php
  57. 1 0
      pg-view/auth/login.php
  58. 1 1
      pg-view/auth/logout.php
  59. 1 0
      pg-view/auth/password.php
  60. 1 0
      pg-view/auth/register.php
  61. 1 0
      pg-view/auth/unregister.php
  62. 1 0
      pg-view/auth/username.php
  63. 1 0
      pg-view/ht/add-dns.php
  64. 1 0
      pg-view/ht/add-onion.php
  65. 1 0
      pg-view/ht/add-subdomain.php
  66. 1 0
      pg-view/ht/add-subpath.php
  67. 2 1
      pg-view/ht/del.php
  68. 1 0
      pg-view/ht/index.php
  69. 5 4
      pg-view/ht/keys.php
  70. 1 0
      pg-view/index.php
  71. 1 0
      pg-view/ns/caa.php
  72. 1 0
      pg-view/ns/cname.php
  73. 1 0
      pg-view/ns/dname.php
  74. 14 13
      pg-view/ns/edit.php
  75. 14 12
      pg-view/ns/form.ns.php
  76. 1 0
      pg-view/ns/index.php
  77. 1 0
      pg-view/ns/ip.php
  78. 1 0
      pg-view/ns/loc.php
  79. 1 0
      pg-view/ns/mx.php
  80. 1 0
      pg-view/ns/ns.php
  81. 2 1
      pg-view/ns/print.php
  82. 1 0
      pg-view/ns/srv.php
  83. 1 0
      pg-view/ns/sshfp.php
  84. 39 0
      pg-view/ns/sync.php
  85. 1 0
      pg-view/ns/tlsa.php
  86. 1 0
      pg-view/ns/txt.php
  87. 1 0
      pg-view/ns/zone-add.php
  88. 1 0
      pg-view/ns/zone-del.php
  89. 3 14
      pg-view/reg/ds.php
  90. 54 0
      pg-view/reg/edit.php
  91. 3 14
      pg-view/reg/glue.php
  92. 22 3
      pg-view/reg/index.php
  93. 3 14
      pg-view/reg/ns.php
  94. 2 8
      pg-view/reg/print.php
  95. 1 0
      pg-view/reg/register.php
  96. 6 0
      pg-view/reg/select-action.inc.php
  97. 12 0
      pg-view/reg/select-domain.inc.php
  98. 1 0
      pg-view/reg/transfer.php
  99. 1 0
      pg-view/reg/unregister.php
  100. 5 5
      router.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;

+ 7 - 0
db/schema.sql

@@ -42,6 +42,13 @@ 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,
+	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,

+ 18 - 16
fn/auth.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 const USERNAME_REGEX = '^.{1,1024}$';
 const PASSWORD_REGEX = '^(?=.*[\p{Ll}])(?=.*[\p{Lu}])(?=.*[\p{N}]).{8,1024}|.{10,1024}$';
@@ -14,47 +14,47 @@ const OPTIONS_PASSWORD = [
 	'threads' => 64,
 ];
 
-function checkUsernameFormat($username) {
+function checkUsernameFormat(string $username): void {
 	if (preg_match('/' . USERNAME_REGEX . '/Du', $username) !== 1)
 		output(403, 'Username malformed.');
 }
 
-function checkPasswordFormat($password) {
+function checkPasswordFormat(string $password): void {
 	if (preg_match('/' . PASSWORD_REGEX . '/Du', $password) !== 1)
 		output(403, 'Password malformed.');
 }
 
-function hashUsername($username) {
+function hashUsername(string $username): string {
 	return base64_encode(sodium_crypto_pwhash(32, $username, hex2bin(query('select', 'params', ['name' => 'username_salt'], 'value')[0]), 2**10, 2**14, SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13));
 }
 
-function hashPassword($password) {
+function hashPassword(string $password): string {
 	return password_hash($password, ALGO_PASSWORD, OPTIONS_PASSWORD);
 }
 
-function usernameExists($username) {
+function usernameExists(string $username): bool {
 	return isset(query('select', 'users', ['username' => $username], 'id')[0]);
 }
 
-function checkPassword($id, $password) {
+function checkPassword(string $id, string $password): bool {
 	return password_verify($password, query('select', 'users', ['id' => $id], 'password')[0]);
 }
 
-function outdatedPasswordHash($id) {
+function outdatedPasswordHash(string $id): bool {
 	return password_needs_rehash(query('select', 'users', ['id' => $id], 'password')[0], ALGO_PASSWORD, OPTIONS_PASSWORD);
 }
 
-function changePassword($id, $password) {
+function changePassword(string $id, string $password): void {
 	DB->prepare('UPDATE users SET password = :password WHERE id = :id')
 		->execute([':password' => hashPassword($password), ':id' => $id]);
 }
 
-function stopSession() {
+function stopSession(): void {
 	if (session_status() === PHP_SESSION_ACTIVE)
 		session_destroy();
 }
 
-function logout() {
+function logout(): never {
 	stopSession();
 
 	header('Clear-Site-Data: "*"');
@@ -62,7 +62,7 @@ function logout() {
 	redir();
 }
 
-function setupDisplayUsername($display_username) {
+function setupDisplayUsername(string $display_username): void {
 	$nonce = random_bytes(24);
 	$key = sodium_crypto_aead_xchacha20poly1305_ietf_keygen();
 	$cyphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
@@ -87,7 +87,7 @@ function setupDisplayUsername($display_username) {
 	$_SESSION['display-username-cyphertext'] = $cyphertext;
 }
 
-function authDeleteUser($user_id) {
+function authDeleteUser(string $user_id): void {
 	$user_services = explode(',', query('select', 'users', ['id' => $user_id], 'services')[0]);
 
 	foreach (SERVICES_USER as $service)
@@ -133,12 +133,14 @@ function authDeleteUser($user_id) {
 		], result_code: $code);
 		if ($code !== 0)
 			output(500, 'Can\'t remove user\'s directory.');
+
+		query('delete', 'ssh-keys', ['username' => $user_id]);
 	}
 
 	query('delete', 'users', ['id' => $user_id]);
 }
 
-function rateLimit() {
+function rateLimit(): void {
 	if (PAGE_METADATA['tokens_account_cost'] ?? 0 > 0)
 		rateLimitAccount(PAGE_METADATA['tokens_account_cost']);
 
@@ -147,7 +149,7 @@ function rateLimit() {
 }
 
 const MAX_ACCOUNT_TOKENS = 86400;
-function rateLimitAccount($requestedTokens) {
+function rateLimitAccount(int $requestedTokens): int {
 	// Get
 	$userData = query('select', 'users', ['id' => $_SESSION['id']]);
 	$tokens = $userData[0]['bucket_tokens'];
@@ -174,7 +176,7 @@ function rateLimitAccount($requestedTokens) {
 	return $tokens;
 }
 
-function rateLimitInstance($requestedTokens) {
+function rateLimitInstance(int $requestedTokens): void {
 	// Get
 	$tokens = query('select', 'params', ['name' => 'instance_bucket_tokens'], 'value')[0];
 	$bucketLastUpdate = query('select', 'params', ['name' => 'instance_bucket_last_update'], 'value')[0];

+ 29 - 11
fn/common.php

@@ -1,6 +1,6 @@
-<?php
+<?php declare(strict_types=1);
 
-function output($code, $msg = '', $logs = [''], $data = []) {
+function output(int $code, string $msg = '', array $logs = [''], array $data = []): never {
 	http_response_code($code);
 	$shortCode = intval($code / 100);
 	if ($shortCode === 5)
@@ -21,7 +21,25 @@ function exescape(array $args, array &$output = NULL, int &$result_code = NULL):
 	return $result_code;
 }
 
-function insert($table, $values) {
+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 . '"(';
 
 	foreach ($values as $key => $val) {
@@ -44,7 +62,7 @@ function insert($table, $values) {
 	->execute($values);
 }
 
-function query($action, $table, $conditions = [], $column = NULL) {
+function query(string $action, string $table, array $conditions = [], string $column = NULL): array {
 
 	$query = match ($action) {
 		'select' => 'SELECT *',
@@ -66,7 +84,7 @@ function query($action, $table, $conditions = [], $column = NULL) {
 	return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), $column);
 }
 
-function displayIndex() { ?>
+function displayIndex(): void { ?>
 <nav>
 	<dl>
 <?php foreach (PAGES[SERVICE] as $pageId => $page) {
@@ -82,11 +100,11 @@ function displayIndex() { ?>
 <?php
 }
 
-function redirUrl($pageId) {
+function redirUrl(string $pageId): string {
 	return CONF['common']['prefix'] . '/' . $pageId . '?redir=' . PAGE_URL;
 }
 
-function redir($redir_to = NULL) {
+function redir(string $redir_to = NULL): never {
 	$redir_to ??= $_GET['redir'] ?? NULL;
 
 	if ($redir_to === NULL) {
@@ -100,7 +118,7 @@ function redir($redir_to = NULL) {
 }
 
 // PHP rmdir() only works on empty directories
-function removeDirectory($dir) {
+function removeDirectory(string $dir): void {
 	$dirObj = new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS);
 	$files = new RecursiveIteratorIterator($dirObj, RecursiveIteratorIterator::CHILD_FIRST);
 	foreach ($files as $file)
@@ -109,7 +127,7 @@ function removeDirectory($dir) {
 		output(500, 'Unable to remove directory.');
 }
 
-function equalArrays($a, $b) {
+function equalArrays(array $a, array $b): bool {
 	return array_diff($a, $b) === [] AND array_diff($b, $a) === [];
 }
 
@@ -126,12 +144,12 @@ if (time() - query('select', 'params', ['name' => 'secret_key_last_change'], 'va
 	->execute([':last_change' => time()]);
 }
 define('SECRET_KEY', hex2bin(query('select', 'params', ['name' => 'secret_key'], 'value')[0]));
-function getAuthToken() {
+function getAuthToken(): string {
 	$salt = bin2hex(random_bytes(4));
 	$hash = hash_hmac('sha256', $salt . ($_SESSION['id'] ?? ''), SECRET_KEY);
 	return $salt . '-' .  substr($hash, 0, 32);
 }
-function checkAuthToken($salt, $hash) {
+function checkAuthToken(string $salt, string $hash): void {
 	$correctProof = substr(hash_hmac('sha256', $salt . $_SESSION['id'], SECRET_KEY), 0, 32);
 	if (hash_equals($correctProof, $hash) !== true)
 		output(403, _('Wrong proof.'));

+ 18 - 15
fn/dns.php

@@ -1,16 +1,19 @@
-<?php
+<?php declare(strict_types=1);
 
-function parseZoneFile($zone_content, $types, $filter_domain = false) {
+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;
 }
@@ -46,7 +49,7 @@ function knotcConfExec(array $cmds): void {
 	}
 }
 
-function knotcZoneExec(string $zone, array $cmd, string $action = NULL) {
+function knotcZoneExec(string $zone, array $cmd, string $action = NULL): void {
 	$action = checkAction($action ?? $_POST['action']);
 
 	knotc(['zone-begin', $zone], $output['begin'], $code['begin']);
@@ -66,32 +69,32 @@ function knotcZoneExec(string $zone, array $cmd, string $action = NULL) {
 	}
 }
 
-function checkIpFormat($ip) {
-	if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4))
-		return 'A';
-	if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))
-		return 'AAAA';
-	output(403, _('IP address malformed.'));
+function checkIpFormat(string $ip): string {
+	return match ($ip) {
+		filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) => 'AAAA',
+		filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) => 'A',
+		default => output(403, _('IP address malformed.')),
+	};
 }
 
-function checkAbsoluteDomainFormat($domain) { // If the domain must end with a dot
+function checkAbsoluteDomainFormat(string $domain): void { // If the domain must end with a dot
 	if (!filter_var($domain, FILTER_VALIDATE_DOMAIN) OR preg_match('/^(?=^.{1,254}$)([a-z0-9_-]{1,63}\.){2,127}$/D', $domain) !== 1)
 		output(403, _('Domain malformed.'));
 }
 
-function formatEndWithDot($str) {
+function formatEndWithDot(string $str): string {
 	if (!str_ends_with($str, '.'))
 		$str .= '.';
 	return $str;
 }
 
-function formatAbsoluteDomain($domain) {
+function formatAbsoluteDomain(string $domain): string {
 	$domain = formatEndWithDot(strtolower($domain));
 	checkAbsoluteDomainFormat($domain);
 	return $domain;
 }
 
-function checkAction($action) {
+function checkAction(string $action): string {
 	return match ($action) {
 		'add' => '',
 		'delete' => 'un',

+ 9 - 9
fn/ht.php

@@ -1,9 +1,9 @@
-<?php
+<?php declare(strict_types=1);
 
 const SUBPATH_REGEX = '^[a-z0-9-]{4,63}$';
 const ED25519_PUBKEY_REGEX = '^[a-zA-Z0-9/+]{68}$';
 
-function htSetupUserFs($id) {
+function htSetupUserFs(string $id): void {
 	// Setup SFTP directory
 	if (mkdir(CONF['ht']['ht_path'] . '/fs/' . $id, 0000) !== true)
 		output(500, 'Can\'t create user directory.');
@@ -42,19 +42,19 @@ function htSetupUserFs($id) {
 		output(500, 'Can\'t create Tor keys directory.');
 }
 
-function checkDomainFormat($domain) {
+function checkDomainFormat(string $domain): void {
 	// If the domain must end without a dot
 	if (!filter_var($domain, FILTER_VALIDATE_DOMAIN) OR !preg_match('/^(?=^.{1,254}$)([a-z0-9_-]{1,63}\.){1,126}[a-z0-9]{1,63}$/D', $domain))
 		output(403, _('Domain malformed.'));
 }
 
-function formatDomain($domain) {
+function formatDomain(string $domain): string {
 	$domain = rtrim(strtolower($domain), '.');
 	checkDomainFormat($domain);
 	return $domain;
 }
 
-function listFsDirs($username) {
+function listFsDirs(string $username): array {
 	if ($username === '')
 		return [];
 	$absoluteDirs = glob(CONF['ht']['ht_path'] . '/fs/' . $username . '/*/', GLOB_ONLYDIR);
@@ -65,7 +65,7 @@ function listFsDirs($username) {
 	return $dirs;
 }
 
-function addSite($username, $siteDir, $address, $type) {
+function addSite(string $username, string $siteDir, string $address, string $type): void {
 	insert('sites', [
 		'username' => $username,
 		'site_dir' => $siteDir,
@@ -75,7 +75,7 @@ function addSite($username, $siteDir, $address, $type) {
 	]);
 }
 
-function dirsStatuses($type) {
+function dirsStatuses(string $type): array {
 	if (isset($_SESSION['id']) !== true)
 		return [];
 	$dbDirs = query('select', 'sites', [
@@ -88,7 +88,7 @@ function dirsStatuses($type) {
 	return $dirs;
 }
 
-function htRelativeSymlink($target, $name) {
+function htRelativeSymlink(string $target, string $name): void {
 	chdir(pathinfo($name)['dirname']);
 	$symlink = symlink($target, pathinfo($name)['basename']);
 	chdir(ROOT_PATH);
@@ -96,7 +96,7 @@ function htRelativeSymlink($target, $name) {
 		output(500, 'Unable to create symlink.');
 }
 
-function htDeleteSite($address, $type, $user_id) {
+function htDeleteSite(string $address, string $type, string $user_id): void {
 
 	if ($type === 'onion') {
 		$dir = query('select', 'sites', [

+ 19 - 15
fn/ns.php

@@ -1,6 +1,6 @@
-<?php
+<?php declare(strict_types=1);
 
-const SOA_VALUES = [
+const NS_SOA_VALUES = [
 	'ttl' => 10800,
 	'email' => CONF['ns']['public_soa_email'],
 	'refresh' => 10800,
@@ -9,15 +9,17 @@ const SOA_VALUES = [
 	'negative' => 10800,
 ];
 
-const MIN_TTL = 300;
-const DEFAULT_TTL = 10800;
-const MAX_TTL = 1728000;
+const NS_MIN_TTL = 300;
+const NS_DEFAULT_TTL = 10800;
+const NS_MAX_TTL = 1728000;
 
-const ALLOWED_TYPES = ['AAAA', 'A', 'TXT', 'SRV', 'MX', 'SVCB', 'HTTPS', 'NS', 'DS', 'CAA', 'CNAME', 'DNAME', 'LOC', 'SSHFP', 'TLSA'];
+const NS_ALLOWED_TYPES = ['AAAA', 'A', 'TXT', 'SRV', 'MX', 'SSHFP', 'TLSA', 'NS', 'DS', 'CSYNC', 'CAA', 'CNAME', 'DNAME', 'SVCB', 'HTTPS', 'LOC'];
 
-const ZONE_MAX_CHARACTERS = 10000;
+const NS_TEXTAREA_MAX_CHARACTERS = 10000;
 
-function nsParseCommonRequirements() {
+const NS_SYNC_TTL = 10800;
+
+function nsParseCommonRequirements(): array {
 	nsCheckZonePossession($_POST['zone']);
 
 	if (($_POST['subdomain'] === '') OR ($_POST['subdomain'] === '@'))
@@ -27,28 +29,28 @@ function nsParseCommonRequirements() {
 
 	$values['ttl'] = intval($_POST['ttl-value'] * $_POST['ttl-multiplier']);
 
-	if ($values['ttl'] < MIN_TTL)
-		output(403, sprintf(_('TTLs shorter than %s seconds are forbidden.'), MIN_TTL));
-	if ($values['ttl'] > MAX_TTL)
-		output(403, sprintf(_('TTLs longer than %s seconds are forbidden.'), MAX_TTL));
+	if ($values['ttl'] < NS_MIN_TTL)
+		output(403, sprintf(_('TTLs shorter than %s seconds are forbidden.'), NS_MIN_TTL));
+	if ($values['ttl'] > NS_MAX_TTL)
+		output(403, sprintf(_('TTLs longer than %s seconds are forbidden.'), NS_MAX_TTL));
 
 	return $values;
 }
 
-function nsListUserZones() {
+function nsListUserZones(): array {
 	if (isset($_SESSION['id']))
 		return query('select', 'zones', ['username' => $_SESSION['id']], 'zone');
 	return [];
 }
 
-function nsCheckZonePossession($zone) {
+function nsCheckZonePossession(string $zone): void {
 	checkAbsoluteDomainFormat($zone);
 
 	if (!in_array($zone, nsListUserZones(), true))
 		output(403, 'You don\'t own this zone on the name server.');
 }
 
-function nsDeleteZone($zone, $user_id) {
+function nsDeleteZone(string $zone, string $user_id): void {
 	// Remove from Knot configuration
 	knotcConfExec([['conf-unset', 'zone[' . $zone . ']']]);
 
@@ -71,6 +73,8 @@ function nsDeleteZone($zone, $user_id) {
 	if ($code !== 0)
 		output(500, 'Failed to purge zone data.');
 
+	query('delete', 'ns-syncs', ['destination' => $zone]);
+
 	// Remove from database
 	query('delete', 'zones', [
 		'zone' => $zone,

+ 64 - 9
fn/reg.php

@@ -1,26 +1,31 @@
-<?php
+<?php declare(strict_types=1);
 
 const SUBDOMAIN_REGEX = '^(?!\-)(?!..\-\-)[a-z0-9-]{4,63}(?<!\-)$';
 
-function regListUserDomains() {
+const REG_TEXTAREA_MAX_CHARACTERS = 5000;
+const REG_ALLOWED_TYPES = ['NS', 'DS', 'AAAA', 'A'];
+
+function regListUserDomains(): array {
 	if (isset($_SESSION['id']))
 		return query('select', 'registry', ['username' => $_SESSION['id']], 'domain');
 	return [];
 }
 
-function regCheckDomainPossession($domain) {
+function regCheckDomainPossession(string $domain): void {
 	if (in_array($domain, regListUserDomains(), true) !== true)
 		output(403, 'You don\'t own this domain on the registry.');
 }
 
-function regDeleteDomain($domain, $user_id) {
+function regStripDomain(string $domain, string $content): string {
+	return preg_replace('/^(?:[a-z0-9._-]+\.)?' . preg_quote($domain, '/') . '[\t ]+.+$/Dm', '', $content);
+}
+
+function regDeleteDomain(string $domain, string $user_id): void {
 	// Delete domain from registry file
 	$path = CONF['reg']['suffixes_path'] . '/' . regParseDomain($domain)['suffix'] . 'zone';
-	$content = file_get_contents($path);
-	if ($content === false)
+	if (($content = file_get_contents($path)) === false)
 		output(500, 'Failed to read current registry file.');
-	$content = preg_replace('/^(?:[a-z0-9._-]+\.)?' . preg_quote($domain, '/') . '[\t ]+.+$/Dm', '', $content);
-	if (file_put_contents($path, $content) === false)
+	if (file_put_contents($path, regStripDomain($domain, $content)) === false)
 		output(500, 'Failed to write new registry file.');
 
 	try {
@@ -46,7 +51,7 @@ function regDeleteDomain($domain, $user_id) {
 	}
 }
 
-function regParseDomain($domain) {
+function regParseDomain(string $domain): array {
 	$parts = explode('.', $domain, 2);
 	$subdomain = $parts[0];
 	$suffix = $parts[1];
@@ -59,3 +64,53 @@ function regParseDomain($domain) {
 		'suffix' => $suffix,
 	];
 }
+
+function regParseRecord(string $domain, array $record): array {
+	checkAbsoluteDomainFormat($record['domain']);
+
+	if ($record['domain'] !== $_POST['domain']) {
+		if ($record['type'] !== 'ip')
+			output(403, _('You can only set a NS/DS record for an apex domain.'));
+		else if (!str_ends_with($record['domain'], '.' . $domain))
+			output(403, _('You can\'t set a record for another domain.'));
+	}
+
+	$new_rec = [
+		$record['domain'],
+		CONF['reg']['ttl'],
+		$record['type'],
+	];
+	if ($record['type'] === 'DS') {
+		if (!in_array($record['algo'], ['8', '13', '14', '15', '16'], true))
+			output(403, 'Wrong value for <code>algo</code>.');
+
+		if ((preg_match('/^[0-9]{1,6}$/D', $record['keytag'])) !== 1 OR !($record['keytag'] >= 1) OR !($record['keytag'] <= 65535))
+			output(403, 'Wrong value for <code>keytag</code>.');
+
+		if ($record['dt'] !== '2' AND $record['dt'] !== '4')
+			output(403, 'Wrong value for <code>dt</code>.');
+
+		if (preg_match('/^(?:[0-9a-fA-F]{64}|[0-9a-fA-F]{96})$/D', $record['key']) !== 1)
+			output(403, 'Wrong value for <code>key</code>.');
+
+		return [
+			...$new_rec,
+			$record['keytag'],
+			$record['algo'],
+			$record['dt'],
+			$record['key'],
+		];
+	}
+	if ($record['type'] === 'NS')
+		return [
+			...$new_rec,
+			formatAbsoluteDomain($record['ns']),
+		];
+	if ($record['type'] === 'ip')
+		return [
+			$record['domain'],
+			CONF['reg']['ttl'],
+			checkIpFormat($record['ip']),
+			$record['ip'],
+		];
+}

+ 3 - 3
init.php

@@ -1,13 +1,13 @@
-<?php
+<?php declare(strict_types=1);
 umask(0077);
 
 set_error_handler(function ($level, $message, $file = '', $line = 0) {
 	throw new ErrorException($message, 0, $level, $file, $line);
 });
 set_exception_handler(function ($e) {
-	error_log($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)

+ 57 - 17
jobs/check.php

@@ -1,6 +1,7 @@
-<?php // Test that the current setup is working
+<?php declare(strict_types=1);
+// Test that the current setup is working
 
-require 'init.php';
+require __DIR__ . '/../init.php';
 
 const SFTP = '/usr/bin/sftp';
 const SSHPASS = '/usr/bin/sshpass';
@@ -18,7 +19,7 @@ if (preg_match('/^;; Flags: qr rd ra ad;/Dm', implode("\n", $output)) !== 1)
 
 define('COOKIE_FILE', sys_get_temp_dir() . '/cookie-' . bin2hex(random_bytes(16)) . '.txt');
 
-function curlTest($address, $post = [], $tor = false) {
+function curlTest(string $address, array $post = [], bool $tor = false): string {
 	$req = curl_init();
 	curl_setopt($req, CURLOPT_RETURNTRANSFER, true);
 
@@ -47,7 +48,7 @@ function curlTest($address, $post = [], $tor = false) {
 	if ($status_code >= 400 OR $result === false) {
 		var_dump($result);
 		var_dump(curl_error($req));
-		exit($address . ' test failed with status code ' . $status_code . LF);
+		throw new Exception($address . ' test failed with status code ' . $status_code);
 	}
 	return $result;
 }
@@ -60,13 +61,6 @@ curlTest('/auth/register', [
 	'password' => $password,
 ]);
 
-curlTest('/auth/logout', []);
-
-curlTest('/auth/login', [
-	'username' => $username,
-	'password' => $password,
-]);
-
 $new_password = bin2hex(random_bytes(16));
 curlTest('/auth/password', [
 	'current-password' => $password,
@@ -74,9 +68,20 @@ curlTest('/auth/password', [
 ]);
 $password = $new_password;
 
+curlTest('/auth/register', [
+	'username' => $username . '2',
+	'password' => $password,
+]);
+curlTest('/auth/logout', []);
+
+curlTest('/auth/login', [
+	'username' => $username,
+	'password' => $password,
+]);
+
 curlTest('/auth/username', [
 	'current-password' => $password,
-	'new-username' => $username . '2',
+	'new-username' => $username . '3',
 ]);
 curlTest('/auth/username', [
 	'current-password' => $password,
@@ -85,7 +90,9 @@ curlTest('/auth/username', [
 
 echo 'Created account with username "' . $username . '" and password "' . $password . '".' . LF;
 
-function testReg() {
+function testReg(): string {
+	global $username, $password;
+
 	$subdomain = bin2hex(random_bytes(16));
 
 	curlTest('/reg/register', [
@@ -116,10 +123,43 @@ function testReg() {
 		'ns' => 'ns1.servnest.invalid.',
 	]);
 
+	{ // Domain transfer
+		curlTest('/auth/logout', []);
+		curlTest('/auth/login', [
+			'username' => $username . '2',
+			'password' => $password,
+		]);
+		preg_match('#\<code\>(?<token>[0-9a-z-]{16,128}\._transfer-verification\.' . preg_quote(CORE_DOMAIN, '#') . '\.)\</code\>#', curlTest('/reg/transfer', []), $matches);
+
+		curlTest('/auth/logout', []);
+		curlTest('/auth/login', [
+			'username' => $username,
+			'password' => $password,
+		]);
+		curlTest('/reg/ns', [
+			'action' => 'add',
+			'domain' => $domain,
+			'ns' => $matches['token'],
+		]);
+
+		curlTest('/auth/logout', []);
+		curlTest('/auth/login', [
+			'username' => $username . '2',
+			'password' => $password,
+		]);
+		curlTest('/reg/transfer', [
+			'subdomain' => $subdomain,
+			'suffix' => SUFFIX,
+			'ns' => $matches['token'],
+		]);
+
+		$username = $username . '2';
+	}
+
 	return $domain;
 }
 
-function testNs($domain) {
+function testNs(string $domain): void {
 	foreach (CONF['ns']['servers'] as $ns)
 		curlTest('/reg/ns', [
 			'action' => 'add',
@@ -164,8 +204,8 @@ function testNs($domain) {
 		exit('Error: /ns/caa: CAA record not set' . LF);
 
 	curlTest('/ns/edit', [
-		'zone' => $domain,
-		'zone-content' => 'aaaa.' . $domain . ' 3600 AAAA ' . CONF['ht']['ipv6_address'] . "\r\n"
+		'domain' => $domain,
+		'records' => 'aaaa.' . $domain . ' 3600 AAAA ' . CONF['ht']['ipv6_address'] . "\r\n"
 			. '@ 86400 NS ' . CONF['ns']['servers'][0] . "\r\n",
 	]);
 	exescape([
@@ -178,7 +218,7 @@ function testNs($domain) {
 		exit('Error: /ns/edit: AAAA record not set' . LF);
 }
 
-function testHt($username, $password) {
+function testHt(string $username, string $password): void {
 	define('TEST_CONTENT', 'test-' . bin2hex(random_bytes(16)));
 
 	file_put_contents(sys_get_temp_dir() . '/index.html', TEST_CONTENT);

+ 2 - 2
jobs/delete-old-testing.php

@@ -1,5 +1,5 @@
-<?php
-require 'init.php';
+<?php declare(strict_types=1);
+require __DIR__ . '/../init.php';
 
 const MAX_TESTING_ACCOUNT_AGE = 86400 * 10;
 

+ 42 - 0
jobs/ns-sync.php

@@ -0,0 +1,42 @@
+<?php declare(strict_types=1);
+require __DIR__ . '/../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', 'CAA'] as $type) {
+		// Get source/distant records
+		try {
+			$results = kdig(name: $sync['source'], type: $type);
+		} catch (KdigException) {
+			fwrite(STDERR, $sync['source'] . ' resolution failed.' . LF);
+			break;
+		}
+		if ($results['AD'] !== 1) {
+			fwrite(STDERR, $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');
+	}
+}

+ 57 - 0
jobs/reg-cds.php

@@ -0,0 +1,57 @@
+<?php declare(strict_types=1);
+/*
+	RFC 7344: Automating DNSSEC Delegation Trust Maintenance
+	RFC 8078: Managing DS Records from the Parent via CDS/CDNSKEY
+*/
+require __DIR__ . '/../init.php';
+
+foreach (query('select', 'registry') as $entry) {
+	$suffix = regParseDomain($entry['domain'])['suffix'];
+
+	// Get child/distant records
+	try {
+		$results = kdig(name: $entry['domain'], type: 'CDS');
+	} catch (KdigException) {
+		fwrite(STDERR, $entry['domain'] . ' resolution failed.' . LF);
+		continue;
+	}
+	if ($results['AD'] !== 1) // No DNSSEC
+		continue;
+	$cds_records = array_column($results['answerRRs'] ?? [], 'rdataCDS');
+
+	// Skip if no updates
+	if ($cds_records === [])
+		continue;
+
+	// Get parent/local CDS records
+	$zone_raw = file_get_contents(CONF['reg']['suffixes_path'] . '/' . $suffix . 'zone');
+	if ($zone_raw === false)
+		output(403, 'Unable to read zone file.');
+	$pds_records = array_column(parseZoneFile($zone_raw, ['DS'], $entry['domain'], false), 3);
+
+	if ($cds_records === ['0 0 0 0'])
+		// Delete every parent DS records
+		foreach ($pds_records as $value_to_delete)
+			knotcZoneExec($suffix, [
+				$entry['domain'],
+				'DS',
+				$value_to_delete,
+			], 'delete');
+	else {
+		// Add child DS records that are not yet in parent
+		foreach (array_diff($cds_records, $pds_records) as $value_to_add)
+			knotcZoneExec($suffix, [
+				$entry['domain'],
+				CONF['reg']['ttl'],
+				'DS',
+				$value_to_add,
+			], 'add');
+		// Delete parent DS records that are not part of child anymore
+		foreach (array_diff($pds_records, $cds_records) as $value_to_delete)
+			knotcZoneExec($suffix, [
+				$entry['domain'],
+				'DS',
+				$value_to_delete,
+			], 'delete');
+	}
+}

+ 61 - 0
jobs/reg-csync.php

@@ -0,0 +1,61 @@
+<?php declare(strict_types=1);
+/*
+	RFC 7477: Child-to-Parent Synchronization in DNS
+*/
+require __DIR__ . '/../init.php';
+
+foreach (query('select', 'registry') as $entry) {
+	$suffix = regParseDomain($entry['domain'])['suffix'];
+
+	// Get child/distant CSYNC records
+	try {
+		$results = kdig(name: $entry['domain'], type: 'CSYNC');
+	} catch (KdigException) {
+		fwrite(STDERR, $entry['domain'] . ' CSYNC resolution failed.' . LF);
+		continue;
+	}
+	if ($results['AD'] !== 1)
+		continue;
+	if (count($results['answerRRs'] ?? []) !== 1) // Parental agents MUST ignore a child's CSYNC RDATA set if multiple CSYNC resource records are found; only a single CSYNC record should ever be present.
+		continue;
+	list($serial, $flags, $types) = explode(' ', $results['answerRRs'][0]['rdataCSYNC'], 3);
+	if ($flags !== '1')
+		continue; // Skip unsupported flags
+	if ($types !== 'NS')
+		continue; // Skip unsupported types
+
+	// Get child/distant NS records
+	try {
+		$results = kdig(name: $entry['domain'], type: 'NS');
+	} catch (KdigException) {
+		fwrite(STDERR, $entry['domain'] . ' NS resolution failed.' . LF);
+		continue;
+	}
+	if ($results['AD'] !== 1)
+		continue;
+	$child_ns_records = array_column($results['answerRRs'] ?? [], 'rdataNS');
+	if (count($child_ns_records) === []) // Parental agents MUST NOT perform NS updates if there are no NS records returned in a query, as verified by DNSSEC denial-of-existence protection.
+		continue;
+
+	// Get parent/local NS records
+	$zone_raw = file_get_contents(CONF['reg']['suffixes_path'] . '/' . $suffix . 'zone');
+	if ($zone_raw === false)
+		output(403, 'Unable to read zone file.');
+	$parent_ns_records = array_column(parseZoneFile($zone_raw, ['NS'], $entry['domain'], false), 3);
+
+	// Add child NS records that are not yet in parent
+	foreach (array_diff($child_ns_records, $parent_ns_records) as $value_to_add)
+		knotcZoneExec($suffix, [
+			$entry['domain'],
+			CONF['reg']['ttl'],
+			'NS',
+			$value_to_add,
+		], 'add');
+	// Delete parent NS records that are not part of child anymore
+	foreach (array_diff($parent_ns_records, $child_ns_records) as $value_to_delete)
+		knotcZoneExec($suffix, [
+			$entry['domain'],
+			'NS',
+			$value_to_delete,
+		], 'delete');
+}

+ 363 - 255
locales/fr/C/LC_MESSAGES/messages.po

@@ -1,7 +1,7 @@
 msgid ""
 msgstr ""
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2023-06-15 01:33+0200\n"
+"POT-Creation-Date: 2023-07-31 01:03+0200\n"
 "Language: fr\n"
 "Content-Type: text/plain; charset=UTF-8\n"
 
@@ -13,8 +13,8 @@ msgstr "Authentification"
 msgid "Manage account"
 msgstr "Gérer son compte"
 
-#: pages.php:13 view.php:21 pg-view/auth/login.php:12
-#: pg-view/auth/register.php:1
+#: pages.php:13 view.php:22 pg-view/auth/login.php:13
+#: pg-view/auth/register.php:2
 msgid "Log in"
 msgstr "Se connecter"
 
@@ -22,7 +22,7 @@ msgstr "Se connecter"
 msgid "Start a new navigation session with an existing account"
 msgstr "Démarrer une nouvelle session avec un compte existant"
 
-#: pages.php:18 view.php:19
+#: pages.php:18 view.php:20
 msgid "Log out"
 msgstr "Se déconnecter"
 
@@ -105,180 +105,197 @@ msgstr "Afficher les enregistrements"
 msgid "Print every record related to a domain and served by the registry"
 msgstr "Montrer tous les enregistrements liés à un domaine et servis par le registre"
 
-#: pages.php:67 pages.php:72 pages.php:117 pages.php:122 pages.php:127
-#: pages.php:132 pages.php:137 pages.php:142 pages.php:147 pages.php:152
-#: pages.php:157 pages.php:162
+#: pages.php:67
+msgid "Edit records"
+msgstr "Modifier des enregistrements"
+
+#: pages.php:68
+msgid "Set registry records to delegate a domain to chosen name servers"
+msgstr "Définir les enregistrements du registre pour déléguer un domaine à des serveurs de noms de son choix"
+
+#: pages.php:72 pages.php:77 pages.php:122 pages.php:127 pages.php:132
+#: pages.php:137 pages.php:142 pages.php:147 pages.php:152 pages.php:157
+#: pages.php:162 pages.php:167
 #, php-format
 msgid "%s records"
 msgstr "Enregistrements %s"
 
-#: pages.php:68
+#: pages.php:73
 #, php-format
 msgid "Indicate the name servers of a %s subdomain"
 msgstr "Indiquer les serveurs de nom d'un sous-domaine de %s"
 
-#: pages.php:73
+#: pages.php:78
 msgid "Delegate <abbr title=\"Domain Name System Security Extensions\">DNSSEC</abbr> trust"
 msgstr "Déléguer la confiance <abbr title=\"Domain Name System Security Extensions\">DNSSEC</abbr>"
 
-#: pages.php:77
+#: pages.php:82
 msgid "Receive a domain transfer"
 msgstr "Recevoir un transfert de domaine"
 
-#: pages.php:78
+#: pages.php:83
 msgid "Transfer a domain owned by another account to the current account"
 msgstr "Transférer vers le compte actuel un domaine possédé par un autre compte"
 
-#: pages.php:82
+#: pages.php:87
 msgid "Glue records"
 msgstr "Glue records"
 
-#: pages.php:83
+#: pages.php:88
 msgid "Advanced: store the IP address of a name server whose domain is inside the domain it serves"
 msgstr "Avancé&nbsp;: indiquer l'adresse IP d'un serveur de nom dont le domaine descend de la zone qu'il sert"
 
-#: pages.php:89 pg-view/ns/index.php:24
+#: pages.php:94 pg-view/ns/index.php:25
 msgid "Name servers"
 msgstr "Serveurs de nom"
 
-#: pages.php:90
+#: pages.php:95
 msgid "Host and manage domain's records"
 msgstr "Héberger et gérer les enregistrements d'un domaine"
 
-#: pages.php:93
+#: pages.php:98
 msgid "Add zone"
 msgstr "Ajouter une zone"
 
-#: pages.php:94
+#: pages.php:99
 #, php-format
 msgid "The zone will be managed by %s name servers"
 msgstr "La zone sera gérée par les serveurs de nom de %s"
 
-#: pages.php:98
+#: pages.php:103
 msgid "Delete zone"
 msgstr "Supprimer une zone"
 
-#: pages.php:99
+#: pages.php:104
 msgid "Erase all zone data"
 msgstr "Effacer tous les enregistrements d'une zone"
 
-#: pages.php:102
+#: pages.php:107
 msgid "Display zone"
 msgstr "Afficher une zone"
 
-#: pages.php:103
+#: pages.php:108
 msgid "Print zonefile content"
 msgstr "Montrer le contenu d'un fichier de zone"
 
-#: pages.php:107
+#: pages.php:112
 msgid "Edit zone"
 msgstr "Modifier une zone"
 
-#: pages.php:108
+#: pages.php:113
 msgid "Change zonefile content"
 msgstr "Éditer le contenu d'un fichier de zone"
 
-#: pages.php:112
+#: pages.php:117
 msgid "AAAA and A records"
 msgstr "Enregistrements AAAA et A"
 
-#: pages.php:113
+#: pages.php:118
 msgid "Store domain's IP address"
 msgstr "Indiquer l'adresse IP d'un domaine"
 
-#: pages.php:118
+#: pages.php:123
 msgid "Store zone's name server"
 msgstr "Indiquer les serveurs de nom d'une zone"
 
-#: pages.php:123
+#: pages.php:128
 msgid "Associate text to domain"
 msgstr "Associer du texte à un domaine"
 
-#: pages.php:128
+#: pages.php:133
 msgid "Limit the certificate authorities allowed to certify the domain"
 msgstr "Limiter les autorités de certification autorisées à certifier un domaine"
 
-#: pages.php:133
+#: pages.php:138
 msgid "Store the location of a domain's service"
 msgstr "Indiquer l'adresse exacte d'un service pour un domaine"
 
-#: pages.php:138
+#: pages.php:143
 msgid "Store the email server's address"
 msgstr "Indiquer l'adresse d'un serveur de courriels"
 
-#: pages.php:143
+#: pages.php:148
 msgid "Store <abbr title=\"Secure SHell\">SSH</abbr> public keys fingerprints"
 msgstr "Indiquer les empreintes de clés publiques <abbr title=\"Secure SHell\">SSH</abbr>"
 
-#: pages.php:148
+#: pages.php:153
 msgid "Setup <abbr title=\"DNS-based Authentication of Named Entities\">DANE</abbr> by publishing the <abbr title=\"Transport Layer Security\">TLS</abbr> certificate fingerprint"
 msgstr "Mettre en place <abbr title=\"DNS-based Authentication of Named Entities\">DANE</abbr> et publiant l'empreinte d'un certificat <abbr title=\"Transport Layer Security\">TLS</abbr>"
 
-#: pages.php:153
+#: pages.php:158
 msgid "Define a domain as an alias of another"
 msgstr "Définir un domaine comme étant l'alias d'un autre"
 
-#: pages.php:158
+#: pages.php:163
 msgid "Define all subdomains of a domain as aliases of subdomains of another domain"
 msgstr "Définir la descendance d'un domaine comme alias de la descendance d'un autre domaine"
 
-#: pages.php:163
+#: pages.php:168
 msgid "Store geographic coordinates"
 msgstr "Indiquer des coordonnées géographiques"
 
-#: pages.php:169
+#: pages.php:172
+#, php-format
+msgid "Synchronized records"
+msgstr "Enregistrements synchronisés"
+
+#: pages.php:173
+msgid "Regularly fetch distant records and update them to a local zone"
+msgstr "Récupérer régulièrement des enregistrements distants et les mettre à jour vers une zone locale"
+
+#: pages.php:179
 msgid "Web"
 msgstr "Web"
 
-#: pages.php:170
+#: pages.php:180
 msgid "Upload a static website into an <abbr title=\"SSH File Transfer Protocol\">SFTP</abbr> space"
 msgstr "Téléverser un site statique dans un espace <abbr title=\"SSH File Transfert Protocol\">SFTP</abbr>"
 
-#: pages.php:173
+#: pages.php:183
 #, php-format
 msgid "%s subpath access"
 msgstr "Accès par sous-chemin de %s"
 
-#: pages.php:174 pages.php:179 pages.php:184
+#: pages.php:184 pages.php:189 pages.php:194
 #, php-format
 msgid "Its URL will look like %s"
 msgstr "Son URL ressemblera à %s"
 
-#: pages.php:174 pages.php:179 pages.php:184
+#: pages.php:184 pages.php:189 pages.php:194
 msgid "mysite"
 msgstr "monsite"
 
-#: pages.php:178
+#: pages.php:188
 #, php-format
 msgid "%s subdomain access"
 msgstr "Accès par sous-domaine de %s"
 
-#: pages.php:183
+#: pages.php:193
 msgid "Dedicated domain with Let's Encrypt certificate access"
 msgstr "Accès par domaine dédié avec certificat Let's Encrypt"
 
-#: pages.php:188
+#: pages.php:198
 msgid "Onion service access"
 msgstr "Accès par service Onion"
 
-#: pages.php:189
+#: pages.php:199
 #, php-format
 msgid "Its URL will look like %s, and work only through the Tor network"
 msgstr "Son URL ressemblera à %s, et ne fonctionnera que par le réseau Tor"
 
-#: pages.php:193 pg-view/ht/del.php:18
+#: pages.php:203 pg-view/ht/del.php:19
 msgid "Delete access"
 msgstr "Supprimer un accès"
 
-#: pages.php:194
+#: pages.php:204
 msgid "Delete an existing HTTP access from a subdirectory of the SFTP space"
 msgstr "Retirer un accès HTTP existant d'un sous-dossier de l'espace SFTP"
 
-#: pages.php:197
+#: pages.php:207
 msgid "Manage SSH keys"
 msgstr "Gérer les clés SSH"
 
-#: pages.php:198
+#: pages.php:208
 msgid "Choose what SSH key can edit what directory"
 msgstr "Choisir quelle clé SSH peut modifier quel dossier"
 
@@ -286,7 +303,7 @@ msgstr "Choisir quelle clé SSH peut modifier quel dossier"
 msgid "This account doesn't exist anymore. Log out to end this ghost session."
 msgstr "Ce compte n'existe plus. Déconnectez-vous pour terminer cette session fantôme."
 
-#: router.php:106 view.php:39
+#: router.php:106 view.php:40
 msgid "This service is currently under maintenance. No action can be taken on it until an administrator finishes repairing it."
 msgstr "Ce service est en cours de maintenance. Aucune action ne peut être effectuée avant qu'ane administrataire termine de le réparer."
 
@@ -294,24 +311,24 @@ msgstr "Ce service est en cours de maintenance. Aucune action ne peut être effe
 msgid "You need to be logged in to do this."
 msgstr "Vous devez être connecté·e à un compte pour faire cela."
 
-#: view.php:19
+#: view.php:20
 msgid "You are using a testing account. It may be deleted anytime."
 msgstr "Vous utilisez un compte de test. Il risque d'être supprimé n'importe quand."
 
-#: view.php:19
+#: view.php:20
 msgid "Read more"
 msgstr "En savoir plus"
 
-#: view.php:21
+#: view.php:22
 msgid "Anonymous"
 msgstr "Anonyme"
 
-#: view.php:44
+#: view.php:45
 #, php-format
 msgid "This form won't be accepted because you need to %slog in%s first."
 msgstr "Ce formulaire ne sera pas accepté car il faut %sse connecter%s d'abord."
 
-#: view.php:51
+#: view.php:52
 #, php-format
 msgid "%sSource code%s available under %s."
 msgstr "%sCode source%s disponible sous %s."
@@ -321,11 +338,11 @@ msgstr "%sCode source%s disponible sous %s."
 msgid "Your account can't be deleted because the %s service is currently unavailable."
 msgstr "Votre compte ne peut pas être supprimé car le service %s est actuellement indisponible."
 
-#: fn/auth.php:143
+#: fn/auth.php:163
 msgid "Account rate limit reached, try again later."
 msgstr "Limite de taux pour ce compte atteinte, réessayez plus tard."
 
-#: fn/auth.php:168
+#: fn/auth.php:188
 msgid "Global rate limit reached, try again later."
 msgstr "Limite de taux globale atteinte, réessayez plus tard."
 
@@ -341,29 +358,37 @@ msgstr "<strong>Erreur de l'utilisataire</strong>&nbsp;: "
 msgid "<strong>Server error</strong>: "
 msgstr "<strong>Erreur du serveur</strong>&nbsp;: "
 
-#: fn/common.php:132
+#: fn/common.php:155
 msgid "Wrong proof."
 msgstr "Preuve incorrecte."
 
-#: fn/dns.php:64
+#: fn/dns.php:76
 msgid "IP address malformed."
 msgstr "Adresse IP malformée."
 
-#: fn/dns.php:69 fn/ht.php:31
+#: fn/dns.php:82 fn/ht.php:48
 msgid "Domain malformed."
 msgstr "Domaine malformé."
 
-#: fn/ns.php:31 pg-act/ns/edit.php:25
+#: fn/ns.php:33 pg-act/ns/edit.php:27
 #, php-format
 msgid "TTLs shorter than %s seconds are forbidden."
 msgstr "Les TTLs plus courts que %s secondes sont interdits."
 
-#: fn/ns.php:33 pg-act/ns/edit.php:27
+#: fn/ns.php:35 pg-act/ns/edit.php:29
 #, php-format
 msgid "TTLs longer than %s seconds are forbidden."
 msgstr "Les TTLs plus longs que %s secondes sont interdits."
 
-#: pg-view/index.php:3
+#: fn/reg.php:73
+msgid "You can only set a NS/DS record for an apex domain."
+msgstr "Vous pouvez seulement définir un enregistrement NS/DS à l'apex du domaine."
+
+#: fn/reg.php:75
+msgid "You can't set a record for another domain."
+msgstr "Vous ne pouvez pas définir un enregistrement pour un autre domaine."
+
+#: pg-view/index.php:4
 msgid "About this installation"
 msgstr "À propos de cette installation"
 
@@ -396,7 +421,7 @@ msgstr "Clé de passe actuelle incorrecte."
 msgid "Password updated."
 msgstr "Clé de passe mise à jour."
 
-#: pg-act/auth/register.php:4 pg-view/auth/register.php:3
+#: pg-act/auth/register.php:4 pg-view/auth/register.php:4
 msgid "Registrations are currently closed on this installation."
 msgstr "Les inscriptions sont actuellement fermées sur cette installation."
 
@@ -434,13 +459,13 @@ msgstr "Ce domaine doit avoir %2$s pour unique enregistrement %1$s."
 msgid "No TXT record with the expected format has been found."
 msgstr "Aucun enregistrement TXT avec le format attendu n'a été trouvé."
 
-#: pg-act/ht/add-dns.php:41 pg-act/ht/add-onion.php:37
+#: pg-act/ht/add-dns.php:48 pg-act/ht/add-onion.php:50
 #: pg-act/ht/add-subdomain.php:19 pg-act/ht/add-subpath.php:19
 #, php-format
 msgid "%s added on this directory."
 msgstr "%s ajouté sur ce dossier."
 
-#: pg-act/ht/add-onion.php:37
+#: pg-act/ht/add-onion.php:50
 #, php-format
 msgid "Its address is: %s"
 msgstr "Son adresse est&nbsp;: %s"
@@ -476,42 +501,50 @@ msgstr "Clés SSH mises à jour."
 #: pg-act/ns/caa.php:25 pg-act/ns/cname.php:16 pg-act/ns/dname.php:16
 #: pg-act/ns/ip.php:16 pg-act/ns/loc.php:72 pg-act/ns/mx.php:20
 #: pg-act/ns/ns.php:16 pg-act/ns/srv.php:28 pg-act/ns/sshfp.php:25
-#: pg-act/ns/tlsa.php:29 pg-act/ns/txt.php:17 pg-act/reg/ds.php:32
-#: pg-act/reg/glue.php:14 pg-act/reg/ns.php:14
+#: pg-act/ns/tlsa.php:29 pg-act/ns/txt.php:17 pg-act/reg/ds.php:12
+#: pg-act/reg/glue.php:13 pg-act/reg/ns.php:12
 msgid "Modification done."
 msgstr "Modification effectuée."
 
-#: pg-act/ns/edit.php:17
+#: pg-act/ns/edit.php:19 pg-act/reg/edit.php:14
 #, php-format
 msgid "The zone is limited to %s characters."
 msgstr "La zone est limitée à %s caractères."
 
-#: pg-act/ns/edit.php:21
+#: pg-act/ns/edit.php:23 pg-act/reg/edit.php:18
 msgid "The following line does not match the expected format: "
 msgstr "La ligne suivante ne correspond pas au format attendu&nbsp;: "
 
-#: pg-act/ns/edit.php:23
+#: pg-act/ns/edit.php:25 pg-act/reg/edit.php:20 pg-act/reg/edit.php:28
 #, php-format
 msgid "The %s type is not allowed."
 msgstr "Le type %s n'est pas autorisé."
 
-#: pg-act/ns/edit.php:38
-msgid "Sent zone content is not correct (according to <code>kzonecheck</code>)."
-msgstr "Le contenu de zone envoyé n'est pas correct (selon <code>kzonecheck</code>)."
+#: pg-act/ns/edit.php:40 pg-act/reg/edit.php:51
+msgid "Sent content is not correct (according to <code>kzonecheck</code>)."
+msgstr "Le contenu envoyé n'est pas correct (selon <code>kzonecheck</code>)."
+
+#: pg-act/ns/sync.php:19
+msgid "Multiple source domains can't be applied to the same target domain."
+msgstr "Plusieurs domaines sources ne peuvent pas être appliqués sur un même domaine cible."
+
+#: pg-act/ns/sync.php:41
+msgid "Synchronized records updated."
+msgstr "Enregistrements synchronisés mis à jour."
 
 #: pg-act/ns/zone-add.php:6
 msgid "This zone already exists on the service."
 msgstr "Cette zone existe déjà sur ce service."
 
-#: pg-act/ns/zone-add.php:12
+#: pg-act/ns/zone-add.php:18
 msgid "Parent zone's name servers not found."
 msgstr "Serveurs de nom de la zone parente introuvables."
 
-#: pg-act/ns/zone-add.php:18 pg-act/reg/transfer.php:18
+#: pg-act/ns/zone-add.php:30 pg-act/reg/transfer.php:24
 msgid "NS authentication record not found."
 msgstr "Enregistrement d'authentification NS introuvable."
 
-#: pg-act/ns/zone-add.php:54
+#: pg-act/ns/zone-add.php:67
 msgid "Zone created."
 msgstr "Zone créée."
 
@@ -519,6 +552,10 @@ msgstr "Zone créée."
 msgid "Zone deleted."
 msgstr "Zone supprimée."
 
+#: pg-act/reg/edit.php:22
+msgid "A DS record expects 4 arguments."
+msgstr "Un enregistrement DS attends 4 arguments."
+
 #: pg-act/reg/register.php:4 pg-act/reg/transfer.php:4
 msgid "This format of subdomain is not allowed."
 msgstr "Ce format de sous-domaine n'est pas autorisé."
@@ -559,7 +596,7 @@ msgstr "Domaine enregistré."
 msgid "The current account already owns this domain."
 msgstr "Le compte actuel possède déjà ce domaine."
 
-#: pg-act/reg/transfer.php:33
+#: pg-act/reg/transfer.php:39
 msgid "The domain has been transferred to the current account ; the NS authentication record has been automatically deleted."
 msgstr "Le domaine a été transféré vers le compte actuel ; l'enregistrement d'authentification NS a été automatiquement supprimé."
 
@@ -567,651 +604,722 @@ msgstr "Le domaine a été transféré vers le compte actuel ; l'enregistrement
 msgid "Domain unregistered."
 msgstr "Domaine désenregistré."
 
-#: pg-view/auth/approval.php:2
+#: pg-view/auth/approval.php:3
 msgid "This form allows to use an approval key to validate your account. Approval keys are distributed by an administrator upon request."
 msgstr "Ce formulaire permet d'utiliser une clé d'approbation pour valider son compte. Les clés d'approbation sont distribuées par ane administrataire sur demande."
 
-#: pg-view/auth/approval.php:6
+#: pg-view/auth/approval.php:7
 msgid "Approval key"
 msgstr "Clé d'approbation"
 
-#: pg-view/auth/approval.php:9
+#: pg-view/auth/approval.php:10
 msgid "Use for this account"
 msgstr "Utiliser pour ce compte"
 
-#: pg-view/auth/index.php:3
+#: pg-view/auth/index.php:4
 msgid "Account type"
 msgstr "Type de compte"
 
-#: pg-view/auth/index.php:9
+#: pg-view/auth/index.php:10
 msgid "You are currently using a <strong>testing</strong> account."
 msgstr "Vous utilisez actuellement un compte <strong>de test</strong>."
 
-#: pg-view/auth/index.php:10
+#: pg-view/auth/index.php:11
 msgid "You are currently using an <strong>approved</strong> account."
 msgstr "Vous utilisez actuellement un compte <strong>approuvé</strong>."
 
-#: pg-view/auth/index.php:13
+#: pg-view/auth/index.php:14
 msgid "You are not logged in."
 msgstr "Vous n'êtes connecté·e à aucun compte."
 
-#: pg-view/auth/index.php:18
+#: pg-view/auth/index.php:19
 msgid "When an account is created, it's a <em>testing</em> account. A testing account is only temporary and with limited capabilities on the services. Once the account is validated by using an approval key requested to an administrator, it becomes an <em>approved</em> account."
 msgstr "Quand un compte est créé, c'est un compte <em>de test</em>. Un compte de test est seulement temporaire et avec des capacités restreintes sur les services. Une fois le compte validé en utilisant une clé d'approbation demandée à ane administrataire, il devient un compte <em>approuvé</em>."
 
-#: pg-view/auth/index.php:21
+#: pg-view/auth/index.php:22
 msgid "Rate limit"
 msgstr "Limite de débit"
 
-#: pg-view/auth/index.php:25
+#: pg-view/auth/index.php:26
 #, php-format
 msgid "Your account is at %s%% of the rate limit."
 msgstr "Votre compte est à %s%% de la limite de débit."
 
-#: pg-view/auth/index.php:27
+#: pg-view/auth/index.php:28
 msgid "Most of the form submissions bring you closer to the rate limit. If you reach it, you need to wait in order to be able to submit forms again."
 msgstr "La plupart des traitements de formulaires vous rapprochent de la limite de débit. Si vous l'atteignez, vous devez attendre avant de pouvoir envoyer des formulaires à nouveau."
 
-#: pg-view/auth/index.php:31
+#: pg-view/auth/index.php:32
 msgid "Internal ID"
 msgstr "Identifiant interne"
 
-#: pg-view/auth/index.php:33
+#: pg-view/auth/index.php:34
 #, php-format
 msgid "The current account's internal ID is %s."
 msgstr "L'identifiant interne du compte actuel est %s."
 
-#: pg-view/auth/login.php:1
+#: pg-view/auth/login.php:2
 msgid "New?"
 msgstr "Nouvele ?"
 
-#: pg-view/auth/login.php:1 pg-view/auth/register.php:16
+#: pg-view/auth/login.php:2 pg-view/auth/register.php:17
 msgid "Create an account"
 msgstr "Créer un compte"
 
-#: pg-view/auth/login.php:4 pg-view/auth/register.php:6 pg-view/ht/index.php:64
+#: pg-view/auth/login.php:5 pg-view/auth/register.php:7 pg-view/ht/index.php:65
 msgid "Username"
 msgstr "Identifiant"
 
-#: pg-view/auth/login.php:8 pg-view/auth/register.php:11
-#: pg-view/ht/index.php:68
+#: pg-view/auth/login.php:9 pg-view/auth/register.php:12
+#: pg-view/ht/index.php:69
 msgid "Password"
 msgstr "Clé de passe"
 
-#: pg-view/auth/password.php:2 pg-view/auth/unregister.php:6
-#: pg-view/auth/username.php:2
+#: pg-view/auth/password.php:3 pg-view/auth/unregister.php:7
+#: pg-view/auth/username.php:3
 msgid "Current password"
 msgstr "Clé de passe actuelle"
 
-#: pg-view/auth/password.php:5
+#: pg-view/auth/password.php:6
 msgid "New password"
 msgstr "Nouvelle clé de passe"
 
-#: pg-view/auth/password.php:8
+#: pg-view/auth/password.php:9
 msgid "Update password"
 msgstr "Mettre à jour la clé de passe"
 
-#: pg-view/auth/register.php:1
+#: pg-view/auth/register.php:2
 msgid "Already have an account?"
 msgstr "Déjà un compte ?"
 
-#: pg-view/auth/register.php:12
+#: pg-view/auth/register.php:13
 #, php-format
 msgid "Minimum %1$s characters, or %2$s characters if it contains lowercase, uppercase and digit."
 msgstr "Minimum %1$s caractères, ou %2$s caractères si elle contient minuscule, majuscule et chiffre."
 
-#: pg-view/auth/unregister.php:2
+#: pg-view/auth/unregister.php:3
 msgid "This will delete every resource managed by the current account, including registered domains, hosted DNS records, websites files and cryptographic keys for Onion services and DNSSEC."
 msgstr "Ceci supprimera toutes les ressources gérées par le compte actuel, y compris les domaines enregistrés, les enregistrements DNS hébergés, les fichiers des sites et les clés cryptographiques des services Onion et de DNSSEC."
 
-#: pg-view/auth/unregister.php:10
+#: pg-view/auth/unregister.php:11
 msgid "Delete the current account and everything related (required)"
 msgstr "Supprimer le compte actuel et tout ce qui y est lié (requis)"
 
-#: pg-view/auth/unregister.php:12 pg-view/ns/form.ns.php:4 pg-view/reg/ds.php:5
-#: pg-view/reg/glue.php:5 pg-view/reg/ns.php:5
+#: pg-view/auth/unregister.php:13 pg-view/ns/form.ns.php:5
+#: pg-view/reg/select-action.inc.php:5
 msgid "Delete"
 msgstr "Supprimer"
 
-#: pg-view/auth/username.php:5
+#: pg-view/auth/username.php:6
 msgid "New username"
 msgstr "Nouvel identifiant"
 
-#: pg-view/auth/username.php:8
+#: pg-view/auth/username.php:9
 msgid "Update username"
 msgstr "Mettre à jour l'identifiant"
 
-#: pg-view/ht/add-dns.php:2
+#: pg-view/ht/add-dns.php:3
 msgid "A Let's Encrypt certificate will be obtained."
 msgstr "Un certificat Let's Encrypt sera obtenu."
 
-#: pg-view/ht/add-dns.php:6
+#: pg-view/ht/add-dns.php:7
 msgid "The domain must have the following records when the form is being processed."
 msgstr "Le domaine doit avoir les enregistrements suivants pendant le traitement du formulaire."
 
-#: pg-view/ht/add-dns.php:29 pg-view/ns/form.ns.php:8 pg-view/ns/print.php:32
-#: pg-view/ns/zone-add.php:6 pg-view/reg/ds.php:8 pg-view/reg/glue.php:8
-#: pg-view/reg/glue.php:15 pg-view/reg/ns.php:8 pg-view/reg/print.php:2
-#: pg-view/reg/print.php:16 pg-view/reg/register.php:11
-#: pg-view/reg/unregister.php:6
+#: pg-view/ht/add-dns.php:30 pg-view/ns/form.ns.php:9 pg-view/ns/print.php:33
+#: pg-view/ns/zone-add.php:7 pg-view/reg/glue.php:5 pg-view/reg/print.php:10
+#: pg-view/reg/register.php:12 pg-view/reg/select-domain.inc.php:2
+#: pg-view/reg/unregister.php:7
 msgid "Domain"
 msgstr "Domaine"
 
-#: pg-view/ht/add-dns.php:31 pg-view/ht/add-onion.php:2
-#: pg-view/ht/add-subdomain.php:8 pg-view/ht/add-subpath.php:8
+#: pg-view/ht/add-dns.php:32 pg-view/ht/add-onion.php:3
+#: pg-view/ht/add-subdomain.php:9 pg-view/ht/add-subpath.php:9
 msgid "Target directory"
 msgstr "Dossier ciblé"
 
-#: pg-view/ht/add-dns.php:40 pg-view/ht/add-onion.php:11
-#: pg-view/ht/add-subdomain.php:17 pg-view/ht/add-subpath.php:17
+#: pg-view/ht/add-dns.php:41 pg-view/ht/add-onion.php:12
+#: pg-view/ht/add-subdomain.php:18 pg-view/ht/add-subpath.php:18
 msgid "Setup access"
 msgstr "Créer l'accès"
 
-#: pg-view/ht/add-subdomain.php:2 pg-view/reg/register.php:6
+#: pg-view/ht/add-subdomain.php:3 pg-view/reg/register.php:7
 #, php-format
 msgid "The subdomain can only contain %1$s, %2$s and %3$s, and must be between 4 and 63 characters. It can't have an hyphen (%3$s) in first, last or both third and fourth position."
 msgstr "Le sous-domain peut uniquement contenir %1$s, %2$s et %3$s, et doit être entre 4 et 63 caractères. Il ne peut pas avoir un tiret (%3$s) en première, dernière ou à la fois troisième et quatrième position."
 
-#: pg-view/ht/add-subdomain.php:6 pg-view/ns/form.ns.php:10
-#: pg-view/reg/glue.php:10 pg-view/reg/register.php:13
-#: pg-view/reg/transfer.php:9
+#: pg-view/ht/add-subdomain.php:7 pg-view/ns/form.ns.php:11
+#: pg-view/reg/glue.php:7 pg-view/reg/register.php:14
+#: pg-view/reg/transfer.php:10
 msgid "Subdomain"
 msgstr "Sous-domaine"
 
-#: pg-view/ht/add-subpath.php:2
+#: pg-view/ht/add-subpath.php:3
 #, php-format
 msgid "The path can only contain %1$s, %2$s and %3$s, and must be between 4 and 63 characters."
 msgstr "Le chemin peut uniquement contenir %1$s, %2$s et %3$s, et doit être entre 4 et 63 caractères."
 
-#: pg-view/ht/add-subpath.php:6
+#: pg-view/ht/add-subpath.php:7
 msgid "Path"
 msgstr "Chemin"
 
-#: pg-view/ht/del.php:2
+#: pg-view/ht/del.php:3
 msgid "Access to delete"
 msgstr "Accès à retirer"
 
-#: pg-view/ht/index.php:2
+#: pg-view/ht/del.php:14
+#, php-format
+msgid "%1$s to %2$s"
+msgstr "%1$s vers %2$s"
+
+#: pg-view/ht/index.php:3
 msgid "This service allows you to send files on the server using SFTP, and to make them publicly available with HTTP."
 msgstr "Ce service permet d'envoyer des fichiers sur le serveur par SFTP, et de les rendre publiquement accessibles par HTTP."
 
-#: pg-view/ht/index.php:8
+#: pg-view/ht/index.php:9
 msgid "Currently hosted sites"
 msgstr "Sites actuellement hébergés"
 
-#: pg-view/ht/index.php:38
+#: pg-view/ht/index.php:39
 msgid "Adding a site access"
 msgstr "Ajouter un accès de site"
 
-#: pg-view/ht/index.php:40
+#: pg-view/ht/index.php:41
 #, php-format
 msgid "In order to be able to set up an HTTP site with this service, a subdirectory for this site must be created inside the SFTP space first. The name of this subdirectory can only contain %1$s, %2$s, %3$s, %4$s and %5$s."
 msgstr "Pour pouvoir créer un site HTTP avec ce service, un sous-dossier pour ce site doit d'abord être créé dans l'espace SFTP. Le nom de ce sous-dossier ne peut contenir que %1$s, %2$s, %3$s, %4$s et %5$s."
 
-#: pg-view/ht/index.php:44
+#: pg-view/ht/index.php:45
 msgid "Connecting to the SFTP server"
 msgstr "Se connecter au serveur SFTP"
 
-#: pg-view/ht/index.php:52
+#: pg-view/ht/index.php:53
 msgid "Server"
 msgstr "Serveur"
 
-#: pg-view/ht/index.php:56 pg-view/ns/srv.php:16
+#: pg-view/ht/index.php:57 pg-view/ns/srv.php:17
 msgid "Port"
 msgstr "Port"
 
-#: pg-view/ht/index.php:60
+#: pg-view/ht/index.php:61
 msgid "Directory"
 msgstr "Dossier"
 
-#: pg-view/ht/index.php:70
+#: pg-view/ht/index.php:71
 msgid "The one of your account"
 msgstr "Celle de votre compte"
 
-#: pg-view/ht/index.php:74
+#: pg-view/ht/index.php:75
 msgid "Authenticating the server"
 msgstr "Authentifier le serveur"
 
-#: pg-view/ht/index.php:76
+#: pg-view/ht/index.php:77
 msgid "An SSHFP record is available."
 msgstr "Un enregistrement SSHFP est disponible."
 
-#: pg-view/ht/index.php:79
+#: pg-view/ht/index.php:80
 msgid "Plain public key"
 msgstr "Clé publique"
 
-#: pg-view/ht/index.php:84
+#: pg-view/ht/index.php:85
 msgid "Public key fingerprint"
 msgstr "Empreinte de la clé publique"
 
-#: pg-view/ht/index.php:89
+#: pg-view/ht/index.php:90
 msgid "ASCII art"
 msgstr "Art ASCII"
 
-#: pg-view/ht/index.php:102
+#: pg-view/ht/index.php:103
 msgid "A content security policy (CSP) forbids Web browsers from loading JavaScript or third-party resources."
 msgstr "Une politique de sécurité du contenu (CSP) interdit l'intégration de ressources tierces ou de JavaScript."
 
-#: pg-view/ht/index.php:105
+#: pg-view/ht/index.php:106
 msgid "<code>.htaccess</code> configuration"
 msgstr "Configuration par <code>.htaccess</code>"
 
-#: pg-view/ht/index.php:107
+#: pg-view/ht/index.php:108
 msgid "You can change the way the HTTP server answers to requests in a directory by setting some directives in a file named <code>.htaccess</code> at the root of this directory. Only the following directives are allowed:"
 msgstr "Vous pouvez modifier la façon dont le serveur HTTP répond aux requêtes dans un dossier en indiquant des directives dans un fichier nommé <code>.htaccess</code> à la racine de ce dossier. Seules les directives suivantes sont autorisées&nbsp;:"
 
-#: pg-view/ht/index.php:163
+#: pg-view/ht/index.php:164
 msgid "Accounts capabilities"
 msgstr "Capacités des comptes"
 
-#: pg-view/ht/index.php:165
+#: pg-view/ht/index.php:166
 msgid "Testing"
 msgstr "De test"
 
-#: pg-view/ht/index.php:168 pg-view/ht/index.php:175
+#: pg-view/ht/index.php:169 pg-view/ht/index.php:176
 #, php-format
 msgid "%s of SFTP quota"
 msgstr "Quota SFTP de %s"
 
-#: pg-view/ht/index.php:168 pg-view/ht/index.php:175
+#: pg-view/ht/index.php:169 pg-view/ht/index.php:176
 msgid "<abbr title=\"gibibyte\">GiB</abbr>"
 msgstr "<abbr title=\"gibioctet\">Gio</abbr>"
 
-#: pg-view/ht/index.php:168 pg-view/ht/index.php:175
+#: pg-view/ht/index.php:169 pg-view/ht/index.php:176
 msgid "<abbr title=\"mebibyte\">MiB</abbr>"
 msgstr "<abbr title=\"mébioctet\">Mio</abbr>"
 
-#: pg-view/ht/index.php:169
+#: pg-view/ht/index.php:170
 msgid "Let's Encrypt certificate from the staging environment (not trusted by clients)"
 msgstr "Certificat Let's Encrypt de test (n'est pas reconnu par les clients)"
 
-#: pg-view/ht/index.php:172
+#: pg-view/ht/index.php:173
 msgid "Approved"
 msgstr "Approuvé"
 
-#: pg-view/ht/index.php:176
+#: pg-view/ht/index.php:177
 msgid "Stable Let's Encrypt certificates"
 msgstr "Vrai certificat Let's Encrypt"
 
-#: pg-view/ht/keys.php:2
+#: pg-view/ht/keys.php:3
 msgid "In addition to your password, you can also access your SFTP space using Ed25519 SSH keys. A key can be granted modification rights to the full space (<code>/</code>) or to any arbitrary subdirectory. A key is always  allowed to list any directory content."
 msgstr "En plus de la clé de passe, c'est également possible d'accéder à l'espace SFTP en utilisant des clés SSH Ed25519. Une clé peut être autorisée à modifier dans tout l'espace (<code>/</code>) ou dans un quelconque sous-dossier spécifique. Une clé est toujours autorisée à lister le contenu de n'importe quel dossier."
 
-#: pg-view/ht/keys.php:17
+#: pg-view/ht/keys.php:18
 msgid "Add new SSH key access"
 msgstr "Ajouter un nouvel accès par clé SSH"
 
-#: pg-view/ht/keys.php:17
+#: pg-view/ht/keys.php:18
 msgid "SSH key access"
 msgstr "Accès par clé SSH"
 
-#: pg-view/ht/keys.php:19
+#: pg-view/ht/keys.php:20
 msgid "Public key"
 msgstr "Clé publique"
 
-#: pg-view/ht/keys.php:23
+#: pg-view/ht/keys.php:24
 msgid "Allowed directory"
 msgstr "Dossier autorisé"
 
-#: pg-view/ht/keys.php:30
+#: pg-view/ht/keys.php:31 pg-view/ns/sync.php:38
 msgid "Update"
 msgstr "Mettre à jour"
 
-#: pg-view/ns/caa.php:3
+#: pg-view/ns/caa.php:4
 msgid "Flag"
 msgstr "Flag"
 
-#: pg-view/ns/caa.php:7 pg-view/ns/print.php:56
+#: pg-view/ns/caa.php:8 pg-view/ns/print.php:57
 msgid "Tag"
 msgstr "Tag"
 
-#: pg-view/ns/caa.php:11 pg-view/ns/form.ns.php:31 pg-view/ns/print.php:35
-#: pg-view/ns/tlsa.php:37 pg-view/reg/print.php:19
+#: pg-view/ns/caa.php:12 pg-view/ns/form.ns.php:33 pg-view/ns/print.php:36
+#: pg-view/ns/tlsa.php:38 pg-view/reg/print.php:13
 msgid "Value"
 msgstr "Valeur"
 
-#: pg-view/ns/caa.php:15 pg-view/ns/cname.php:7 pg-view/ns/dname.php:7
-#: pg-view/ns/ip.php:5 pg-view/ns/loc.php:75 pg-view/ns/mx.php:11
-#: pg-view/ns/ns.php:7 pg-view/ns/srv.php:27 pg-view/ns/sshfp.php:29
-#: pg-view/ns/tlsa.php:43 pg-view/ns/txt.php:7 pg-view/reg/ds.php:56
-#: pg-view/reg/glue.php:29 pg-view/reg/ns.php:22
+#: pg-view/ns/caa.php:16 pg-view/ns/cname.php:8 pg-view/ns/dname.php:8
+#: pg-view/ns/ip.php:6 pg-view/ns/loc.php:76 pg-view/ns/mx.php:12
+#: pg-view/ns/ns.php:8 pg-view/ns/srv.php:28 pg-view/ns/sshfp.php:30
+#: pg-view/ns/tlsa.php:44 pg-view/ns/txt.php:8 pg-view/reg/ds.php:45
+#: pg-view/reg/glue.php:18 pg-view/reg/ns.php:11
 msgid "Apply"
 msgstr "Appliquer"
 
-#: pg-view/ns/cname.php:3
+#: pg-view/ns/cname.php:4
 msgid "Canonical name"
 msgstr "Nom canonique"
 
-#: pg-view/ns/dname.php:3
+#: pg-view/ns/dname.php:4
 msgid "Delegation name"
 msgstr "Nom délégué"
 
-#: pg-view/ns/edit.php:2
+#: pg-view/ns/edit.php:3
 msgid "Zone to be changed"
 msgstr "Zone à modifier"
 
-#: pg-view/ns/edit.php:12 pg-view/ns/print.php:20 pg-view/reg/print.php:11
+#: pg-view/ns/edit.php:13 pg-view/ns/print.php:21 pg-view/reg/edit.php:13
+#: pg-view/reg/print.php:5
 msgid "Display"
 msgstr "Afficher"
 
-#: pg-view/ns/edit.php:23
+#: pg-view/ns/edit.php:24
 #, php-format
-msgid "New content of the %s zone"
-msgstr "Nouveau contenu de la zone %s"
+msgid "Authoritative records for %s"
+msgstr "Enregistrements ayant autorité pour %s"
 
-#: pg-view/ns/edit.php:27
+#: pg-view/ns/edit.php:28 pg-view/reg/edit.php:28
 msgid "Replace"
 msgstr "Remplacer"
 
-#: pg-view/ns/edit.php:38
+#: pg-view/ns/edit.php:39
 msgid "Default values"
 msgstr "Valeurs par défaut"
 
-#: pg-view/ns/edit.php:40
+#: pg-view/ns/edit.php:41
 #, php-format
 msgid "If the TTL is omitted, it will default to %s seconds."
 msgstr "Si le TTL est omis, il sera définit à %s secondes."
 
-#: pg-view/ns/edit.php:42
+#: pg-view/ns/edit.php:43 pg-view/reg/edit.php:41
 msgid "Precising the class (<code>IN</code>) is optional."
 msgstr "La précision de la classe (<code>IN</code>) est facultative."
 
-#: pg-view/ns/edit.php:44
+#: pg-view/ns/edit.php:45
 msgid "Allowed values"
 msgstr "Valeurs autorisées"
 
-#: pg-view/ns/edit.php:46
+#: pg-view/ns/edit.php:47 pg-view/reg/edit.php:43
 #, php-format
-msgid "Submitted zone content is limited to %s characters."
-msgstr "La zone n'est pas autorisée à dépasser %s caractères."
+msgid "Submitted field content is limited to %s characters."
+msgstr "Le champ envoyé n'est pas autorisé à dépasser %s caractères."
 
-#: pg-view/ns/edit.php:48
+#: pg-view/ns/edit.php:49
 #, php-format
 msgid "TTLs must last between %1$s and %2$s seconds."
 msgstr "Les TTLs ne sont autorisés qu'entre %1$s et %2$s secondes."
 
-#: pg-view/ns/edit.php:50
-msgid "The only types that can be defined are:"
-msgstr "Les seuls types dont l'édition est autorisée sont&nbsp;:"
+#: pg-view/ns/edit.php:51 pg-view/reg/edit.php:47
+msgid "The only types that can be defined here are:"
+msgstr "Les seuls types qui peuvent être définis ici sont&nbsp;:"
 
-#: pg-view/ns/form.ns.php:1 pg-view/reg/ds.php:2 pg-view/reg/glue.php:2
-#: pg-view/reg/ns.php:2
+#: pg-view/ns/form.ns.php:2 pg-view/reg/select-action.inc.php:2
 msgid "Action"
 msgstr "Action"
 
-#: pg-view/ns/form.ns.php:3 pg-view/ns/zone-add.php:8 pg-view/reg/ds.php:4
-#: pg-view/reg/glue.php:4 pg-view/reg/ns.php:4
+#: pg-view/ns/form.ns.php:4 pg-view/ns/zone-add.php:9
+#: pg-view/reg/select-action.inc.php:4
 msgid "Add"
 msgstr "Ajouter"
 
-#: pg-view/ns/form.ns.php:15 pg-view/ns/print.php:52 pg-view/ns/zone-del.php:2
+#: pg-view/ns/form.ns.php:16 pg-view/ns/print.php:53 pg-view/ns/zone-del.php:3
 msgid "Zone"
 msgstr "Zone"
 
-#: pg-view/ns/form.ns.php:29 pg-view/ns/print.php:33 pg-view/reg/print.php:17
+#: pg-view/ns/form.ns.php:31 pg-view/ns/print.php:34 pg-view/reg/print.php:11
 msgid "TTL"
 msgstr "TTL"
 
-#: pg-view/ns/form.ns.php:45
+#: pg-view/ns/form.ns.php:47
 msgid "Unit"
 msgstr "Unité"
 
-#: pg-view/ns/form.ns.php:48
+#: pg-view/ns/form.ns.php:50
 msgid "second"
 msgstr "seconde"
 
-#: pg-view/ns/form.ns.php:49
+#: pg-view/ns/form.ns.php:51
 msgid "minute"
 msgstr "minute"
 
-#: pg-view/ns/form.ns.php:50
+#: pg-view/ns/form.ns.php:52
 msgid "hour"
 msgstr "heure"
 
-#: pg-view/ns/form.ns.php:51
+#: pg-view/ns/form.ns.php:53
 msgid "day"
 msgstr "jour"
 
-#: pg-view/ns/index.php:2
+#: pg-view/ns/index.php:3
 msgid "This service allows to host and manage DNS records inside a DNS zone."
 msgstr "Ce service permet d'héberger et de gérer les enregistrements DNS à l'intérieur d'une zone DNS."
 
-#: pg-view/ns/index.php:8
+#: pg-view/ns/index.php:9
 msgid "Currently hosted zones"
 msgstr "Zones actuellement hébergées"
 
-#: pg-view/ns/index.php:26
+#: pg-view/ns/index.php:27
 msgid "A zone hosted on this service is served by these name servers:"
 msgstr "Une zone hébergée sur ce service est servie par les serveurs suivants&nbsp;:"
 
-#: pg-view/ns/ip.php:3 pg-view/reg/glue.php:26
+#: pg-view/ns/ip.php:4 pg-view/reg/glue.php:15
 msgid "IP address"
 msgstr "Adresse IP"
 
-#: pg-view/ns/loc.php:4
+#: pg-view/ns/loc.php:5
 msgid "Latitude"
 msgstr "Latitude"
 
-#: pg-view/ns/loc.php:6 pg-view/ns/loc.php:34
+#: pg-view/ns/loc.php:7 pg-view/ns/loc.php:35
 msgid "Degrees"
 msgstr "Dégrés"
 
-#: pg-view/ns/loc.php:11 pg-view/ns/loc.php:39
+#: pg-view/ns/loc.php:12 pg-view/ns/loc.php:40
 msgid "Minutes"
 msgstr "Minutes"
 
-#: pg-view/ns/loc.php:16 pg-view/ns/loc.php:44
+#: pg-view/ns/loc.php:17 pg-view/ns/loc.php:45
 msgid "Seconds"
 msgstr "Secondes"
 
-#: pg-view/ns/loc.php:21 pg-view/ns/loc.php:49
+#: pg-view/ns/loc.php:22 pg-view/ns/loc.php:50
 msgid "Direction"
 msgstr "Direction"
 
-#: pg-view/ns/loc.php:25
+#: pg-view/ns/loc.php:26
 msgid "North"
 msgstr "Nord"
 
-#: pg-view/ns/loc.php:26
+#: pg-view/ns/loc.php:27
 msgid "South"
 msgstr "Sud"
 
-#: pg-view/ns/loc.php:32
+#: pg-view/ns/loc.php:33
 msgid "Longitude"
 msgstr "Longitude"
 
-#: pg-view/ns/loc.php:53
+#: pg-view/ns/loc.php:54
 msgid "East"
 msgstr "Est"
 
-#: pg-view/ns/loc.php:54
+#: pg-view/ns/loc.php:55
 msgid "West"
 msgstr "Ouest"
 
-#: pg-view/ns/loc.php:59
+#: pg-view/ns/loc.php:60
 msgid "Altitude"
 msgstr "Altitude"
 
-#: pg-view/ns/loc.php:63
+#: pg-view/ns/loc.php:64
 msgid "Size"
 msgstr "Taille"
 
-#: pg-view/ns/loc.php:67
+#: pg-view/ns/loc.php:68
 msgid "Horizontal precision"
 msgstr "Précision horizontale"
 
-#: pg-view/ns/loc.php:71
+#: pg-view/ns/loc.php:72
 msgid "Vertical precision"
 msgstr "Précision verticale"
 
-#: pg-view/ns/mx.php:3 pg-view/ns/srv.php:4
+#: pg-view/ns/mx.php:4 pg-view/ns/srv.php:5
 msgid "Priority"
 msgstr "Priorité"
 
-#: pg-view/ns/mx.php:7
+#: pg-view/ns/mx.php:8
 msgid "Host"
 msgstr "Hôte"
 
-#: pg-view/ns/ns.php:3 pg-view/reg/ns.php:18
+#: pg-view/ns/ns.php:4 pg-view/reg/ns.php:7
 msgid "Name server"
 msgstr "Serveur de nom"
 
-#: pg-view/ns/print.php:3
+#: pg-view/ns/print.php:4
 msgid "Records table"
 msgstr "Tableau des enregistrements"
 
-#: pg-view/ns/print.php:6
+#: pg-view/ns/print.php:7
 msgid "DS record"
 msgstr "Enregistrement DS"
 
-#: pg-view/ns/print.php:9
+#: pg-view/ns/print.php:10
 msgid "Raw zonefile"
 msgstr "Fichier de zone brut"
 
-#: pg-view/ns/print.php:11
+#: pg-view/ns/print.php:12
 msgid "Selected zone"
 msgstr "Zone"
 
-#: pg-view/ns/print.php:34 pg-view/reg/print.php:18
+#: pg-view/ns/print.php:35 pg-view/reg/print.php:12
 msgid "Type"
 msgstr "Type"
 
-#: pg-view/ns/print.php:60 pg-view/ns/sshfp.php:4 pg-view/reg/ds.php:22
+#: pg-view/ns/print.php:61 pg-view/ns/sshfp.php:5 pg-view/reg/ds.php:11
 msgid "Algorithm"
 msgstr "Algorithme"
 
-#: pg-view/ns/print.php:64 pg-view/reg/ds.php:41
+#: pg-view/ns/print.php:65 pg-view/reg/ds.php:30
 msgid "Digest type"
 msgstr "Type de condensat"
 
-#: pg-view/ns/print.php:68
+#: pg-view/ns/print.php:69
 msgid "Digest"
 msgstr "Condensat"
 
-#: pg-view/ns/srv.php:10
+#: pg-view/ns/srv.php:11
 msgid "Weight"
 msgstr "Poids"
 
-#: pg-view/ns/srv.php:22
+#: pg-view/ns/srv.php:23
 msgid "Target"
 msgstr "Cible"
 
-#: pg-view/ns/sshfp.php:15
+#: pg-view/ns/sshfp.php:16
 msgid "Hash type"
 msgstr "Type de hash"
 
-#: pg-view/ns/sshfp.php:24
+#: pg-view/ns/sshfp.php:25
 msgid "Fingerprint"
 msgstr "Empreinte"
 
-#: pg-view/ns/tlsa.php:4
+#: pg-view/ns/sync.php:3
+#, php-format
+msgid "AAAA, A and CAA records are regularly copied from the source domain to the target domain. Their TTLs are set to %s seconds."
+msgstr "Les enregistrements AAAA, A et CAA sont régulièrement copiés du domain source vers le domain cible. Leurs TTLs sont définis à %s secondes."
+
+#: pg-view/ns/sync.php:6
+msgid "Source domains that are not signed with DNSSEC are not synchronized. Synchronizations that remain broken may be deleted."
+msgstr "Les domains sources qui ne sont pas signés avec DNSSEC ne sont pas synchronisés. Les synchronisations qui restent cassées peuvent être supprimées."
+
+#: pg-view/ns/sync.php:9
+msgid "This is meant to be used for apex domains, where CNAME records are not allowed. For non-apex domains, CNAME records should be used instead."
+msgstr "Ceci est destiné à être utilisé sur des domaines apex, où les enregistrements CNAME ne sont pas autorisés. Pour des domaines non-apex, les enregistrements CNAME devraient être utilisés à la place."
+
+#: pg-view/ns/sync.php:17
+msgid "Add new domain records to be synchronized"
+msgstr "Ajouter de nouveaux enregistrements de domain à synchroniser"
+
+#: pg-view/ns/sync.php:17
+msgid "Synchronized domain"
+msgstr "Domaine synchronisé"
+
+#: pg-view/ns/sync.php:19
+msgid "Source domain"
+msgstr "Domaine source"
+
+#: pg-view/ns/sync.php:23
+msgid "Target domain"
+msgstr "Domaine cible"
+
+#: pg-view/ns/tlsa.php:5
 msgid "Use"
 msgstr "Utilisation"
 
-#: pg-view/ns/tlsa.php:16
+#: pg-view/ns/tlsa.php:17
 msgid "Selector"
 msgstr "Selecteur"
 
-#: pg-view/ns/tlsa.php:20
+#: pg-view/ns/tlsa.php:21
 msgid "the full certificate must match"
 msgstr "le certificat entier doit correspondre"
 
-#: pg-view/ns/tlsa.php:21
+#: pg-view/ns/tlsa.php:22
 msgid "the certificate public key must match"
 msgstr "la clé publique du certificat doit correspondre"
 
-#: pg-view/ns/tlsa.php:26
+#: pg-view/ns/tlsa.php:27
 msgid "Match type"
 msgstr "Type de correspondance"
 
-#: pg-view/ns/tlsa.php:30
+#: pg-view/ns/tlsa.php:31
 msgid "full certificate"
 msgstr "certificat entier"
 
-#: pg-view/ns/txt.php:3
+#: pg-view/ns/txt.php:4
 msgid "Text"
 msgstr "Texte"
 
-#: pg-view/ns/txt.php:5
+#: pg-view/ns/txt.php:6
 msgid "Some text…"
 msgstr "Du texte…"
 
-#: pg-view/ns/zone-add.php:2
+#: pg-view/ns/zone-add.php:3
 #, php-format
 msgid "To prove that you own this domain, it must have a NS record equal to %s when the form is being processed."
 msgstr "Pour prouver que vous possédez bien ce domaine, il doit posséder un enregistrement NS égal à %s lors du traitement de ce formulaire."
 
-#: pg-view/ns/zone-del.php:11
+#: pg-view/ns/zone-del.php:12
 msgid "Delete everything related to this zone"
 msgstr "Supprimer tout de cette zone"
 
-#: pg-view/reg/ds.php:18
+#: pg-view/reg/ds.php:7
 msgid "Key tag"
 msgstr "Tag de la clé"
 
-#: pg-view/reg/ds.php:52
+#: pg-view/reg/ds.php:41
 msgid "Key"
 msgstr "Condensat"
 
-#: pg-view/reg/glue.php:27
+#: pg-view/reg/edit.php:3
+msgid "Domain to be changed"
+msgstr "Domaine à modifier"
+
+#: pg-view/reg/edit.php:24
+#, php-format
+msgid "Delegation records for %s"
+msgstr "Enregistrements de délégation pour %s"
+
+#: pg-view/reg/edit.php:39
+msgid "Input values"
+msgstr "Saisie des valeurs"
+
+#: pg-view/reg/edit.php:45
+#, php-format
+msgid "TTL values are ignored and always set to %s seconds."
+msgstr "Les valeurs de TTL sont ignorées et toujours définies à %s secondes."
+
+#: pg-view/reg/glue.php:16
 #, php-format
 msgid "%1$s or %2$s"
 msgstr "%1$s ou %2$s"
 
-#: pg-view/reg/index.php:2
+#: pg-view/reg/index.php:3
 #, php-format
 msgid "This domain name registry allows to register domains ending with <code>%1$s</code>, for instance <code><em>domain</em>%1$s</code>."
 msgstr "Ce registre de noms de domaine permet d'enregistrer des domaines se terminant par <code>%1$s</code>, par exemple <code><em>domaine</em>%1$s</code>."
 
-#: pg-view/reg/index.php:7
+#: pg-view/reg/index.php:8
 msgid "Currently registered domains"
 msgstr "Domaines actuellement enregistrés"
 
-#: pg-view/reg/index.php:27
+#: pg-view/reg/index.php:28
 msgid "Both <span aria-hidden=\"true\">⏳ </span><em>testing</em> and <span aria-hidden=\"true\">👤 </span><em>approved</em> accounts can register a domain under these suffixes:"
 msgstr "Les comptes <span aria-hidden=\"true\">⏳ </span><em>de test</em> et <span aria-hidden=\"true\">👤 </span><em>approuvés</em> peuvent enregistrer un domaine sous ces suffixes&nbsp;:"
 
-#: pg-view/reg/index.php:38
+#: pg-view/reg/index.php:41
 msgid "Only <span aria-hidden=\"true\">👤 </span><em>approved</em> accounts can register a domain under these suffixes:"
 msgstr "Seuls les comptes <span aria-hidden=\"true\">👤 </span><em>approuvés</em> peuvent enregistrer un domaine sous ces suffixes&nbsp;:"
 
-#: pg-view/reg/index.php:49
+#: pg-view/reg/index.php:54
 msgid "Nobody can register a domain under these suffixes:"
 msgstr "Personne ne peut enregistrer un domain sous ces suffixes&nbsp;:"
 
-#: pg-view/reg/register.php:2
+#: pg-view/reg/index.php:70
+msgid "Automatic updates from child zone"
+msgstr "Mises à jour automatiques depuis la zone enfant"
+
+#: pg-view/reg/index.php:71
+msgid "CSYNC records"
+msgstr "Enregistrements CSYNC"
+
+#: pg-view/reg/index.php:73
+msgid "The registry can synchronize NS records from the child zone if a CSYNC record is present at the apex of the child zone, has flags <code>1</code> and type bit map <code>NS</code>, and can be DNSSEC-validated. Others values are not supported."
+msgstr "Le registre peut synchroniser les enregistrements NS depuis la zone enfant si un enregistrement CSYNC est présent à l'apex de la zone enfant, a les drapeaux <code>1</code> et le type bit map <code>NS</code>, et peut être validé par DNSSEC. Les autres valeurs ne sont pas supportées."
+
+#: pg-view/reg/index.php:75
+msgid "DNSSEC and DS records"
+msgstr "DNSSEC et enregistrements DS"
+
+#: pg-view/reg/index.php:77
+msgid "Once DNSSEC has been manually enabled through the current interface, the delegated zone can publish a CDS record in order to update the DS record in the registry. A single CDS record with value <code>0 0 0 0</code> tells the registry to disable DNSSEC. Using a CDS record to enable DNSSEC is not supported."
+msgstr "Une fois que DNSSEC a été manuellement activé en utilisant l'interface actuelle, la zone déléguée peut publier un enregistrement CDS afin de mettre à jour l'enregistrement DS dans le registre. Un unique enregistrement CDS avec pour valeur <code>0 0 0 0</code> indique au registre de désactiver DNSSEC. Utiliser un enregistrement CDS pour activer DNSSEC n'est pas supporté."
+
+#: pg-view/reg/register.php:3
 msgid "Register a new domain on your account."
 msgstr "Enregistrer un nouveau domaine sur son compte."
 
-#: pg-view/reg/register.php:18 pg-view/reg/transfer.php:14
+#: pg-view/reg/register.php:19 pg-view/reg/transfer.php:15
 msgid "Suffix"
 msgstr "Suffixe"
 
-#: pg-view/reg/register.php:31
+#: pg-view/reg/register.php:32
 msgid "Check availability"
 msgstr "Vérifier sa disponibilité"
 
-#: pg-view/reg/register.php:33
+#: pg-view/reg/register.php:34
 msgid "Register"
 msgstr "Enregistrer"
 
-#: pg-view/reg/transfer.php:2
+#: pg-view/reg/transfer.php:3
 #, php-format
 msgid "To prove that you are allowed to receive the domain by its current owner, the domain must have an NS record equal to %s when the form is being processed. The NS record will be automatically deleted once validated."
 msgstr "Pour prouver que vous êtes autorisé à recevoir le domaine par san possessaire actuele, ledit domaine doit posséder un enregistrement NS égal à %s lors du traitement de ce formulaire. Cet enregistrement sera automatiquement retiré une fois validé."
 
-#: pg-view/reg/transfer.php:7
+#: pg-view/reg/transfer.php:8
 msgid "Domain that will be transferred to this account"
 msgstr "Domaine à transférer vers ce compte"
 
-#: pg-view/reg/transfer.php:26
+#: pg-view/reg/transfer.php:27
 msgid "Receive the domain"
 msgstr "Recevoir le domaine"
 
-#: pg-view/reg/unregister.php:2
+#: pg-view/reg/unregister.php:3
 msgid "This will unregister the domain, making it registerable by anyone again (after a delay of 1 year plus half the registration period, with a maximum of 8 years)."
 msgstr "Ceci désenregistrera le domaine, ce qui le re-disponibilisera à tout le monde (après un délai de 1 an + la moitié de la durée d'enregistrement, avec un maximum de 8 ans)."
 
-#: pg-view/reg/unregister.php:16
+#: pg-view/reg/unregister.php:17
 msgid "Unregister"
 msgstr "Désenregistrer"

+ 359 - 251
locales/messages.pot

@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2023-06-15 01:33+0200\n"
+"POT-Creation-Date: 2023-07-31 01:03+0200\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -25,8 +25,8 @@ msgstr ""
 msgid "Manage account"
 msgstr ""
 
-#: pages.php:13 view.php:21 pg-view/auth/login.php:12
-#: pg-view/auth/register.php:1
+#: pages.php:13 view.php:22 pg-view/auth/login.php:13
+#: pg-view/auth/register.php:2
 msgid "Log in"
 msgstr ""
 
@@ -34,7 +34,7 @@ msgstr ""
 msgid "Start a new navigation session with an existing account"
 msgstr ""
 
-#: pages.php:18 view.php:19
+#: pages.php:18 view.php:20
 msgid "Log out"
 msgstr ""
 
@@ -117,180 +117,197 @@ msgstr ""
 msgid "Print every record related to a domain and served by the registry"
 msgstr ""
 
-#: pages.php:67 pages.php:72 pages.php:117 pages.php:122 pages.php:127
-#: pages.php:132 pages.php:137 pages.php:142 pages.php:147 pages.php:152
-#: pages.php:157 pages.php:162
+#: pages.php:67
+msgid "Edit records"
+msgstr ""
+
+#: pages.php:68
+msgid "Set registry records to delegate a domain to chosen name servers"
+msgstr ""
+
+#: pages.php:72 pages.php:77 pages.php:122 pages.php:127 pages.php:132
+#: pages.php:137 pages.php:142 pages.php:147 pages.php:152 pages.php:157
+#: pages.php:162 pages.php:167
 #, php-format
 msgid "%s records"
 msgstr ""
 
-#: pages.php:68
+#: pages.php:73
 #, php-format
 msgid "Indicate the name servers of a %s subdomain"
 msgstr ""
 
-#: pages.php:73
+#: pages.php:78
 msgid "Delegate <abbr title=\"Domain Name System Security Extensions\">DNSSEC</abbr> trust"
 msgstr ""
 
-#: pages.php:77
+#: pages.php:82
 msgid "Receive a domain transfer"
 msgstr ""
 
-#: pages.php:78
+#: pages.php:83
 msgid "Transfer a domain owned by another account to the current account"
 msgstr ""
 
-#: pages.php:82
+#: pages.php:87
 msgid "Glue records"
 msgstr ""
 
-#: pages.php:83
+#: pages.php:88
 msgid "Advanced: store the IP address of a name server whose domain is inside the domain it serves"
 msgstr ""
 
-#: pages.php:89 pg-view/ns/index.php:24
+#: pages.php:94 pg-view/ns/index.php:25
 msgid "Name servers"
 msgstr ""
 
-#: pages.php:90
+#: pages.php:95
 msgid "Host and manage domain's records"
 msgstr ""
 
-#: pages.php:93
+#: pages.php:98
 msgid "Add zone"
 msgstr ""
 
-#: pages.php:94
+#: pages.php:99
 #, php-format
 msgid "The zone will be managed by %s name servers"
 msgstr ""
 
-#: pages.php:98
+#: pages.php:103
 msgid "Delete zone"
 msgstr ""
 
-#: pages.php:99
+#: pages.php:104
 msgid "Erase all zone data"
 msgstr ""
 
-#: pages.php:102
+#: pages.php:107
 msgid "Display zone"
 msgstr ""
 
-#: pages.php:103
+#: pages.php:108
 msgid "Print zonefile content"
 msgstr ""
 
-#: pages.php:107
+#: pages.php:112
 msgid "Edit zone"
 msgstr ""
 
-#: pages.php:108
+#: pages.php:113
 msgid "Change zonefile content"
 msgstr ""
 
-#: pages.php:112
+#: pages.php:117
 msgid "AAAA and A records"
 msgstr ""
 
-#: pages.php:113
+#: pages.php:118
 msgid "Store domain's IP address"
 msgstr ""
 
-#: pages.php:118
+#: pages.php:123
 msgid "Store zone's name server"
 msgstr ""
 
-#: pages.php:123
+#: pages.php:128
 msgid "Associate text to domain"
 msgstr ""
 
-#: pages.php:128
+#: pages.php:133
 msgid "Limit the certificate authorities allowed to certify the domain"
 msgstr ""
 
-#: pages.php:133
+#: pages.php:138
 msgid "Store the location of a domain's service"
 msgstr ""
 
-#: pages.php:138
+#: pages.php:143
 msgid "Store the email server's address"
 msgstr ""
 
-#: pages.php:143
+#: pages.php:148
 msgid "Store <abbr title=\"Secure SHell\">SSH</abbr> public keys fingerprints"
 msgstr ""
 
-#: pages.php:148
+#: pages.php:153
 msgid "Setup <abbr title=\"DNS-based Authentication of Named Entities\">DANE</abbr> by publishing the <abbr title=\"Transport Layer Security\">TLS</abbr> certificate fingerprint"
 msgstr ""
 
-#: pages.php:153
+#: pages.php:158
 msgid "Define a domain as an alias of another"
 msgstr ""
 
-#: pages.php:158
+#: pages.php:163
 msgid "Define all subdomains of a domain as aliases of subdomains of another domain"
 msgstr ""
 
-#: pages.php:163
+#: pages.php:168
 msgid "Store geographic coordinates"
 msgstr ""
 
-#: pages.php:169
+#: pages.php:172
+#, php-format
+msgid "Synchronized records"
+msgstr ""
+
+#: pages.php:173
+msgid "Regularly fetch distant records and update them to a local zone"
+msgstr ""
+
+#: pages.php:179
 msgid "Web"
 msgstr ""
 
-#: pages.php:170
+#: pages.php:180
 msgid "Upload a static website into an <abbr title=\"SSH File Transfer Protocol\">SFTP</abbr> space"
 msgstr ""
 
-#: pages.php:173
+#: pages.php:183
 #, php-format
 msgid "%s subpath access"
 msgstr ""
 
-#: pages.php:174 pages.php:179 pages.php:184
+#: pages.php:184 pages.php:189 pages.php:194
 #, php-format
 msgid "Its URL will look like %s"
 msgstr ""
 
-#: pages.php:174 pages.php:179 pages.php:184
+#: pages.php:184 pages.php:189 pages.php:194
 msgid "mysite"
 msgstr ""
 
-#: pages.php:178
+#: pages.php:188
 #, php-format
 msgid "%s subdomain access"
 msgstr ""
 
-#: pages.php:183
+#: pages.php:193
 msgid "Dedicated domain with Let's Encrypt certificate access"
 msgstr ""
 
-#: pages.php:188
+#: pages.php:198
 msgid "Onion service access"
 msgstr ""
 
-#: pages.php:189
+#: pages.php:199
 #, php-format
 msgid "Its URL will look like %s, and work only through the Tor network"
 msgstr ""
 
-#: pages.php:193 pg-view/ht/del.php:18
+#: pages.php:203 pg-view/ht/del.php:19
 msgid "Delete access"
 msgstr ""
 
-#: pages.php:194
+#: pages.php:204
 msgid "Delete an existing HTTP access from a subdirectory of the SFTP space"
 msgstr ""
 
-#: pages.php:197
+#: pages.php:207
 msgid "Manage SSH keys"
 msgstr ""
 
-#: pages.php:198
+#: pages.php:208
 msgid "Choose what SSH key can edit what directory"
 msgstr ""
 
@@ -298,7 +315,7 @@ msgstr ""
 msgid "This account doesn't exist anymore. Log out to end this ghost session."
 msgstr ""
 
-#: router.php:106 view.php:39
+#: router.php:106 view.php:40
 msgid "This service is currently under maintenance. No action can be taken on it until an administrator finishes repairing it."
 msgstr ""
 
@@ -306,24 +323,24 @@ msgstr ""
 msgid "You need to be logged in to do this."
 msgstr ""
 
-#: view.php:19
+#: view.php:20
 msgid "You are using a testing account. It may be deleted anytime."
 msgstr ""
 
-#: view.php:19
+#: view.php:20
 msgid "Read more"
 msgstr ""
 
-#: view.php:21
+#: view.php:22
 msgid "Anonymous"
 msgstr ""
 
-#: view.php:44
+#: view.php:45
 #, php-format
 msgid "This form won't be accepted because you need to %slog in%s first."
 msgstr ""
 
-#: view.php:51
+#: view.php:52
 #, php-format
 msgid "%sSource code%s available under %s."
 msgstr ""
@@ -333,11 +350,11 @@ msgstr ""
 msgid "Your account can't be deleted because the %s service is currently unavailable."
 msgstr ""
 
-#: fn/auth.php:143
+#: fn/auth.php:163
 msgid "Account rate limit reached, try again later."
 msgstr ""
 
-#: fn/auth.php:168
+#: fn/auth.php:188
 msgid "Global rate limit reached, try again later."
 msgstr ""
 
@@ -353,29 +370,37 @@ msgstr ""
 msgid "<strong>Server error</strong>: "
 msgstr ""
 
-#: fn/common.php:132
+#: fn/common.php:155
 msgid "Wrong proof."
 msgstr ""
 
-#: fn/dns.php:64
+#: fn/dns.php:76
 msgid "IP address malformed."
 msgstr ""
 
-#: fn/dns.php:69 fn/ht.php:31
+#: fn/dns.php:82 fn/ht.php:48
 msgid "Domain malformed."
 msgstr ""
 
-#: fn/ns.php:31 pg-act/ns/edit.php:25
+#: fn/ns.php:33 pg-act/ns/edit.php:27
 #, php-format
 msgid "TTLs shorter than %s seconds are forbidden."
 msgstr ""
 
-#: fn/ns.php:33 pg-act/ns/edit.php:27
+#: fn/ns.php:35 pg-act/ns/edit.php:29
 #, php-format
 msgid "TTLs longer than %s seconds are forbidden."
 msgstr ""
 
-#: pg-view/index.php:3
+#: fn/reg.php:73
+msgid "You can only set a NS/DS record for an apex domain."
+msgstr ""
+
+#: fn/reg.php:75
+msgid "You can't set a record for another domain."
+msgstr ""
+
+#: pg-view/index.php:4
 msgid "About this installation"
 msgstr ""
 
@@ -408,7 +433,7 @@ msgstr ""
 msgid "Password updated."
 msgstr ""
 
-#: pg-act/auth/register.php:4 pg-view/auth/register.php:3
+#: pg-act/auth/register.php:4 pg-view/auth/register.php:4
 msgid "Registrations are currently closed on this installation."
 msgstr ""
 
@@ -446,13 +471,13 @@ msgstr ""
 msgid "No TXT record with the expected format has been found."
 msgstr ""
 
-#: pg-act/ht/add-dns.php:41 pg-act/ht/add-onion.php:37
+#: pg-act/ht/add-dns.php:48 pg-act/ht/add-onion.php:50
 #: pg-act/ht/add-subdomain.php:19 pg-act/ht/add-subpath.php:19
 #, php-format
 msgid "%s added on this directory."
 msgstr ""
 
-#: pg-act/ht/add-onion.php:37
+#: pg-act/ht/add-onion.php:50
 #, php-format
 msgid "Its address is: %s"
 msgstr ""
@@ -488,42 +513,50 @@ msgstr ""
 #: pg-act/ns/caa.php:25 pg-act/ns/cname.php:16 pg-act/ns/dname.php:16
 #: pg-act/ns/ip.php:16 pg-act/ns/loc.php:72 pg-act/ns/mx.php:20
 #: pg-act/ns/ns.php:16 pg-act/ns/srv.php:28 pg-act/ns/sshfp.php:25
-#: pg-act/ns/tlsa.php:29 pg-act/ns/txt.php:17 pg-act/reg/ds.php:32
-#: pg-act/reg/glue.php:14 pg-act/reg/ns.php:14
+#: pg-act/ns/tlsa.php:29 pg-act/ns/txt.php:17 pg-act/reg/ds.php:12
+#: pg-act/reg/glue.php:13 pg-act/reg/ns.php:12
 msgid "Modification done."
 msgstr ""
 
-#: pg-act/ns/edit.php:17
+#: pg-act/ns/edit.php:19 pg-act/reg/edit.php:14
 #, php-format
 msgid "The zone is limited to %s characters."
 msgstr ""
 
-#: pg-act/ns/edit.php:21
+#: pg-act/ns/edit.php:23 pg-act/reg/edit.php:18
 msgid "The following line does not match the expected format: "
 msgstr ""
 
-#: pg-act/ns/edit.php:23
+#: pg-act/ns/edit.php:25 pg-act/reg/edit.php:20 pg-act/reg/edit.php:28
 #, php-format
 msgid "The %s type is not allowed."
 msgstr ""
 
-#: pg-act/ns/edit.php:38
-msgid "Sent zone content is not correct (according to <code>kzonecheck</code>)."
+#: pg-act/ns/edit.php:40 pg-act/reg/edit.php:51
+msgid "Sent content is not correct (according to <code>kzonecheck</code>)."
+msgstr ""
+
+#: pg-act/ns/sync.php:19
+msgid "Multiple source domains can't be applied to the same target domain."
+msgstr ""
+
+#: pg-act/ns/sync.php:41
+msgid "Synchronized records updated."
 msgstr ""
 
 #: pg-act/ns/zone-add.php:6
 msgid "This zone already exists on the service."
 msgstr ""
 
-#: pg-act/ns/zone-add.php:12
+#: pg-act/ns/zone-add.php:18
 msgid "Parent zone's name servers not found."
 msgstr ""
 
-#: pg-act/ns/zone-add.php:18 pg-act/reg/transfer.php:18
+#: pg-act/ns/zone-add.php:30 pg-act/reg/transfer.php:24
 msgid "NS authentication record not found."
 msgstr ""
 
-#: pg-act/ns/zone-add.php:54
+#: pg-act/ns/zone-add.php:67
 msgid "Zone created."
 msgstr ""
 
@@ -531,6 +564,10 @@ msgstr ""
 msgid "Zone deleted."
 msgstr ""
 
+#: pg-act/reg/edit.php:22
+msgid "A DS record expects 4 arguments."
+msgstr ""
+
 #: pg-act/reg/register.php:4 pg-act/reg/transfer.php:4
 msgid "This format of subdomain is not allowed."
 msgstr ""
@@ -571,7 +608,7 @@ msgstr ""
 msgid "The current account already owns this domain."
 msgstr ""
 
-#: pg-act/reg/transfer.php:33
+#: pg-act/reg/transfer.php:39
 msgid "The domain has been transferred to the current account ; the NS authentication record has been automatically deleted."
 msgstr ""
 
@@ -579,651 +616,722 @@ msgstr ""
 msgid "Domain unregistered."
 msgstr ""
 
-#: pg-view/auth/approval.php:2
+#: pg-view/auth/approval.php:3
 msgid "This form allows to use an approval key to validate your account. Approval keys are distributed by an administrator upon request."
 msgstr ""
 
-#: pg-view/auth/approval.php:6
+#: pg-view/auth/approval.php:7
 msgid "Approval key"
 msgstr ""
 
-#: pg-view/auth/approval.php:9
+#: pg-view/auth/approval.php:10
 msgid "Use for this account"
 msgstr ""
 
-#: pg-view/auth/index.php:3
+#: pg-view/auth/index.php:4
 msgid "Account type"
 msgstr ""
 
-#: pg-view/auth/index.php:9
+#: pg-view/auth/index.php:10
 msgid "You are currently using a <strong>testing</strong> account."
 msgstr ""
 
-#: pg-view/auth/index.php:10
+#: pg-view/auth/index.php:11
 msgid "You are currently using an <strong>approved</strong> account."
 msgstr ""
 
-#: pg-view/auth/index.php:13
+#: pg-view/auth/index.php:14
 msgid "You are not logged in."
 msgstr ""
 
-#: pg-view/auth/index.php:18
+#: pg-view/auth/index.php:19
 msgid "When an account is created, it's a <em>testing</em> account. A testing account is only temporary and with limited capabilities on the services. Once the account is validated by using an approval key requested to an administrator, it becomes an <em>approved</em> account."
 msgstr ""
 
-#: pg-view/auth/index.php:21
+#: pg-view/auth/index.php:22
 msgid "Rate limit"
 msgstr ""
 
-#: pg-view/auth/index.php:25
+#: pg-view/auth/index.php:26
 #, php-format
 msgid "Your account is at %s%% of the rate limit."
 msgstr ""
 
-#: pg-view/auth/index.php:27
+#: pg-view/auth/index.php:28
 msgid "Most of the form submissions bring you closer to the rate limit. If you reach it, you need to wait in order to be able to submit forms again."
 msgstr ""
 
-#: pg-view/auth/index.php:31
+#: pg-view/auth/index.php:32
 msgid "Internal ID"
 msgstr ""
 
-#: pg-view/auth/index.php:33
+#: pg-view/auth/index.php:34
 #, php-format
 msgid "The current account's internal ID is %s."
 msgstr ""
 
-#: pg-view/auth/login.php:1
+#: pg-view/auth/login.php:2
 msgid "New?"
 msgstr ""
 
-#: pg-view/auth/login.php:1 pg-view/auth/register.php:16
+#: pg-view/auth/login.php:2 pg-view/auth/register.php:17
 msgid "Create an account"
 msgstr ""
 
-#: pg-view/auth/login.php:4 pg-view/auth/register.php:6 pg-view/ht/index.php:64
+#: pg-view/auth/login.php:5 pg-view/auth/register.php:7 pg-view/ht/index.php:65
 msgid "Username"
 msgstr ""
 
-#: pg-view/auth/login.php:8 pg-view/auth/register.php:11
-#: pg-view/ht/index.php:68
+#: pg-view/auth/login.php:9 pg-view/auth/register.php:12
+#: pg-view/ht/index.php:69
 msgid "Password"
 msgstr ""
 
-#: pg-view/auth/password.php:2 pg-view/auth/unregister.php:6
-#: pg-view/auth/username.php:2
+#: pg-view/auth/password.php:3 pg-view/auth/unregister.php:7
+#: pg-view/auth/username.php:3
 msgid "Current password"
 msgstr ""
 
-#: pg-view/auth/password.php:5
+#: pg-view/auth/password.php:6
 msgid "New password"
 msgstr ""
 
-#: pg-view/auth/password.php:8
+#: pg-view/auth/password.php:9
 msgid "Update password"
 msgstr ""
 
-#: pg-view/auth/register.php:1
+#: pg-view/auth/register.php:2
 msgid "Already have an account?"
 msgstr ""
 
-#: pg-view/auth/register.php:12
+#: pg-view/auth/register.php:13
 #, php-format
 msgid "Minimum %1$s characters, or %2$s characters if it contains lowercase, uppercase and digit."
 msgstr ""
 
-#: pg-view/auth/unregister.php:2
+#: pg-view/auth/unregister.php:3
 msgid "This will delete every resource managed by the current account, including registered domains, hosted DNS records, websites files and cryptographic keys for Onion services and DNSSEC."
 msgstr ""
 
-#: pg-view/auth/unregister.php:10
+#: pg-view/auth/unregister.php:11
 msgid "Delete the current account and everything related (required)"
 msgstr ""
 
-#: pg-view/auth/unregister.php:12 pg-view/ns/form.ns.php:4 pg-view/reg/ds.php:5
-#: pg-view/reg/glue.php:5 pg-view/reg/ns.php:5
+#: pg-view/auth/unregister.php:13 pg-view/ns/form.ns.php:5
+#: pg-view/reg/select-action.inc.php:5
 msgid "Delete"
 msgstr ""
 
-#: pg-view/auth/username.php:5
+#: pg-view/auth/username.php:6
 msgid "New username"
 msgstr ""
 
-#: pg-view/auth/username.php:8
+#: pg-view/auth/username.php:9
 msgid "Update username"
 msgstr ""
 
-#: pg-view/ht/add-dns.php:2
+#: pg-view/ht/add-dns.php:3
 msgid "A Let's Encrypt certificate will be obtained."
 msgstr ""
 
-#: pg-view/ht/add-dns.php:6
+#: pg-view/ht/add-dns.php:7
 msgid "The domain must have the following records when the form is being processed."
 msgstr ""
 
-#: pg-view/ht/add-dns.php:29 pg-view/ns/form.ns.php:8 pg-view/ns/print.php:32
-#: pg-view/ns/zone-add.php:6 pg-view/reg/ds.php:8 pg-view/reg/glue.php:8
-#: pg-view/reg/glue.php:15 pg-view/reg/ns.php:8 pg-view/reg/print.php:2
-#: pg-view/reg/print.php:16 pg-view/reg/register.php:11
-#: pg-view/reg/unregister.php:6
+#: pg-view/ht/add-dns.php:30 pg-view/ns/form.ns.php:9 pg-view/ns/print.php:33
+#: pg-view/ns/zone-add.php:7 pg-view/reg/glue.php:5 pg-view/reg/print.php:10
+#: pg-view/reg/register.php:12 pg-view/reg/select-domain.inc.php:2
+#: pg-view/reg/unregister.php:7
 msgid "Domain"
 msgstr ""
 
-#: pg-view/ht/add-dns.php:31 pg-view/ht/add-onion.php:2
-#: pg-view/ht/add-subdomain.php:8 pg-view/ht/add-subpath.php:8
+#: pg-view/ht/add-dns.php:32 pg-view/ht/add-onion.php:3
+#: pg-view/ht/add-subdomain.php:9 pg-view/ht/add-subpath.php:9
 msgid "Target directory"
 msgstr ""
 
-#: pg-view/ht/add-dns.php:40 pg-view/ht/add-onion.php:11
-#: pg-view/ht/add-subdomain.php:17 pg-view/ht/add-subpath.php:17
+#: pg-view/ht/add-dns.php:41 pg-view/ht/add-onion.php:12
+#: pg-view/ht/add-subdomain.php:18 pg-view/ht/add-subpath.php:18
 msgid "Setup access"
 msgstr ""
 
-#: pg-view/ht/add-subdomain.php:2 pg-view/reg/register.php:6
+#: pg-view/ht/add-subdomain.php:3 pg-view/reg/register.php:7
 #, php-format
 msgid "The subdomain can only contain %1$s, %2$s and %3$s, and must be between 4 and 63 characters. It can't have an hyphen (%3$s) in first, last or both third and fourth position."
 msgstr ""
 
-#: pg-view/ht/add-subdomain.php:6 pg-view/ns/form.ns.php:10
-#: pg-view/reg/glue.php:10 pg-view/reg/register.php:13
-#: pg-view/reg/transfer.php:9
+#: pg-view/ht/add-subdomain.php:7 pg-view/ns/form.ns.php:11
+#: pg-view/reg/glue.php:7 pg-view/reg/register.php:14
+#: pg-view/reg/transfer.php:10
 msgid "Subdomain"
 msgstr ""
 
-#: pg-view/ht/add-subpath.php:2
+#: pg-view/ht/add-subpath.php:3
 #, php-format
 msgid "The path can only contain %1$s, %2$s and %3$s, and must be between 4 and 63 characters."
 msgstr ""
 
-#: pg-view/ht/add-subpath.php:6
+#: pg-view/ht/add-subpath.php:7
 msgid "Path"
 msgstr ""
 
-#: pg-view/ht/del.php:2
+#: pg-view/ht/del.php:3
 msgid "Access to delete"
 msgstr ""
 
-#: pg-view/ht/index.php:2
+#: pg-view/ht/del.php:14
+#, php-format
+msgid "%1$s to %2$s"
+msgstr ""
+
+#: pg-view/ht/index.php:3
 msgid "This service allows you to send files on the server using SFTP, and to make them publicly available with HTTP."
 msgstr ""
 
-#: pg-view/ht/index.php:8
+#: pg-view/ht/index.php:9
 msgid "Currently hosted sites"
 msgstr ""
 
-#: pg-view/ht/index.php:38
+#: pg-view/ht/index.php:39
 msgid "Adding a site access"
 msgstr ""
 
-#: pg-view/ht/index.php:40
+#: pg-view/ht/index.php:41
 #, php-format
 msgid "In order to be able to set up an HTTP site with this service, a subdirectory for this site must be created inside the SFTP space first. The name of this subdirectory can only contain %1$s, %2$s, %3$s, %4$s and %5$s."
 msgstr ""
 
-#: pg-view/ht/index.php:44
+#: pg-view/ht/index.php:45
 msgid "Connecting to the SFTP server"
 msgstr ""
 
-#: pg-view/ht/index.php:52
+#: pg-view/ht/index.php:53
 msgid "Server"
 msgstr ""
 
-#: pg-view/ht/index.php:56 pg-view/ns/srv.php:16
+#: pg-view/ht/index.php:57 pg-view/ns/srv.php:17
 msgid "Port"
 msgstr ""
 
-#: pg-view/ht/index.php:60
+#: pg-view/ht/index.php:61
 msgid "Directory"
 msgstr ""
 
-#: pg-view/ht/index.php:70
+#: pg-view/ht/index.php:71
 msgid "The one of your account"
 msgstr ""
 
-#: pg-view/ht/index.php:74
+#: pg-view/ht/index.php:75
 msgid "Authenticating the server"
 msgstr ""
 
-#: pg-view/ht/index.php:76
+#: pg-view/ht/index.php:77
 msgid "An SSHFP record is available."
 msgstr ""
 
-#: pg-view/ht/index.php:79
+#: pg-view/ht/index.php:80
 msgid "Plain public key"
 msgstr ""
 
-#: pg-view/ht/index.php:84
+#: pg-view/ht/index.php:85
 msgid "Public key fingerprint"
 msgstr ""
 
-#: pg-view/ht/index.php:89
+#: pg-view/ht/index.php:90
 msgid "ASCII art"
 msgstr ""
 
-#: pg-view/ht/index.php:102
+#: pg-view/ht/index.php:103
 msgid "A content security policy (CSP) forbids Web browsers from loading JavaScript or third-party resources."
 msgstr ""
 
-#: pg-view/ht/index.php:105
+#: pg-view/ht/index.php:106
 msgid "<code>.htaccess</code> configuration"
 msgstr ""
 
-#: pg-view/ht/index.php:107
+#: pg-view/ht/index.php:108
 msgid "You can change the way the HTTP server answers to requests in a directory by setting some directives in a file named <code>.htaccess</code> at the root of this directory. Only the following directives are allowed:"
 msgstr ""
 
-#: pg-view/ht/index.php:163
+#: pg-view/ht/index.php:164
 msgid "Accounts capabilities"
 msgstr ""
 
-#: pg-view/ht/index.php:165
+#: pg-view/ht/index.php:166
 msgid "Testing"
 msgstr ""
 
-#: pg-view/ht/index.php:168 pg-view/ht/index.php:175
+#: pg-view/ht/index.php:169 pg-view/ht/index.php:176
 #, php-format
 msgid "%s of SFTP quota"
 msgstr ""
 
-#: pg-view/ht/index.php:168 pg-view/ht/index.php:175
+#: pg-view/ht/index.php:169 pg-view/ht/index.php:176
 msgid "<abbr title=\"gibibyte\">GiB</abbr>"
 msgstr ""
 
-#: pg-view/ht/index.php:168 pg-view/ht/index.php:175
+#: pg-view/ht/index.php:169 pg-view/ht/index.php:176
 msgid "<abbr title=\"mebibyte\">MiB</abbr>"
 msgstr ""
 
-#: pg-view/ht/index.php:169
+#: pg-view/ht/index.php:170
 msgid "Let's Encrypt certificate from the staging environment (not trusted by clients)"
 msgstr ""
 
-#: pg-view/ht/index.php:172
+#: pg-view/ht/index.php:173
 msgid "Approved"
 msgstr ""
 
-#: pg-view/ht/index.php:176
+#: pg-view/ht/index.php:177
 msgid "Stable Let's Encrypt certificates"
 msgstr ""
 
-#: pg-view/ht/keys.php:2
+#: pg-view/ht/keys.php:3
 msgid "In addition to your password, you can also access your SFTP space using Ed25519 SSH keys. A key can be granted modification rights to the full space (<code>/</code>) or to any arbitrary subdirectory. A key is always  allowed to list any directory content."
 msgstr ""
 
-#: pg-view/ht/keys.php:17
+#: pg-view/ht/keys.php:18
 msgid "Add new SSH key access"
 msgstr ""
 
-#: pg-view/ht/keys.php:17
+#: pg-view/ht/keys.php:18
 msgid "SSH key access"
 msgstr ""
 
-#: pg-view/ht/keys.php:19
+#: pg-view/ht/keys.php:20
 msgid "Public key"
 msgstr ""
 
-#: pg-view/ht/keys.php:23
+#: pg-view/ht/keys.php:24
 msgid "Allowed directory"
 msgstr ""
 
-#: pg-view/ht/keys.php:30
+#: pg-view/ht/keys.php:31 pg-view/ns/sync.php:38
 msgid "Update"
 msgstr ""
 
-#: pg-view/ns/caa.php:3
+#: pg-view/ns/caa.php:4
 msgid "Flag"
 msgstr ""
 
-#: pg-view/ns/caa.php:7 pg-view/ns/print.php:56
+#: pg-view/ns/caa.php:8 pg-view/ns/print.php:57
 msgid "Tag"
 msgstr ""
 
-#: pg-view/ns/caa.php:11 pg-view/ns/form.ns.php:31 pg-view/ns/print.php:35
-#: pg-view/ns/tlsa.php:37 pg-view/reg/print.php:19
+#: pg-view/ns/caa.php:12 pg-view/ns/form.ns.php:33 pg-view/ns/print.php:36
+#: pg-view/ns/tlsa.php:38 pg-view/reg/print.php:13
 msgid "Value"
 msgstr ""
 
-#: pg-view/ns/caa.php:15 pg-view/ns/cname.php:7 pg-view/ns/dname.php:7
-#: pg-view/ns/ip.php:5 pg-view/ns/loc.php:75 pg-view/ns/mx.php:11
-#: pg-view/ns/ns.php:7 pg-view/ns/srv.php:27 pg-view/ns/sshfp.php:29
-#: pg-view/ns/tlsa.php:43 pg-view/ns/txt.php:7 pg-view/reg/ds.php:56
-#: pg-view/reg/glue.php:29 pg-view/reg/ns.php:22
+#: pg-view/ns/caa.php:16 pg-view/ns/cname.php:8 pg-view/ns/dname.php:8
+#: pg-view/ns/ip.php:6 pg-view/ns/loc.php:76 pg-view/ns/mx.php:12
+#: pg-view/ns/ns.php:8 pg-view/ns/srv.php:28 pg-view/ns/sshfp.php:30
+#: pg-view/ns/tlsa.php:44 pg-view/ns/txt.php:8 pg-view/reg/ds.php:45
+#: pg-view/reg/glue.php:18 pg-view/reg/ns.php:11
 msgid "Apply"
 msgstr ""
 
-#: pg-view/ns/cname.php:3
+#: pg-view/ns/cname.php:4
 msgid "Canonical name"
 msgstr ""
 
-#: pg-view/ns/dname.php:3
+#: pg-view/ns/dname.php:4
 msgid "Delegation name"
 msgstr ""
 
-#: pg-view/ns/edit.php:2
+#: pg-view/ns/edit.php:3
 msgid "Zone to be changed"
 msgstr ""
 
-#: pg-view/ns/edit.php:12 pg-view/ns/print.php:20 pg-view/reg/print.php:11
+#: pg-view/ns/edit.php:13 pg-view/ns/print.php:21 pg-view/reg/edit.php:13
+#: pg-view/reg/print.php:5
 msgid "Display"
 msgstr ""
 
-#: pg-view/ns/edit.php:23
+#: pg-view/ns/edit.php:24
 #, php-format
-msgid "New content of the %s zone"
+msgid "Authoritative records for %s"
 msgstr ""
 
-#: pg-view/ns/edit.php:27
+#: pg-view/ns/edit.php:28 pg-view/reg/edit.php:28
 msgid "Replace"
 msgstr ""
 
-#: pg-view/ns/edit.php:38
+#: pg-view/ns/edit.php:39
 msgid "Default values"
 msgstr ""
 
-#: pg-view/ns/edit.php:40
+#: pg-view/ns/edit.php:41
 #, php-format
 msgid "If the TTL is omitted, it will default to %s seconds."
 msgstr ""
 
-#: pg-view/ns/edit.php:42
+#: pg-view/ns/edit.php:43 pg-view/reg/edit.php:41
 msgid "Precising the class (<code>IN</code>) is optional."
 msgstr ""
 
-#: pg-view/ns/edit.php:44
+#: pg-view/ns/edit.php:45
 msgid "Allowed values"
 msgstr ""
 
-#: pg-view/ns/edit.php:46
+#: pg-view/ns/edit.php:47 pg-view/reg/edit.php:43
 #, php-format
-msgid "Submitted zone content is limited to %s characters."
+msgid "Submitted field content is limited to %s characters."
 msgstr ""
 
-#: pg-view/ns/edit.php:48
+#: pg-view/ns/edit.php:49
 #, php-format
 msgid "TTLs must last between %1$s and %2$s seconds."
 msgstr ""
 
-#: pg-view/ns/edit.php:50
-msgid "The only types that can be defined are:"
+#: pg-view/ns/edit.php:51 pg-view/reg/edit.php:47
+msgid "The only types that can be defined here are:"
 msgstr ""
 
-#: pg-view/ns/form.ns.php:1 pg-view/reg/ds.php:2 pg-view/reg/glue.php:2
-#: pg-view/reg/ns.php:2
+#: pg-view/ns/form.ns.php:2 pg-view/reg/select-action.inc.php:2
 msgid "Action"
 msgstr ""
 
-#: pg-view/ns/form.ns.php:3 pg-view/ns/zone-add.php:8 pg-view/reg/ds.php:4
-#: pg-view/reg/glue.php:4 pg-view/reg/ns.php:4
+#: pg-view/ns/form.ns.php:4 pg-view/ns/zone-add.php:9
+#: pg-view/reg/select-action.inc.php:4
 msgid "Add"
 msgstr ""
 
-#: pg-view/ns/form.ns.php:15 pg-view/ns/print.php:52 pg-view/ns/zone-del.php:2
+#: pg-view/ns/form.ns.php:16 pg-view/ns/print.php:53 pg-view/ns/zone-del.php:3
 msgid "Zone"
 msgstr ""
 
-#: pg-view/ns/form.ns.php:29 pg-view/ns/print.php:33 pg-view/reg/print.php:17
+#: pg-view/ns/form.ns.php:31 pg-view/ns/print.php:34 pg-view/reg/print.php:11
 msgid "TTL"
 msgstr ""
 
-#: pg-view/ns/form.ns.php:45
+#: pg-view/ns/form.ns.php:47
 msgid "Unit"
 msgstr ""
 
-#: pg-view/ns/form.ns.php:48
+#: pg-view/ns/form.ns.php:50
 msgid "second"
 msgstr ""
 
-#: pg-view/ns/form.ns.php:49
+#: pg-view/ns/form.ns.php:51
 msgid "minute"
 msgstr ""
 
-#: pg-view/ns/form.ns.php:50
+#: pg-view/ns/form.ns.php:52
 msgid "hour"
 msgstr ""
 
-#: pg-view/ns/form.ns.php:51
+#: pg-view/ns/form.ns.php:53
 msgid "day"
 msgstr ""
 
-#: pg-view/ns/index.php:2
+#: pg-view/ns/index.php:3
 msgid "This service allows to host and manage DNS records inside a DNS zone."
 msgstr ""
 
-#: pg-view/ns/index.php:8
+#: pg-view/ns/index.php:9
 msgid "Currently hosted zones"
 msgstr ""
 
-#: pg-view/ns/index.php:26
+#: pg-view/ns/index.php:27
 msgid "A zone hosted on this service is served by these name servers:"
 msgstr ""
 
-#: pg-view/ns/ip.php:3 pg-view/reg/glue.php:26
+#: pg-view/ns/ip.php:4 pg-view/reg/glue.php:15
 msgid "IP address"
 msgstr ""
 
-#: pg-view/ns/loc.php:4
+#: pg-view/ns/loc.php:5
 msgid "Latitude"
 msgstr ""
 
-#: pg-view/ns/loc.php:6 pg-view/ns/loc.php:34
+#: pg-view/ns/loc.php:7 pg-view/ns/loc.php:35
 msgid "Degrees"
 msgstr ""
 
-#: pg-view/ns/loc.php:11 pg-view/ns/loc.php:39
+#: pg-view/ns/loc.php:12 pg-view/ns/loc.php:40
 msgid "Minutes"
 msgstr ""
 
-#: pg-view/ns/loc.php:16 pg-view/ns/loc.php:44
+#: pg-view/ns/loc.php:17 pg-view/ns/loc.php:45
 msgid "Seconds"
 msgstr ""
 
-#: pg-view/ns/loc.php:21 pg-view/ns/loc.php:49
+#: pg-view/ns/loc.php:22 pg-view/ns/loc.php:50
 msgid "Direction"
 msgstr ""
 
-#: pg-view/ns/loc.php:25
+#: pg-view/ns/loc.php:26
 msgid "North"
 msgstr ""
 
-#: pg-view/ns/loc.php:26
+#: pg-view/ns/loc.php:27
 msgid "South"
 msgstr ""
 
-#: pg-view/ns/loc.php:32
+#: pg-view/ns/loc.php:33
 msgid "Longitude"
 msgstr ""
 
-#: pg-view/ns/loc.php:53
+#: pg-view/ns/loc.php:54
 msgid "East"
 msgstr ""
 
-#: pg-view/ns/loc.php:54
+#: pg-view/ns/loc.php:55
 msgid "West"
 msgstr ""
 
-#: pg-view/ns/loc.php:59
+#: pg-view/ns/loc.php:60
 msgid "Altitude"
 msgstr ""
 
-#: pg-view/ns/loc.php:63
+#: pg-view/ns/loc.php:64
 msgid "Size"
 msgstr ""
 
-#: pg-view/ns/loc.php:67
+#: pg-view/ns/loc.php:68
 msgid "Horizontal precision"
 msgstr ""
 
-#: pg-view/ns/loc.php:71
+#: pg-view/ns/loc.php:72
 msgid "Vertical precision"
 msgstr ""
 
-#: pg-view/ns/mx.php:3 pg-view/ns/srv.php:4
+#: pg-view/ns/mx.php:4 pg-view/ns/srv.php:5
 msgid "Priority"
 msgstr ""
 
-#: pg-view/ns/mx.php:7
+#: pg-view/ns/mx.php:8
 msgid "Host"
 msgstr ""
 
-#: pg-view/ns/ns.php:3 pg-view/reg/ns.php:18
+#: pg-view/ns/ns.php:4 pg-view/reg/ns.php:7
 msgid "Name server"
 msgstr ""
 
-#: pg-view/ns/print.php:3
+#: pg-view/ns/print.php:4
 msgid "Records table"
 msgstr ""
 
-#: pg-view/ns/print.php:6
+#: pg-view/ns/print.php:7
 msgid "DS record"
 msgstr ""
 
-#: pg-view/ns/print.php:9
+#: pg-view/ns/print.php:10
 msgid "Raw zonefile"
 msgstr ""
 
-#: pg-view/ns/print.php:11
+#: pg-view/ns/print.php:12
 msgid "Selected zone"
 msgstr ""
 
-#: pg-view/ns/print.php:34 pg-view/reg/print.php:18
+#: pg-view/ns/print.php:35 pg-view/reg/print.php:12
 msgid "Type"
 msgstr ""
 
-#: pg-view/ns/print.php:60 pg-view/ns/sshfp.php:4 pg-view/reg/ds.php:22
+#: pg-view/ns/print.php:61 pg-view/ns/sshfp.php:5 pg-view/reg/ds.php:11
 msgid "Algorithm"
 msgstr ""
 
-#: pg-view/ns/print.php:64 pg-view/reg/ds.php:41
+#: pg-view/ns/print.php:65 pg-view/reg/ds.php:30
 msgid "Digest type"
 msgstr ""
 
-#: pg-view/ns/print.php:68
+#: pg-view/ns/print.php:69
 msgid "Digest"
 msgstr ""
 
-#: pg-view/ns/srv.php:10
+#: pg-view/ns/srv.php:11
 msgid "Weight"
 msgstr ""
 
-#: pg-view/ns/srv.php:22
+#: pg-view/ns/srv.php:23
 msgid "Target"
 msgstr ""
 
-#: pg-view/ns/sshfp.php:15
+#: pg-view/ns/sshfp.php:16
 msgid "Hash type"
 msgstr ""
 
-#: pg-view/ns/sshfp.php:24
+#: pg-view/ns/sshfp.php:25
 msgid "Fingerprint"
 msgstr ""
 
-#: pg-view/ns/tlsa.php:4
+#: pg-view/ns/sync.php:3
+#, php-format
+msgid "AAAA, A and CAA records are regularly copied from the source domain to the target domain. Their TTLs are set to %s seconds."
+msgstr ""
+
+#: pg-view/ns/sync.php:6
+msgid "Source domains that are not signed with DNSSEC are not synchronized. Synchronizations that remain broken may be deleted."
+msgstr ""
+
+#: pg-view/ns/sync.php:9
+msgid "This is meant to be used for apex domains, where CNAME records are not allowed. For non-apex domains, CNAME records should be used instead."
+msgstr ""
+
+#: pg-view/ns/sync.php:17
+msgid "Add new domain records to be synchronized"
+msgstr ""
+
+#: pg-view/ns/sync.php:17
+msgid "Synchronized domain"
+msgstr ""
+
+#: pg-view/ns/sync.php:19
+msgid "Source domain"
+msgstr ""
+
+#: pg-view/ns/sync.php:23
+msgid "Target domain"
+msgstr ""
+
+#: pg-view/ns/tlsa.php:5
 msgid "Use"
 msgstr ""
 
-#: pg-view/ns/tlsa.php:16
+#: pg-view/ns/tlsa.php:17
 msgid "Selector"
 msgstr ""
 
-#: pg-view/ns/tlsa.php:20
+#: pg-view/ns/tlsa.php:21
 msgid "the full certificate must match"
 msgstr ""
 
-#: pg-view/ns/tlsa.php:21
+#: pg-view/ns/tlsa.php:22
 msgid "the certificate public key must match"
 msgstr ""
 
-#: pg-view/ns/tlsa.php:26
+#: pg-view/ns/tlsa.php:27
 msgid "Match type"
 msgstr ""
 
-#: pg-view/ns/tlsa.php:30
+#: pg-view/ns/tlsa.php:31
 msgid "full certificate"
 msgstr ""
 
-#: pg-view/ns/txt.php:3
+#: pg-view/ns/txt.php:4
 msgid "Text"
 msgstr ""
 
-#: pg-view/ns/txt.php:5
+#: pg-view/ns/txt.php:6
 msgid "Some text…"
 msgstr ""
 
-#: pg-view/ns/zone-add.php:2
+#: pg-view/ns/zone-add.php:3
 #, php-format
 msgid "To prove that you own this domain, it must have a NS record equal to %s when the form is being processed."
 msgstr ""
 
-#: pg-view/ns/zone-del.php:11
+#: pg-view/ns/zone-del.php:12
 msgid "Delete everything related to this zone"
 msgstr ""
 
-#: pg-view/reg/ds.php:18
+#: pg-view/reg/ds.php:7
 msgid "Key tag"
 msgstr ""
 
-#: pg-view/reg/ds.php:52
+#: pg-view/reg/ds.php:41
 msgid "Key"
 msgstr ""
 
-#: pg-view/reg/glue.php:27
+#: pg-view/reg/edit.php:3
+msgid "Domain to be changed"
+msgstr ""
+
+#: pg-view/reg/edit.php:24
+#, php-format
+msgid "Delegation records for %s"
+msgstr ""
+
+#: pg-view/reg/edit.php:39
+msgid "Input values"
+msgstr ""
+
+#: pg-view/reg/edit.php:45
+#, php-format
+msgid "TTL values are ignored and always set to %s seconds."
+msgstr ""
+
+#: pg-view/reg/glue.php:16
 #, php-format
 msgid "%1$s or %2$s"
 msgstr ""
 
-#: pg-view/reg/index.php:2
+#: pg-view/reg/index.php:3
 #, php-format
 msgid "This domain name registry allows to register domains ending with <code>%1$s</code>, for instance <code><em>domain</em>%1$s</code>."
 msgstr ""
 
-#: pg-view/reg/index.php:7
+#: pg-view/reg/index.php:8
 msgid "Currently registered domains"
 msgstr ""
 
-#: pg-view/reg/index.php:27
+#: pg-view/reg/index.php:28
 msgid "Both <span aria-hidden=\"true\">⏳ </span><em>testing</em> and <span aria-hidden=\"true\">👤 </span><em>approved</em> accounts can register a domain under these suffixes:"
 msgstr ""
 
-#: pg-view/reg/index.php:38
+#: pg-view/reg/index.php:41
 msgid "Only <span aria-hidden=\"true\">👤 </span><em>approved</em> accounts can register a domain under these suffixes:"
 msgstr ""
 
-#: pg-view/reg/index.php:49
+#: pg-view/reg/index.php:54
 msgid "Nobody can register a domain under these suffixes:"
 msgstr ""
 
-#: pg-view/reg/register.php:2
+#: pg-view/reg/index.php:70
+msgid "Automatic updates from child zone"
+msgstr ""
+
+#: pg-view/reg/index.php:71
+msgid "CSYNC records"
+msgstr ""
+
+#: pg-view/reg/index.php:73
+msgid "The registry can synchronize NS records from the child zone if a CSYNC record is present at the apex of the child zone, has flags <code>1</code> and type bit map <code>NS</code>, and can be DNSSEC-validated. Others values are not supported."
+msgstr ""
+
+#: pg-view/reg/index.php:75
+msgid "DNSSEC and DS records"
+msgstr ""
+
+#: pg-view/reg/index.php:77
+msgid "Once DNSSEC has been manually enabled through the current interface, the delegated zone can publish a CDS record in order to update the DS record in the registry. A single CDS record with value <code>0 0 0 0</code> tells the registry to disable DNSSEC. Using a CDS record to enable DNSSEC is not supported."
+msgstr ""
+
+#: pg-view/reg/register.php:3
 msgid "Register a new domain on your account."
 msgstr ""
 
-#: pg-view/reg/register.php:18 pg-view/reg/transfer.php:14
+#: pg-view/reg/register.php:19 pg-view/reg/transfer.php:15
 msgid "Suffix"
 msgstr ""
 
-#: pg-view/reg/register.php:31
+#: pg-view/reg/register.php:32
 msgid "Check availability"
 msgstr ""
 
-#: pg-view/reg/register.php:33
+#: pg-view/reg/register.php:34
 msgid "Register"
 msgstr ""
 
-#: pg-view/reg/transfer.php:2
+#: pg-view/reg/transfer.php:3
 #, php-format
 msgid "To prove that you are allowed to receive the domain by its current owner, the domain must have an NS record equal to %s when the form is being processed. The NS record will be automatically deleted once validated."
 msgstr ""
 
-#: pg-view/reg/transfer.php:7
+#: pg-view/reg/transfer.php:8
 msgid "Domain that will be transferred to this account"
 msgstr ""
 
-#: pg-view/reg/transfer.php:26
+#: pg-view/reg/transfer.php:27
 msgid "Receive the domain"
 msgstr ""
 
-#: pg-view/reg/unregister.php:2
+#: pg-view/reg/unregister.php:3
 msgid "This will unregister the domain, making it registerable by anyone again (after a delay of 1 year plus half the registration period, with a maximum of 8 years)."
 msgstr ""
 
-#: pg-view/reg/unregister.php:16
+#: pg-view/reg/unregister.php:17
 msgid "Unregister"
 msgstr ""

+ 11 - 1
pages.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 define('PAGES', [
 	'index' => [
@@ -63,6 +63,11 @@ define('PAGES', [
 			'description' => _('Print every record related to a domain and served by the registry'),
 			'tokens_account_cost' => 60,
 		],
+		'edit' => [
+			'title' => _('Edit records'),
+			'description' => _('Set registry records to delegate a domain to chosen name servers'),
+			'tokens_account_cost' => 900,
+		],
 		'ns' => [
 			'title' => sprintf(_('%s records'), '<abbr title="Name Server">NS</abbr>'),
 			'description' => sprintf(_('Indicate the name servers of a %s subdomain'), '<code>' . key(CONF['reg']['suffixes']) . '</code>'),
@@ -163,6 +168,11 @@ define('PAGES', [
 			'description' => _('Store geographic coordinates'),
 			'tokens_account_cost' => 120,
 		],
+		'sync' => [
+			'title' => sprintf(_('Synchronized records')),
+			'description' => _('Regularly fetch distant records and update them to a local zone'),
+			'tokens_account_cost' => 900,
+		],
 	],
 	'ht' => [
 		'index' => [

+ 1 - 1
pg-act/auth/approval.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 if ($_SESSION['type'] !== 'testing')
 	output(403, _('This account is already approved.'));

+ 1 - 1
pg-act/auth/login.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 checkPasswordFormat($_POST['password']);
 

+ 0 - 3
pg-act/auth/logout.php

@@ -1,3 +0,0 @@
-<?php
-
-logout();

+ 1 - 1
pg-act/auth/password.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 checkPasswordFormat($_POST['new-password']);
 

+ 1 - 1
pg-act/auth/register.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 if (CONF['common']['services']['auth'] !== 'enabled')
 	output(403, _('Registrations are currently closed on this installation.'));

+ 1 - 1
pg-act/auth/unregister.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 if (checkPassword($_SESSION['id'], $_POST['current-password']) !== true)
 	output(403, _('Wrong current password.'));

+ 1 - 1
pg-act/auth/username.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 checkUsernameFormat($_POST['new-username']);
 

+ 1 - 1
pg-act/ht/add-dns.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 $_POST['domain'] = formatDomain($_POST['domain']);
 

+ 1 - 1
pg-act/ht/add-onion.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 if (dirsStatuses('onion')[$_POST['dir']] !== false)
 	output(403, 'Wrong value for <code>dir</code>.');

+ 1 - 1
pg-act/ht/add-subdomain.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 if (dirsStatuses('subdomain')[$_POST['dir']] !== false)
 	output(403, 'Wrong value for <code>dir</code>.');

+ 1 - 1
pg-act/ht/add-subpath.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 if (dirsStatuses('subpath')[$_POST['dir']] !== false)
 	output(403, 'Wrong value for <code>dir</code>.');

+ 1 - 1
pg-act/ht/del.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 if (preg_match('/^(?<type>subpath|subdomain|onion|dns):(?<address>[a-z0-9._-]{1,256})$/D', $_POST['site'], $site) !== 1)
 	output(403, 'Malformed value for <code>site</code>.');

+ 1 - 1
pg-act/ht/keys.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 $el_nb = count($_POST['keys']);
 if ($el_nb < 1 OR $el_nb > 8)

+ 1 - 1
pg-act/ns/caa.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 $values = nsParseCommonRequirements();
 

+ 1 - 1
pg-act/ns/cname.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 $values = nsParseCommonRequirements();
 

+ 1 - 1
pg-act/ns/dname.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 $values = nsParseCommonRequirements();
 

+ 34 - 33
pg-act/ns/edit.php

@@ -1,60 +1,62 @@
-<?php
+<?php declare(strict_types=1);
 
-nsCheckZonePossession($_POST['zone']);
+nsCheckZonePossession($_POST['domain']);
 
-if (isset($_POST['zone-content'])) { // Update zone
+$path = CONF['ns']['knot_zones_path'] . '/' . $_POST['domain'] . 'zone';
+
+if (isset($_POST['records'])) {
 
 	// Get current SOA record
-	$current_zone_content = file_get_contents(CONF['ns']['knot_zones_path'] . '/' . $_POST['zone'] . 'zone');
+	$current_zone_content = file_get_contents($path);
 	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 ]+.+)$/Dm', $current_zone_content, $matches) !== 1)
+	if (preg_match('/^(?<soa>' . preg_quote($_POST['domain'], '/') . '[\t ]+[0-9]{1,16}[\t ]+SOA[\t ]+.+)$/Dm', $current_zone_content, $matches) !== 1)
 		output(500, 'Unable to get current SOA record from zone file.');
 
-	// Generate new zone content
+	// Generate new content
 	$new_zone_content = $matches['soa'] . LF;
-	if (strlen($_POST['zone-content']) > ZONE_MAX_CHARACTERS)
-		output(403, sprintf(_('The zone is limited to %s characters.'), ZONE_MAX_CHARACTERS));
-	foreach (explode("\r\n", $_POST['zone-content']) as $line) {
-		if ($line === '') continue;
-		if (preg_match('/^(?<domain>[a-z0-9@._-]{1,256})(?:[\t ]+(?<ttl>[0-9]{1,16}))?(?:[\t ]+IN)?[\t ]+(?<type>[A-Z]{1,16})[\t ]+(?<value>.+)$/D', $line, $matches) !== 1)
-			output(403, _('The following line does not match the expected format: ') . '<code>' . htmlspecialchars($line) . '</code>');
-		if (in_array($matches['type'], ALLOWED_TYPES, true) !== true)
+	if (strlen($_POST['records']) > NS_TEXTAREA_MAX_CHARACTERS)
+		output(403, sprintf(_('The zone is limited to %s characters.'), NS_TEXTAREA_MAX_CHARACTERS));
+	foreach (explode("\r\n", $_POST['records']) as $record) {
+		if ($record === '') continue;
+		if (preg_match('/^(?<domain>[a-z0-9@._-]{1,256})(?:[\t ]+(?<ttl>[0-9]{1,16}))?(?:[\t ]+IN)?[\t ]+(?<type>[A-Z]{1,16})[\t ]+(?<value>.+)$/D', $record, $matches) !== 1)
+			output(403, _('The following line does not match the expected format: ') . '<code>' . htmlspecialchars($record) . '</code>');
+		if (in_array($matches['type'], NS_ALLOWED_TYPES, true) !== true)
 			output(403, sprintf(_('The %s type is not allowed.'), '<code>' . $matches['type'] . '</code>'));
-		if ($matches['ttl'] !== '' AND $matches['ttl'] < MIN_TTL)
-			output(403, sprintf(_('TTLs shorter than %s seconds are forbidden.'), MIN_TTL));
-		if ($matches['ttl'] !== '' AND $matches['ttl'] > MAX_TTL)
-			output(403, sprintf(_('TTLs longer than %s seconds are forbidden.'), MAX_TTL));
-		$new_zone_content .= $matches['domain'] . ' ' . (($matches['ttl'] === '') ? DEFAULT_TTL : $matches['ttl']) . ' ' . $matches['type'] . ' ' . $matches['value'] . LF;
+		if ($matches['ttl'] !== '' AND $matches['ttl'] < NS_MIN_TTL)
+			output(403, sprintf(_('TTLs shorter than %s seconds are forbidden.'), NS_MIN_TTL));
+		if ($matches['ttl'] !== '' AND $matches['ttl'] > NS_MAX_TTL)
+			output(403, sprintf(_('TTLs longer than %s seconds are forbidden.'), NS_MAX_TTL));
+		$new_zone_content .= $matches['domain'] . ' ' . (($matches['ttl'] === '') ? NS_DEFAULT_TTL : $matches['ttl']) . ' ' . $matches['type'] . ' ' . $matches['value'] . LF;
 	}
 
 	// Send the zone content to kzonecheck's stdin
-	$process = proc_open(CONF['ns']['kzonecheck_path'] . ' --origin ' . $_POST['zone'] . ' --dnssec off -', [0 => ['pipe', 'r']], $pipes);
+	$process = proc_open(CONF['ns']['kzonecheck_path'] . ' --origin ' . escapeshellarg($_POST['domain']) . ' --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, _('Sent zone content is not correct (according to <code>kzonecheck</code>).'));
+		output(403, _('Sent content is not correct (according to <code>kzonecheck</code>).'));
 
 	ratelimit();
 
-	knotc(['zone-freeze', $_POST['zone']], $output, $return_code);
+	knotc(['zone-freeze', $_POST['domain']], $output, $return_code);
 	if ($return_code !== 0)
 		output(500, 'Failed to freeze zone file.', $output);
 
-	knotc(['zone-flush', $_POST['zone']], $output, $return_code);
+	knotc(['zone-flush', $_POST['domain']], $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)
+	if (file_put_contents($path, $new_zone_content) === false)
 		output(500, 'Failed to write zone file.');
 
-	knotc(['zone-reload', $_POST['zone']], $output, $return_code);
+	knotc(['zone-reload', $_POST['domain']], $output, $return_code);
 	if ($return_code !== 0)
 		output(500, 'Failed to reload zone file.', $output);
 
-	knotc(['zone-thaw', $_POST['zone']], $output, $return_code);
+	knotc(['zone-thaw', $_POST['domain']], $output, $return_code);
 	if ($return_code !== 0)
 		output(500, 'Failed to thaw zone file.', $output);
 
@@ -63,18 +65,17 @@ if (isset($_POST['zone-content'])) { // Update zone
 
 // Display zone
 
-$zone_content = file_get_contents(CONF['ns']['knot_zones_path'] . '/' . $_POST['zone'] . 'zone');
-if ($zone_content === false)
+if (($records = file_get_contents($path)) === false)
 	output(500, 'Unable to read zone file.');
 
-$data['zone_content'] = '';
-foreach (explode(LF, $zone_content) as $zone_line) {
+$data['records'] = '';
+foreach (explode(LF, $records) 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 ]+.+$/D', $zone_line, $matches)) {
-		if (in_array($matches['type'], ALLOWED_TYPES, true) !== true)
+	if (preg_match('/^(?:(?:[a-z0-9_-]{1,63}\.){1,127})?' . preg_quote($_POST['domain'], '/') . '[\t ]+[0-9]{1,8}[\t ]+(?<type>[A-Z]{1,16})[\t ]+.+$/D', $zone_line, $matches)) {
+		if (in_array($matches['type'], NS_ALLOWED_TYPES, true) !== true)
 			continue;
-		$data['zone_content'] .= $zone_line . LF;
+		$data['records'] .= $zone_line . LF;
 	}
 }
-$data['zone_content'] .= LF;
+$data['records'] .= LF;

+ 1 - 1
pg-act/ns/ip.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 $values = nsParseCommonRequirements();
 

+ 1 - 1
pg-act/ns/loc.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 $values = nsParseCommonRequirements();
 

+ 1 - 1
pg-act/ns/mx.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 $values = nsParseCommonRequirements();
 

+ 1 - 1
pg-act/ns/ns.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 $values = nsParseCommonRequirements();
 

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

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 nsCheckZonePossession($_POST['zone']);
 

+ 1 - 1
pg-act/ns/srv.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 $values = nsParseCommonRequirements();
 

+ 1 - 1
pg-act/ns/sshfp.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 $values = nsParseCommonRequirements();
 

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

@@ -0,0 +1,41 @@
+<?php declare(strict_types=1);
+
+$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']);
+
+$destinations = array_column($syncs, 'destination');
+if (count($destinations) !== count(array_unique($destinations)))
+	output(403, _('Multiple source domains can\'t be applied to the same target domain.'));
+
+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.'));

+ 1 - 1
pg-act/ns/tlsa.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 $values = nsParseCommonRequirements();
 

+ 1 - 1
pg-act/ns/txt.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 $values = nsParseCommonRequirements();
 

+ 8 - 7
pg-act/ns/zone-add.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 $_POST['domain'] = formatAbsoluteDomain($_POST['domain']);
 
@@ -41,18 +41,19 @@ insert('zones', [
 $knotZonePath = CONF['ns']['knot_zones_path'] . '/' . $_POST['domain'] . 'zone';
 $knotZone = implode(' ', [
 	$_POST['domain'],
-	SOA_VALUES['ttl'],
+	NS_SOA_VALUES['ttl'],
 	'SOA',
 	CONF['ns']['servers'][0],
-	SOA_VALUES['email'],
+	NS_SOA_VALUES['email'],
 	1,
-	SOA_VALUES['refresh'],
-	SOA_VALUES['retry'],
-	SOA_VALUES['expire'],
-	SOA_VALUES['negative'],
+	NS_SOA_VALUES['refresh'],
+	NS_SOA_VALUES['retry'],
+	NS_SOA_VALUES['expire'],
+	NS_SOA_VALUES['negative'],
 ]) . LF;
 foreach (CONF['ns']['servers'] as $server)
 	$knotZone .= $_POST['domain'] . ' 86400 NS ' . $server . LF;
+$knotZone .= $_POST['domain'] . ' 86400 CSYNC 0 1 NS' . LF;
 if (is_int(file_put_contents($knotZonePath, $knotZone)) !== true)
 	output(500, 'Failed to write new zone file.');
 if (chmod($knotZonePath, 0660) !== true)

+ 1 - 1
pg-act/ns/zone-del.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 nsCheckZonePossession($_POST['zone']);
 

+ 6 - 24
pg-act/reg/ds.php

@@ -1,30 +1,12 @@
-<?php
+<?php declare(strict_types=1);
 
-if (!in_array($_POST['algo'], ['8', '13', '14', '15', '16'], true))
-	output(403, 'Wrong value for <code>algo</code>.');
-
-$_POST['keytag'] = intval($_POST['keytag']);
-if ((preg_match('/^[0-9]{1,6}$/D', $_POST['keytag'])) !== 1 OR !($_POST['keytag'] >= 1) OR !($_POST['keytag'] <= 65535))
-	output(403, 'Wrong value for <code>keytag</code>.');
-
-if ($_POST['dt'] !== '2' AND $_POST['dt'] !== '4')
-	output(403, 'Wrong value for <code>dt</code>.');
-
-if (preg_match('/^(?:[0-9a-fA-F]{64}|[0-9a-fA-F]{96})$/D', $_POST['key']) !== 1)
-	output(403, 'Wrong value for <code>key</code>.');
-
-regCheckDomainPossession($_POST['zone']);
+regCheckDomainPossession($_POST['domain']);
 
 rateLimit();
 
-knotcZoneExec(regParseDomain($_POST['zone'])['suffix'], [
-	$_POST['zone'],
-	CONF['reg']['ttl'],
-	'DS',
-	$_POST['keytag'],
-	$_POST['algo'],
-	$_POST['dt'],
-	$_POST['key']
-]);
+knotcZoneExec(regParseDomain($_POST['domain'])['suffix'], regParseRecord($_POST['domain'], [
+	'type' => 'DS',
+	...$_POST,
+]));
 
 output(200, _('Modification done.'));

+ 97 - 0
pg-act/reg/edit.php

@@ -0,0 +1,97 @@
+<?php declare(strict_types=1);
+
+regCheckDomainPossession($_POST['domain']);
+
+$suffix = regParseDomain($_POST['domain'])['suffix'];
+
+$path = CONF['reg']['suffixes_path'] . '/' . $suffix . 'zone';
+
+if (isset($_POST['records'])) {
+
+	// Generate new content
+	$new_records = '';
+	if (strlen($_POST['records']) > REG_TEXTAREA_MAX_CHARACTERS)
+		output(403, sprintf(_('The zone is limited to %s characters.'), REG_TEXTAREA_MAX_CHARACTERS));
+	foreach (explode("\r\n", $_POST['records']) as $record) {
+		if ($record === '') continue;
+		if (preg_match('/^(?<domain>[a-z0-9@._-]{1,256})(?:[\t ]+(?<ttl>[0-9]{1,16}))?(?:[\t ]+IN)?[\t ]+(?<type>[A-Z]{1,16})[\t ]+(?<value>.+)$/D', $record, $matches) !== 1)
+			output(403, _('The following line does not match the expected format: ') . '<code>' . htmlspecialchars($record) . '</code>');
+		if (in_array($matches['type'], REG_ALLOWED_TYPES, true) !== true)
+			output(403, sprintf(_('The %s type is not allowed.'), '<code>' . $matches['type'] . '</code>'));
+		if ($matches['type'] === 'DS' AND count($record_values = explode(' ', $matches['value'])) !== 4)
+			output(403, _('A DS record expects 4 arguments.'));
+		$new_records .= implode(' ', regParseRecord($_POST['domain'], [
+			'domain' => $matches['domain'],
+			'type' => match ($matches['type']) {
+				'NS', 'DS' => $matches['type'],
+				'AAAA', 'A' => 'ip',
+				default => output(403, sprintf(_('The %s type is not allowed.'), '<code>' . $matches['type'] . '</code>')),
+			},
+			...match ($matches['type']) {
+				'NS' => ['ns' => $matches['value']],
+				'AAAA', 'A' => ['ip' => $matches['value']],
+				'DS' => array_combine([
+					'keytag',
+					'algo',
+					'dt',
+					'key',
+				], $record_values),
+			},
+		])) . LF;
+	}
+
+	// Send the zone content to kzonecheck's stdin
+	$process = proc_open(CONF['ns']['kzonecheck_path'] . ' --origin ' . escapeshellarg($suffix) . ' --dnssec off -', [0 => ['pipe', 'r']], $pipes);
+	if (is_resource($process) !== true)
+		output(500, 'Can\'t spawn kzonecheck.');
+	$new = $suffix . ' 10800 SOA invalid. invalid. 0 21600 7200 3628800 3600' . LF . $suffix . ' 10800 NS invalid.' . LF . $new_records;
+	fwrite($pipes[0], $new);
+	fclose($pipes[0]);
+	if (proc_close($process) !== 0)
+		output(403, _('Sent content is not correct (according to <code>kzonecheck</code>).'));
+
+	ratelimit();
+
+	knotc(['zone-freeze', $suffix], $output, $return_code);
+	if ($return_code !== 0)
+		output(500, 'Failed to freeze zone file.', $output);
+
+	knotc(['zone-flush', $suffix], $output, $return_code);
+	if ($return_code !== 0)
+		output(500, 'Failed to flush zone file.', $output);
+
+	if (($zone_content = file_get_contents($path)) === false)
+		output(500, 'Unable to read zone file.');
+
+	$zone_content = regStripDomain($_POST['domain'], $zone_content);
+
+	if (file_put_contents($path, $zone_content . LF . $new_records) === false)
+		output(500, 'Failed to write zone file.');
+
+	knotc(['zone-reload', $suffix], $output, $return_code);
+	if ($return_code !== 0)
+		output(500, 'Failed to reload zone file.', $output);
+
+	knotc(['zone-thaw', $suffix], $output, $return_code);
+	if ($return_code !== 0)
+		output(500, 'Failed to thaw zone file.', $output);
+
+	usleep(1000000);
+}
+
+// Display zone
+
+if (($records = file_get_contents($path)) === false)
+	output(500, 'Unable to read zone file.');
+
+$data['records'] = '';
+foreach (explode(LF, $records) 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['domain'], '/') . '[\t ]+[0-9]{1,8}[\t ]+(?<type>[A-Z]{1,16})[\t ]+.+$/D', $zone_line, $matches)) {
+		if (in_array($matches['type'], REG_ALLOWED_TYPES, true) !== true)
+			continue;
+		$data['records'] .= $zone_line . LF;
+	}
+}
+$data['records'] .= LF;

+ 7 - 8
pg-act/reg/glue.php

@@ -1,14 +1,13 @@
-<?php
+<?php declare(strict_types=1);
 
-regCheckDomainPossession($_POST['suffix']);
+regCheckDomainPossession($_POST['domain']);
 
 rateLimit();
 
-knotcZoneExec(regParseDomain($_POST['suffix'])['suffix'], [
-	formatAbsoluteDomain(formatEndWithDot($_POST['subdomain']) . $_POST['suffix']),
-	CONF['reg']['ttl'],
-	checkIpFormat($_POST['ip']),
-	$_POST['ip']
-]);
+knotcZoneExec(regParseDomain($_POST['domain'])['suffix'], regParseRecord($_POST['domain'], [
+	'type' => 'ip',
+	'domain' => formatAbsoluteDomain(formatEndWithDot($_POST['subdomain']) . $_POST['domain']),
+	...$_POST,
+]));
 
 output(200, _('Modification done.'));

+ 5 - 7
pg-act/reg/ns.php

@@ -1,14 +1,12 @@
-<?php
+<?php declare(strict_types=1);
 
 regCheckDomainPossession($_POST['domain']);
 
 rateLimit();
 
-knotcZoneExec(regParseDomain($_POST['domain'])['suffix'], [
-	$_POST['domain'],
-	CONF['reg']['ttl'],
-	'NS',
-	formatAbsoluteDomain($_POST['ns'])
-]);
+knotcZoneExec(regParseDomain($_POST['domain'])['suffix'], regParseRecord($_POST['domain'], [
+	'type' => 'NS',
+	...$_POST,
+]));
 
 output(200, _('Modification done.'));

+ 1 - 1
pg-act/reg/print.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 regCheckDomainPossession($_POST['domain']);
 

+ 2 - 2
pg-act/reg/register.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 if (preg_match('/' . SUBDOMAIN_REGEX . '/D', $_POST['subdomain']) !== 1)
 	output(403, _('This format of subdomain is not allowed.'));
@@ -53,7 +53,7 @@ if ($blocked OR $registration_data !== [])
 if ($_POST['action'] !== 'register')
 	message($message . ' ✔️ ' . _('This domain is open to registration!'));
 
-function message($message) {
+function message(string $message): never {
 	output(200, data: [
 		'message' => '<p>' . $message . '</p>',
 		'domain' => htmlspecialchars($_POST['subdomain']),

+ 1 - 1
pg-act/reg/transfer.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 if (preg_match('/' . SUBDOMAIN_REGEX . '/D', $_POST['subdomain']) !== 1)
 	output(403, _('This format of subdomain is not allowed.'));

+ 1 - 1
pg-act/reg/unregister.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 regCheckDomainPossession($_POST['domain']);
 

+ 1 - 0
pg-view/auth/approval.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <p>
 	<?= _('This form allows to use an approval key to validate your account. Approval keys are distributed by an administrator upon request.') ?>
 </p>

+ 1 - 0
pg-view/auth/index.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <?php displayIndex(); ?>
 
 <h2 id="type"><?= _('Account type') ?></h2>

+ 1 - 0
pg-view/auth/login.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <p><?= _('New?') ?> <a href="register"><?= _('Create an account') ?></a></p>
 
 <form method="post">

+ 1 - 1
pg-view/auth/logout.php

@@ -1,3 +1,3 @@
-<?php
+<?php declare(strict_types=1);
 
 logout();

+ 1 - 0
pg-view/auth/password.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 	<label for="current-password"><?= _('Current password') ?></label><br>
 	<input required="" autocomplete="current-password" minlength="8" maxlength="1024" pattern="<?= PASSWORD_REGEX ?>" id="current-password" name="current-password" type="password" placeholder="<?= PLACEHOLDER_PASSWORD ?>"><br>

+ 1 - 0
pg-view/auth/register.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <p><?= _('Already have an account?') ?> <a href="login"><?= _('Log in') ?></a></p>
 
 <?= (CONF['common']['services']['auth'] !== 'enabled') ? '<p><strong>' . _('Registrations are currently closed on this installation.') . '</strong></p>' : '' ?>

+ 1 - 0
pg-view/auth/unregister.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <p>
 	<?= _('This will delete every resource managed by the current account, including registered domains, hosted DNS records, websites files and cryptographic keys for Onion services and DNSSEC.') ?>
 </p>

+ 1 - 0
pg-view/auth/username.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 	<label for="current-password"><?= _('Current password') ?></label><br>
 	<input required="" autocomplete="current-password" minlength="8" maxlength="1024" pattern="<?= PASSWORD_REGEX ?>" id="current-password" name="current-password" type="password" placeholder="<?= PLACEHOLDER_PASSWORD ?>"><br>

+ 1 - 0
pg-view/ht/add-dns.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <p>
 	<?= _('A Let\'s Encrypt certificate will be obtained.') ?>
 </p>

+ 1 - 0
pg-view/ht/add-onion.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 	<label for="dir"><?= _('Target directory') ?></label><br>
 	<select required="" name="dir" id="dir">

+ 1 - 0
pg-view/ht/add-subdomain.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <p>
 	<?= sprintf(_('The subdomain can only contain %1$s, %2$s and %3$s, and must be between 4 and 63 characters. It can\'t have an hyphen (%3$s) in first, last or both third and fourth position.'), '<abbr title="abcdefghijklmnopqrstuvwxyz"><code>a</code>-<code>z</code></abbr>',  '<abbr title="0123456789"><code>0</code>-<code>9</code></abbr>', '<code>-</code>') ?>
 </p>

+ 1 - 0
pg-view/ht/add-subpath.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <p>
 	<?= sprintf(_('The path can only contain %1$s, %2$s and %3$s, and must be between 4 and 63 characters.'), '<abbr title="abcdefghijklmnopqrstuvwxyz"><code>a</code>-<code>z</code></abbr>',  '<abbr title="0123456789"><code>0</code>-<code>9</code></abbr>', '<code>-</code>') ?>
 </p>

+ 2 - 1
pg-view/ht/del.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 	<label for="site"><?= _('Access to delete') ?></label><br>
 	<select required="" name="site" id="site">
@@ -10,7 +11,7 @@ foreach (query('select', 'sites', ['username' => $_SESSION['id'] ?? '']) as $sit
 		'onion' => 'http://' . $site['address'] . '/',
 		'dns' => 'https://' . $site['address'] . '/',
 	};
-	echo '		<option value="' . $site['type'] . ':' . $site['address'] . '">' . $url . ' vers /' . $site['site_dir'] . '</option>' . LF;
+	echo '		<option value="' . $site['type'] . ':' . $site['address'] . '">' . ' ' . sprintf(_('%1$s to %2$s'), $url, '/' . $site['site_dir']) . '</option>' . LF;
 }
 ?>
 	</select>

+ 1 - 0
pg-view/ht/index.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <p>
 	<?= _('This service allows you to send files on the server using SFTP, and to make them publicly available with HTTP.') ?>
 </p>

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

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <p>
 	<?= _('In addition to your password, you can also access your SFTP space using Ed25519 SSH keys. A key can be granted modification rights to the full space (<code>/</code>) or to any arbitrary subdirectory. A key is always  allowed to list any directory content.') ?>
 </p>
@@ -16,12 +17,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 - 0
pg-view/index.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <nav>
 	<p>
 		<span aria-hidden="true">➡️ </span><em><a href="<?= CONF['common']['about_url'] ?>"><?= _('About this installation') ?></a></em>

+ 1 - 0
pg-view/ns/caa.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 <?php require ROOT_PATH . '/pg-view/ns/form.ns.php'; ?>
 	<label for="flag"><?= _('Flag') ?></label>

+ 1 - 0
pg-view/ns/cname.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 <?php require ROOT_PATH . '/pg-view/ns/form.ns.php'; ?>
 	<label for="cname"><?= _('Canonical name') ?></label>

+ 1 - 0
pg-view/ns/dname.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 <?php require ROOT_PATH . '/pg-view/ns/form.ns.php'; ?>
 	<label for="dname"><?= _('Delegation name') ?></label>

+ 14 - 13
pg-view/ns/edit.php

@@ -1,11 +1,12 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
-	<label for="zone"><?= _('Zone to be changed') ?></label>
+	<label for="domain"><?= _('Zone to be changed') ?></label>
 	<br>
-	<select required="" name="zone" id="zone">
+	<select required="" name="domain" id="domain">
 		<option value="" disabled="" selected="">-</option>
 <?php
-foreach (nsListUserZones() as $zone)
-	echo '		<option value="' . $zone . '">' . $zone . '</option>' . LF;
+foreach (nsListUserZones() as $domain)
+	echo '		<option value="' . $domain . '">' . $domain . '</option>' . LF;
 ?>
 	</select>
 	<br>
@@ -14,15 +15,15 @@ foreach (nsListUserZones() as $zone)
 
 <?php
 
-if (isset($data['zone_content'])) { // Display zone
+if (isset($data['records'])) { // Display zone
 
 ?>
 <form method="post">
-	<input type="hidden" name="zone" value="<?= $_POST['zone'] ?>">
+	<input type="hidden" name="domain" value="<?= $_POST['domain'] ?>">
 
-	<label for="zone-content"><?= sprintf(_('New content of the %s zone'), '<code><strong>' . $_POST['zone'] . '</strong></code>') ?></label>
+	<label for="zone-content"><?= sprintf(_('Authoritative records for %s'), '<code><strong>' . $_POST['domain'] . '</strong></code>') ?></label>
 	<br>
-	<textarea id="zone-content" name="zone-content" wrap="off" rows="<?= substr_count($data['zone_content'], LF) + 1 ?>"><?= htmlspecialchars($data['zone_content']) ?></textarea>
+	<textarea id="records" name="records" wrap="off" rows="<?= substr_count($data['records'], LF) + 1 ?>"><?= htmlspecialchars($data['records']) ?></textarea>
 	<br>
 	<input type="submit" value="<?= _('Replace') ?>">
 </form>
@@ -37,21 +38,21 @@ displayFinalMessage($data);
 
 <h2><?= _('Default values') ?></h2>
 
-<p><?= sprintf(_('If the TTL is omitted, it will default to %s seconds.'), '<code><time datetime="PT' . DEFAULT_TTL . 'S">' . DEFAULT_TTL . '</time></code>') ?></p>
+<p><?= sprintf(_('If the TTL is omitted, it will default to %s seconds.'), '<code><time datetime="PT' . NS_DEFAULT_TTL . 'S">' . NS_DEFAULT_TTL . '</time></code>') ?></p>
 
 <p><?= _('Precising the class (<code>IN</code>) is optional.') ?></p>
 
 <h2><?= _('Allowed values') ?></h2>
 
-<p><?= sprintf(_('Submitted zone content is limited to %s characters.'), ZONE_MAX_CHARACTERS) ?></p>
+<p><?= sprintf(_('Submitted field content is limited to %s characters.'), NS_TEXTAREA_MAX_CHARACTERS) ?></p>
 
-<p><?= sprintf(_('TTLs must last between %1$s and %2$s seconds.'), '<code><time datetime="PT' . MIN_TTL . 'S">' . MIN_TTL . '</time></code>', '<code><time datetime="PT' . MAX_TTL . 'S">' . MAX_TTL . '</time></code>') ?></p>
+<p><?= sprintf(_('TTLs must last between %1$s and %2$s seconds.'), '<code><time datetime="PT' . NS_MIN_TTL . 'S">' . NS_MIN_TTL . '</time></code>', '<code><time datetime="PT' . NS_MAX_TTL . 'S">' . NS_MAX_TTL . '</time></code>') ?></p>
 
-<p><?= _('The only types that can be defined are:') ?></p>
+<p><?= _('The only types that can be defined here are:') ?></p>
 
 <ul>
 <?php
-	foreach (ALLOWED_TYPES as $allowed_type)
+	foreach (NS_ALLOWED_TYPES as $allowed_type)
 		echo '	<li><code>' . $allowed_type . '</code></li>';
 ?>
 </ul>

+ 14 - 12
pg-view/ns/form.ns.php

@@ -1,7 +1,8 @@
+<?php declare(strict_types=1); ?>
 	<label for="action"><?= _('Action') ?></label>
 	<select name="action" id="action">
-		<option value="add"><?= _('Add') ?></option>
-		<option value="delete"><?= _('Delete') ?></option>
+		<option value="add"<?= ($_POST['action'] ?? NULL) === 'add' ? ' selected=""' : '' ?>><?= _('Add') ?></option>
+		<option value="delete"<?= ($_POST['action'] ?? NULL) === 'delete' ? ' selected=""' : '' ?>><?= _('Delete') ?></option>
 	</select>
 
 	<fieldset>
@@ -9,18 +10,19 @@
 		<div>
 			<label for="subdomain"><?= _('Subdomain') ?></label>
 			<br>
-			<input id="subdomain" size="16" placeholder="www" pattern="^(([a-z0-9_-]{1,63}\.?){1,127})|(@){1}$" name="subdomain" type="text">
+			<input id="subdomain" size="16" placeholder="www" pattern="^(([a-z0-9_-]{1,63}\.?){1,127})|(@){1}$" name="subdomain" type="text" value="<?= htmlspecialchars($_POST['subdomain'] ?? '') ?>">
 		</div>
 		<div>
 			<label for="zone"><?= _('Zone') ?></label>
 			<br>
 			<select required="" name="zone" id="zone">
-				<option value="" disabled="" selected="">-</option>
 <?php
-foreach (nsListUserZones() as $zone)
-	echo "<option value='" . $zone . "'>" . $zone . "</option>";
+$user_zones = nsListUserZones();
+if (!in_array($_POST['zone'] ?? NULL, $user_zones, true))
+	echo '				<option value="" disabled="" selected="">—</option>' . LF;
+foreach ($user_zones as $zone)
+	echo '				<option value="' . $zone . '"' . (($_POST['zone'] ?? NULL) === $zone ? ' selected=""' : '') . '>.' . $zone . '</option>' . LF;
 ?>
-
 			</select>
 		</div>
 	</fieldset>
@@ -30,7 +32,7 @@ foreach (nsListUserZones() as $zone)
 		<div>
 			<label for="ttl-value"><?= _('Value') ?></label>
 			<br>
-			<input required="" id="ttl-value" list="ttls" name="ttl-value" size="6" type="number" min="1" max="432000" value="<?= DEFAULT_TTL ?>" placeholder="<?= DEFAULT_TTL ?>">
+			<input required="" id="ttl-value" list="ttls" name="ttl-value" size="6" type="number" min="1" max="432000" value="<?= $_POST['ttl-value'] ?? NS_DEFAULT_TTL ?>" placeholder="<?= NS_DEFAULT_TTL ?>">
 			<datalist id="ttls">
 				<option value="900">
 				<option value="1800">
@@ -45,10 +47,10 @@ foreach (nsListUserZones() as $zone)
 			<label for="ttl-multiplier"><?= _('Unit') ?></label>
 			<br>
 			<select required="" name="ttl-multiplier" id="ttl-multiplier">
-				<option value="1"><?= _('second') ?></option>
-				<option value="60"><?= _('minute') ?></option>
-				<option value="3600"><?= _('hour') ?></option>
-				<option value="86400"><?= _('day') ?></option>
+				<option value="1"<?= ($_POST['ttl-multiplier'] ?? NULL) === '1' ? ' selected=""' : '' ?>><?= _('second') ?></option>
+				<option value="60"<?= ($_POST['ttl-multiplier'] ?? NULL) === '60' ? ' selected=""' : '' ?>><?= _('minute') ?></option>
+				<option value="3600"<?= ($_POST['ttl-multiplier'] ?? NULL) === '3600' ? ' selected=""' : '' ?>><?= _('hour') ?></option>
+				<option value="86400"<?= ($_POST['ttl-multiplier'] ?? NULL) === '86400' ? ' selected=""' : '' ?>><?= _('day') ?></option>
 			</select>
 		</div>
 	</fieldset>

+ 1 - 0
pg-view/ns/index.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <p>
 	<?= _('This service allows to host and manage DNS records inside a DNS zone.') ?>
 </p>

+ 1 - 0
pg-view/ns/ip.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 <?php require ROOT_PATH . '/pg-view/ns/form.ns.php'; ?>
 	<label for="ip"><?= _('IP address') ?></label><br>

+ 1 - 0
pg-view/ns/loc.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 <?php require ROOT_PATH . '/pg-view/ns/form.ns.php'; ?>
 	<fieldset>

+ 1 - 0
pg-view/ns/mx.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 <?php require ROOT_PATH . '/pg-view/ns/form.ns.php'; ?>
 	<label for="priority"><?= _('Priority') ?></label>

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

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 <?php require ROOT_PATH . '/pg-view/ns/form.ns.php'; ?>
 	<label for="ns"><?= _('Name server') ?></label>

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

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 	<input type="radio" name="print" id="table" value="table" checked="">
 	<label for="table"><?= _('Records table') ?></label>
@@ -38,7 +39,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;
 	}
 }

+ 1 - 0
pg-view/ns/srv.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 <?php require ROOT_PATH . '/pg-view/ns/form.ns.php'; ?>
 

+ 1 - 0
pg-view/ns/sshfp.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 <?php require ROOT_PATH . '/pg-view/ns/form.ns.php'; ?>
 

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

@@ -0,0 +1,39 @@
+<?php declare(strict_types=1); ?>
+<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 CNAME records are 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 domain') ?></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 (array_diff(nsListUserZones(), query('select', 'ns-syncs', ['username' => $_SESSION['id'] ?? ''], 'destination')) as $zone)
+	echo "<option value='" . $zone . "'>" . $zone . "</option>";
+?>
+
+			</select>
+		</div>
+	</fieldset>
+<?php
+}
+?>
+	<input type="submit" value="<?= _('Update') ?>">
+</form>

+ 1 - 0
pg-view/ns/tlsa.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 <?php require ROOT_PATH . '/pg-view/ns/form.ns.php'; ?>
 

+ 1 - 0
pg-view/ns/txt.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 <?php require ROOT_PATH . '/pg-view/ns/form.ns.php'; ?>
 	<label for="txt"><?= _('Text') ?></label>

+ 1 - 0
pg-view/ns/zone-add.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <p>
 	<?= sprintf(_('To prove that you own this domain, it must have a NS record equal to %s when the form is being processed.'), '<code>' . getAuthToken() . '._domain-verification.' . SERVER_NAME . '.</code>') ?>
 </p>

+ 1 - 0
pg-view/ns/zone-del.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
 	<label for="zone"><?= _('Zone') ?></label>
 	<select required="" name="zone" id="zone">

+ 3 - 14
pg-view/reg/ds.php

@@ -1,19 +1,8 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
-	<label for="action"><?= _('Action') ?></label>
-	<select name="action" id="action">
-		<option value="add"><?= _('Add') ?></option>
-		<option value="delete"><?= _('Delete') ?></option>
-	</select>
-	<br>
-	<label for="zone"><?= _('Domain') ?></label>
+<?php require ROOT_PATH . '/pg-view/reg/select-action.inc.php'; ?>
 	<br>
-	<select required="" name="zone" id="zone">
-		<option value="" disabled="" selected="">—</option>
-<?php
-foreach (regListUserDomains() as $domain)
-	echo '		<option value="' . $domain . '">' . $domain . '</option>' . LF;
-?>
-	</select>
+<?php require ROOT_PATH . '/pg-view/reg/select-domain.inc.php'; ?>
 	<br>
 	<label for="keytag"><?= _('Key tag') ?></label>
 	<br>

+ 54 - 0
pg-view/reg/edit.php

@@ -0,0 +1,54 @@
+<?php declare(strict_types=1); ?>
+<form method="post">
+	<label for="domain"><?= _('Domain to be changed') ?></label>
+	<br>
+	<select required="" name="domain" id="domain">
+		<option value="" disabled="" selected="">-</option>
+<?php
+foreach (regListUserDomains() as $domain)
+	echo '		<option value="' . $domain . '">' . $domain . '</option>' . LF;
+?>
+	</select>
+	<br>
+	<input type="submit" value="<?= _('Display') ?>">
+</form>
+
+<?php
+
+if (isset($data['records'])) { // Display zone
+
+?>
+<form method="post">
+	<input type="hidden" name="domain" value="<?= $_POST['domain'] ?>">
+
+	<label for="records"><?= sprintf(_('Delegation records for %s'), '<code><strong>' . $_POST['domain'] . '</strong></code>') ?></label>
+	<br>
+	<textarea id="records" name="records" wrap="off" rows="<?= substr_count($data['records'], LF) + 1 ?>"><?= htmlspecialchars($data['records']) ?></textarea>
+	<br>
+	<input type="submit" value="<?= _('Replace') ?>">
+</form>
+
+<?php
+
+}
+
+displayFinalMessage($data);
+
+?>
+
+<h2><?= _('Input values') ?></h2>
+
+<p><?= _('Precising the class (<code>IN</code>) is optional.') ?></p>
+
+<p><?= sprintf(_('Submitted field content is limited to %s characters.'), REG_TEXTAREA_MAX_CHARACTERS) ?></p>
+
+<p><?= sprintf(_('TTL values are ignored and always set to %s seconds.'), '<code><time datetime="PT' . CONF['reg']['ttl'] . 'S">' . CONF['reg']['ttl'] . '</time></code>') ?></p>
+
+<p><?= _('The only types that can be defined here are:') ?></p>
+
+<ul>
+<?php
+	foreach (REG_ALLOWED_TYPES as $allowed_type)
+		echo '	<li><code>' . $allowed_type . '</code></li>';
+?>
+</ul>

+ 3 - 14
pg-view/reg/glue.php

@@ -1,9 +1,6 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
-	<label for="action"><?= _('Action') ?></label>
-	<select name="action" id="action">
-		<option value="add"><?= _('Add') ?></option>
-		<option value="delete"><?= _('Delete') ?></option>
-	</select>
+<?php require ROOT_PATH . '/pg-view/reg/select-action.inc.php'; ?>
 	<fieldset>
 		<legend><?= _('Domain') ?></legend>
 		<div>
@@ -12,15 +9,7 @@
 			<input required="" id="subdomain" placeholder="ns1" name="subdomain" type="text">
 		</div>
 		<div>
-			<label for="suffix"><?= _('Domain') ?></label>
-			<br>
-			<select required="" name="suffix" id="suffix">
-				<option value="" disabled="" selected="">—</option>
-<?php
-foreach (regListUserDomains() as $suffix)
-	echo '		<option value="' . $suffix . '">' . $suffix . '</option>' . LF;
-?>
-			</select>
+<?php require ROOT_PATH . '/pg-view/reg/select-domain.inc.php'; ?>
 		</div>
 	</fieldset>
 	<label for="ip"><?= _('IP address') ?></label><br>

+ 22 - 3
pg-view/reg/index.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <p>
 	<?= sprintf(_('This domain name registry allows to register domains ending with <code>%1$s</code>, for instance <code><em>domain</em>%1$s</code>.'), '.' . key(CONF['reg']['suffixes'])) ?>
 </p>
@@ -30,7 +31,9 @@ else {
 <?php
 foreach (CONF['reg']['suffixes'] as $suffix => $condition)
 	if ($condition === 'all')
-		echo '<li><code>' . $suffix . ' </code></li>';
+		echo '<li><code>' . $suffix . ' </code></li>' . LF;
+if (!in_array('all', CONF['reg']['suffixes'], true))
+	echo '<li>∅</li>' . LF;
 ?>
 			</ul>
 		</dd>
@@ -41,7 +44,9 @@ foreach (CONF['reg']['suffixes'] as $suffix => $condition)
 <?php
 foreach (CONF['reg']['suffixes'] as $suffix => $condition)
 	if ($condition === 'approved')
-		echo '<li><code>' . $suffix . ' </code></li>';
+		echo '<li><code>' . $suffix . ' </code></li>' . LF;
+if (!in_array('approved', CONF['reg']['suffixes'], true))
+	echo '<li>∅</li>' . LF;
 ?>
 			</ul>
 		</dd>
@@ -52,9 +57,23 @@ foreach (CONF['reg']['suffixes'] as $suffix => $condition)
 <?php
 foreach (CONF['reg']['suffixes'] as $suffix => $condition)
 	if ($condition === 'none')
-		echo '<li><code>' . $suffix . ' </code></li>';
+		echo '<li><code>' . $suffix . ' </code></li>' . LF;
+if (!in_array('none', CONF['reg']['suffixes'], true))
+	echo '<li>∅</li>' . LF;
 ?>
 			</ul>
 		</dd>
 	</dl>
 </section>
+
+<section>
+	<h2><?= _('Automatic updates from child zone') ?></h2>
+	<h3><?= _('CSYNC records') ?></h3>
+	<p>
+		<?= _('The registry can synchronize NS records from the child zone if a CSYNC record is present at the apex of the child zone, has flags <code>1</code> and type bit map <code>NS</code>, and can be DNSSEC-validated. Others values are not supported.') ?>
+	</p>
+	<h3><?= _('DNSSEC and DS records') ?></h3>
+	<p>
+		<?= _('Once DNSSEC has been manually enabled through the current interface, the delegated zone can publish a CDS record in order to update the DS record in the registry. A single CDS record with value <code>0 0 0 0</code> tells the registry to disable DNSSEC. Using a CDS record to enable DNSSEC is not supported.') ?>
+	</p>
+</section>

+ 3 - 14
pg-view/reg/ns.php

@@ -1,19 +1,8 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
-	<label for="action"><?= _('Action') ?></label>
-	<select name="action" id="action">
-		<option value="add"><?= _('Add') ?></option>
-		<option value="delete"><?= _('Delete') ?></option>
-	</select>
+<?php require ROOT_PATH . '/pg-view/reg/select-action.inc.php'; ?>
 	<br>
-	<label for="domain"><?= _('Domain') ?></label>
-	<br>
-	<select required="" name="domain" id="domain">
-		<option value="" disabled="" selected="">—</option>
-<?php
-foreach (regListUserDomains() as $domain)
-	echo '		<option value="' . $domain . '">' . $domain . '</option>' . LF;
-?>
-	</select>
+<?php require ROOT_PATH . '/pg-view/reg/select-domain.inc.php'; ?>
 	<br>
 	<label for="ns"><?= _('Name server') ?></label>
 	<br>

+ 2 - 8
pg-view/reg/print.php

@@ -1,12 +1,6 @@
+<?php declare(strict_types=1); ?>
 <form method="post">
-	<label for="domain"><?= _('Domain') ?></label>
-	<select required="" name="domain" id="domain">
-		<option value="" disabled="" selected="">-</option>
-<?php
-foreach (regListUserDomains() as $domain)
-	echo '		<option value="' . $domain . '">' . $domain . '</option>' . LF;
-?>
-	</select>
+<?php require ROOT_PATH . '/pg-view/reg/select-domain.inc.php'; ?>
 	<br>
 	<input type="submit" value="<?= _('Display') ?>">
 </form>

+ 1 - 0
pg-view/reg/register.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <p>
 	<?= _('Register a new domain on your account.') ?>
 </p>

+ 6 - 0
pg-view/reg/select-action.inc.php

@@ -0,0 +1,6 @@
+<?php declare(strict_types=1); ?>
+	<label for="action"><?= _('Action') ?></label>
+	<select name="action" id="action">
+		<option value="add"<?= ($_POST['action'] ?? NULL) === 'add' ? ' selected=""' : '' ?>><?= _('Add') ?></option>
+		<option value="delete"<?= ($_POST['action'] ?? NULL) === 'delete' ? ' selected=""' : '' ?>><?= _('Delete') ?></option>
+	</select>

+ 12 - 0
pg-view/reg/select-domain.inc.php

@@ -0,0 +1,12 @@
+<?php declare(strict_types=1); ?>
+	<label for="domain"><?= _('Domain') ?></label>
+	<br>
+	<select required="" name="domain" id="domain">
+<?php
+$user_domains = regListUserDomains();
+if (!in_array($_POST['domain'] ?? NULL, $user_domains, true))
+	echo '		<option value="" disabled="" selected="">—</option>' . LF;
+foreach ($user_domains as $domain)
+	echo '		<option value="' . $domain . '"' . (($_POST['domain'] ?? NULL === $domain) ? ' selected=""' : '') . '>' . $domain . '</option>' . LF;
+?>
+	</select>

+ 1 - 0
pg-view/reg/transfer.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <p>
 	<?= sprintf(_('To prove that you are allowed to receive the domain by its current owner, the domain must have an NS record equal to %s when the form is being processed. The NS record will be automatically deleted once validated.'), '<code>' . getAuthToken() . '._transfer-verification.' . SERVER_NAME . '.</code>') ?>
 </p>

+ 1 - 0
pg-view/reg/unregister.php

@@ -1,3 +1,4 @@
+<?php declare(strict_types=1); ?>
 <p>
 	<?= _('This will unregister the domain, making it registerable by anyone again (after a delay of 1 year plus half the registration period, with a maximum of 8 years).') ?>
 </p>

+ 5 - 5
router.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 require 'init.php';
 
 $pageAddress = substr($_SERVER['REQUEST_URI'], strlen(CONF['common']['prefix']) + 1);
@@ -11,7 +11,7 @@ define('PAGE_ADDRESS', $pageAddress . ((substr($pageAddress, -1) === '/' OR $pag
 define('PAGE_LINEAGE', explode('/', PAGE_ADDRESS));
 define('SERVICE', dirname(PAGE_ADDRESS));
 
-function getPageInformations($pages, $pageElements) {
+function getPageInformations(array $pages, array $pageElements): array {
 	if (!isset($pages['index']) OR $pageElements[0] === 'index')
 		return [
 			'titles_lineage' => [$pages[$pageElements[0]]['title'] ?? false],
@@ -44,7 +44,7 @@ if (in_array($_SERVER['SERVER_NAME'], CONF['common']['public_domains'], true) !=
 define('SERVER_NAME', $_SERVER['SERVER_NAME']);
 
 const SESSION_COOKIE_NAME = 'servnest-session-key';
-function startSession() {
+function startSession(): void {
 	session_start([
 		'name' => SESSION_COOKIE_NAME,
 		'sid_length' => 64,
@@ -94,7 +94,7 @@ if (isset($_SESSION['id'])) {
 		}
 }
 
-function displayFinalMessage($data) {
+function displayFinalMessage(?array $data): void {
 	if (isset($data['final_message'])) {
 		echo $data['final_message'];
 		unset($data['final_message']);
@@ -118,7 +118,7 @@ if ($_POST !== []) {
 		require ROOT_PATH . '/pg-act/' . PAGE_ADDRESS . '.php';
 }
 
-function displayPage($data) {
+function displayPage(?array $data): never {
 	require ROOT_PATH . '/view.php';
 	exit();
 }

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác