compliance: Origin and NSOnlyOrigin validators
Some checks are pending
continuous-integration/drone/push Build is pending

Add sync validators for the zone apex services. Coverage:

- SOA structure: primary master and Mbox hostnames (no IP literals,
  FQDN form), Mbox MUST NOT contain '@' (RFC 1035 §3.3.13), serial is
  non-zero and warned about when 10 digits don't match YYYYMMDDNN
  (RFC 1912 §2.2 example), Refresh / Retry / Expire / Minttl ranges
  per RFC 1912 §2.2 and RFC 2308 §5, plus the Retry < Refresh and
  Expire >= Refresh + Retry cross-checks.
- NS list: at least 1 NS (error) and at least 2 (warning, RFC 2182
  §3.1), no IP literals, FQDN form, duplicate detection, and a
  warning when the SOA primary master is not also published as an NS
  (RFC 1912 §2.7).
- abstract.NSOnlyOrigin reuses the NS-list checks without SOA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-04-25 13:27:16 +07:00
commit 9e079f6c5c
5 changed files with 842 additions and 0 deletions

View file

@ -1451,6 +1451,128 @@
"title": "No _xmpp-server._tcp record",
"detail": "Server-to-server federation relies on _xmpp-server._tcp; without it remote XMPP servers cannot reach yours."
}
},
"origin": {
"missing-soa": {
"title": "Origin without SOA record",
"detail": "The zone apex must publish a Start Of Authority record."
},
"no-ns": {
"title": "Origin without NS records",
"detail": "A zone apex must list at least one authoritative name server."
},
"single-ns": {
"title": "Origin has only one NS record",
"detail": "RFC 2182 §3.1 recommends at least two name servers on diverse infrastructure."
},
"ns-missing-target": {
"title": "NS record without a target",
"detail": "Each NS must point to a hostname."
},
"ns-target-is-ip": {
"title": "NS target is an IP address: {{target}}",
"detail": "RFC 1034 forbids IP literals in NS targets — use a hostname that resolves to A/AAAA records."
},
"ns-invalid-hostname": {
"title": "Invalid NS hostname: {{target}}",
"detail": "NS targets must be valid DNS hostnames."
},
"ns-target-not-fqdn": {
"title": "NS target {{target}} is not absolute",
"detail": "Add a trailing dot to make the target an FQDN, otherwise the zone origin will be appended."
},
"duplicate-ns": {
"title": "Duplicate NS target: {{target}}",
"detail": "The same hostname is listed twice; only the first is used."
},
"soa-primary-missing-from-ns": {
"title": "SOA primary {{primary}} is not listed as an NS",
"detail": "RFC 1912 §2.7: the master listed in the MNAME field of the SOA should also appear as an NS at the apex."
},
"soa-missing-primary": {
"title": "SOA primary master is empty",
"detail": "Provide the FQDN of the authoritative primary name server."
},
"soa-primary-is-ip": {
"title": "SOA primary is an IP literal: {{target}}",
"detail": "RFC 1035 requires a hostname; place the IP behind an A/AAAA record and reference the hostname here."
},
"soa-primary-invalid-hostname": {
"title": "Invalid SOA primary hostname: {{target}}",
"detail": "The MNAME field must be a valid DNS hostname."
},
"soa-primary-not-fqdn": {
"title": "SOA primary {{target}} is not absolute",
"detail": "Add a trailing dot to make the primary an FQDN."
},
"soa-missing-mbox": {
"title": "SOA contact (Mbox) is empty",
"detail": "Set the responsible-party mailbox in DNS form, e.g. hostmaster.example.com."
},
"soa-mbox-with-at": {
"title": "SOA Mbox contains @: {{value}}",
"detail": "RFC 1035 §3.3.13 encodes the address with a dot replacing the @: hostmaster.example.com., not hostmaster@example.com."
},
"soa-mbox-invalid": {
"title": "Invalid SOA contact: {{value}}",
"detail": "Mbox must be a valid DNS hostname (e.g. hostmaster.example.com.)."
},
"soa-mbox-not-fqdn": {
"title": "SOA Mbox {{value}} is not absolute",
"detail": "Add a trailing dot so the Mbox does not get the zone origin appended."
},
"soa-invalid-serial": {
"title": "Invalid SOA serial: {{value}}",
"detail": "The serial must be a non-negative 32-bit integer."
},
"soa-zero-serial": {
"title": "SOA serial is 0",
"detail": "Slaves keep the zone version they already have if the master serial is 0; bump it to a real value."
},
"soa-serial-not-date": {
"title": "Serial {{value}} is 10 digits but not YYYYMMDDNN",
"detail": "RFC 1912 §2.2 example uses date-based serials. With 10 digits the operator usually expects the YYYYMMDDNN convention."
},
"soa-invalid-refresh": {
"title": "Invalid SOA refresh: {{value}}",
"detail": "Refresh must be a non-negative integer in seconds."
},
"soa-refresh-out-of-range": {
"title": "SOA refresh outside RFC 1912 range ({{value}}s)",
"detail": "RFC 1912 §2.2 recommends refresh between {{min}} and {{max}} seconds."
},
"soa-invalid-retry": {
"title": "Invalid SOA retry: {{value}}",
"detail": "Retry must be a non-negative integer in seconds."
},
"soa-retry-out-of-range": {
"title": "SOA retry outside RFC 1912 range ({{value}}s)",
"detail": "RFC 1912 §2.2 recommends retry between {{min}} and {{max}} seconds."
},
"soa-retry-ge-refresh": {
"title": "SOA retry is not smaller than refresh",
"detail": "Slaves should retry on a shorter cycle than the regular refresh interval."
},
"soa-invalid-expire": {
"title": "Invalid SOA expire: {{value}}",
"detail": "Expire must be a non-negative integer in seconds."
},
"soa-expire-out-of-range": {
"title": "SOA expire outside RFC 1912 range ({{value}}s)",
"detail": "RFC 1912 §2.2 recommends expire between {{min}} and {{max}} seconds (commonly 2 weeks)."
},
"soa-expire-lt-refresh-retry": {
"title": "SOA expire is shorter than refresh + retry",
"detail": "Slaves should not give up on the zone before they had a chance to retry the refresh."
},
"soa-invalid-minttl": {
"title": "Invalid SOA minimum TTL: {{value}}",
"detail": "Minimum/Negative TTL must be a non-negative integer in seconds."
},
"soa-minttl-out-of-range": {
"title": "SOA minimum/negative TTL outside common range ({{value}}s)",
"detail": "RFC 2308 §5 caps the negative-cache TTL effectively at a few hours; aim for a value between {{min}} and {{max}} seconds."
}
}
}
}

View file

@ -1222,6 +1222,128 @@
"title": "Aucun enregistrement _xmpp-server._tcp",
"detail": "La fédération serveur-à-serveur s'appuie sur _xmpp-server._tcp ; sans cela les serveurs distants ne peuvent pas joindre le vôtre."
}
},
"origin": {
"missing-soa": {
"title": "Origine sans enregistrement SOA",
"detail": "L'apex de zone doit publier un enregistrement Start Of Authority."
},
"no-ns": {
"title": "Origine sans enregistrement NS",
"detail": "L'apex de zone doit lister au moins un serveur de noms autoritaire."
},
"single-ns": {
"title": "Origine avec un seul NS",
"detail": "RFC 2182 §3.1 recommande au moins deux serveurs de noms sur des infrastructures diverses."
},
"ns-missing-target": {
"title": "NS sans cible",
"detail": "Chaque NS doit pointer vers un nom d'hôte."
},
"ns-target-is-ip": {
"title": "Cible NS est une adresse IP : {{target}}",
"detail": "La RFC 1034 interdit les littéraux IP comme cible de NS — utilisez un nom d'hôte qui résout en A/AAAA."
},
"ns-invalid-hostname": {
"title": "Nom d'hôte NS invalide : {{target}}",
"detail": "Les cibles NS doivent être des noms DNS valides."
},
"ns-target-not-fqdn": {
"title": "Cible NS {{target}} non absolue",
"detail": "Ajoutez un point final pour rendre la cible FQDN, sinon l'origine de zone sera ajoutée."
},
"duplicate-ns": {
"title": "Cible NS dupliquée : {{target}}",
"detail": "Le même hôte est listé deux fois ; seul le premier est utilisé."
},
"soa-primary-missing-from-ns": {
"title": "Primaire SOA {{primary}} absent des NS",
"detail": "RFC 1912 §2.7 : le maître figurant en MNAME du SOA doit aussi être listé en NS à l'apex."
},
"soa-missing-primary": {
"title": "Primaire SOA vide",
"detail": "Renseignez le FQDN du serveur de noms primaire faisant autorité."
},
"soa-primary-is-ip": {
"title": "Primaire SOA est une IP : {{target}}",
"detail": "La RFC 1035 impose un nom d'hôte ; placez l'IP derrière un A/AAAA et référencez le nom ici."
},
"soa-primary-invalid-hostname": {
"title": "Nom d'hôte SOA invalide : {{target}}",
"detail": "Le champ MNAME doit être un nom DNS valide."
},
"soa-primary-not-fqdn": {
"title": "Primaire SOA {{target}} non absolu",
"detail": "Ajoutez un point final pour rendre le nom FQDN."
},
"soa-missing-mbox": {
"title": "Contact SOA (Mbox) vide",
"detail": "Renseignez la boîte responsable au format DNS, par ex. hostmaster.example.com."
},
"soa-mbox-with-at": {
"title": "Le Mbox SOA contient @ : {{value}}",
"detail": "RFC 1035 §3.3.13 encode l'adresse avec un point à la place du @ : hostmaster.example.com., pas hostmaster@example.com."
},
"soa-mbox-invalid": {
"title": "Contact SOA invalide : {{value}}",
"detail": "Mbox doit être un nom DNS valide (ex. hostmaster.example.com.)."
},
"soa-mbox-not-fqdn": {
"title": "Mbox SOA {{value}} non absolu",
"detail": "Ajoutez un point final pour que le Mbox ne reçoive pas l'origine de zone en suffixe."
},
"soa-invalid-serial": {
"title": "Serial SOA invalide : {{value}}",
"detail": "Le serial doit être un entier 32 bits non négatif."
},
"soa-zero-serial": {
"title": "Serial SOA à 0",
"detail": "Les esclaves conservent leur version actuelle si le serial maître est 0 ; passez à une valeur réelle."
},
"soa-serial-not-date": {
"title": "Serial {{value}} : 10 chiffres mais pas YYYYMMDDNN",
"detail": "RFC 1912 §2.2 illustre la convention basée date. Avec 10 chiffres, l'opérateur attend généralement le format YYYYMMDDNN."
},
"soa-invalid-refresh": {
"title": "Refresh SOA invalide : {{value}}",
"detail": "Refresh doit être un entier non négatif en secondes."
},
"soa-refresh-out-of-range": {
"title": "Refresh SOA hors plage RFC 1912 ({{value}}s)",
"detail": "RFC 1912 §2.2 recommande un refresh entre {{min}} et {{max}} secondes."
},
"soa-invalid-retry": {
"title": "Retry SOA invalide : {{value}}",
"detail": "Retry doit être un entier non négatif en secondes."
},
"soa-retry-out-of-range": {
"title": "Retry SOA hors plage RFC 1912 ({{value}}s)",
"detail": "RFC 1912 §2.2 recommande un retry entre {{min}} et {{max}} secondes."
},
"soa-retry-ge-refresh": {
"title": "Retry SOA pas inférieur au refresh",
"detail": "Les esclaves doivent réessayer sur un cycle plus court que le refresh régulier."
},
"soa-invalid-expire": {
"title": "Expire SOA invalide : {{value}}",
"detail": "Expire doit être un entier non négatif en secondes."
},
"soa-expire-out-of-range": {
"title": "Expire SOA hors plage RFC 1912 ({{value}}s)",
"detail": "RFC 1912 §2.2 recommande un expire entre {{min}} et {{max}} secondes (typiquement 2 semaines)."
},
"soa-expire-lt-refresh-retry": {
"title": "Expire SOA plus court que refresh + retry",
"detail": "Les esclaves ne devraient pas abandonner la zone avant d'avoir eu l'occasion de retenter le refresh."
},
"soa-invalid-minttl": {
"title": "TTL minimum SOA invalide : {{value}}",
"detail": "Le TTL minimum/négatif doit être un entier non négatif en secondes."
},
"soa-minttl-out-of-range": {
"title": "TTL minimum/négatif SOA hors plage usuelle ({{value}}s)",
"detail": "RFC 2308 §5 plafonne le TTL négatif à quelques heures ; visez une valeur entre {{min}} et {{max}} secondes."
}
}
}
}

View file

@ -35,5 +35,6 @@ import "../verification"; // abstract.ACMEChallenge + verification services
import "../openpgp"; // abstract.OpenPGP + abstract.SMimeCert
import "../delegation"; // abstract.Delegation
import "../srv-abstract"; // abstract.RFC6186 + abstract.MatrixIM + abstract.XMPP
import "../origin"; // abstract.Origin + abstract.NSOnlyOrigin
export {};

View file

@ -0,0 +1,224 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { describe, it, expect } from "vitest";
import { validateOrigin, validateNSOnlyOrigin } from "./origin";
import type { ComplianceContext } from "./compliance";
import type { Domain } from "$lib/model/domain";
const ctx: ComplianceContext = {
dn: "@",
origin: { id: "test", domain: "example.com" } as unknown as Domain,
zone: null,
findServices: () => [],
findAllServices: () => [],
};
const ids = (issues: { id: string }[]) => issues.map((i) => i.id);
const okSOA = {
Ns: "ns1.example.com.",
Mbox: "hostmaster.example.com.",
Serial: 2026042501,
Refresh: 28800,
Retry: 3600,
Expire: 1209600,
Minttl: 3600,
};
describe("validateOrigin", () => {
it("accepts a clean RFC 1912-compliant origin", () => {
expect(
validateOrigin(
{
soa: okSOA,
ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }],
},
ctx,
),
).toEqual([]);
});
it("flags missing SOA", () => {
expect(ids(validateOrigin({ ns: [{ Ns: "ns1.example.com." }] }, ctx))).toContain(
"abstract.origin.missing-soa",
);
});
it("flags Mbox with @", () => {
expect(
ids(
validateOrigin(
{
soa: { ...okSOA, Mbox: "hostmaster@example.com." },
ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }],
},
ctx,
),
),
).toContain("abstract.origin.soa-mbox-with-at");
});
it("flags zero serial", () => {
expect(
ids(
validateOrigin(
{
soa: { ...okSOA, Serial: 0 },
ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }],
},
ctx,
),
),
).toContain("abstract.origin.soa-zero-serial");
});
it("flags 10-digit serial that's not date-shaped", () => {
expect(
ids(
validateOrigin(
{
soa: { ...okSOA, Serial: 1234567890 },
ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }],
},
ctx,
),
),
).toContain("abstract.origin.soa-serial-not-date");
});
it("flags refresh out of range", () => {
expect(
ids(
validateOrigin(
{
soa: { ...okSOA, Refresh: 60 },
ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }],
},
ctx,
),
),
).toContain("abstract.origin.soa-refresh-out-of-range");
});
it("flags retry >= refresh", () => {
expect(
ids(
validateOrigin(
{
soa: { ...okSOA, Retry: 7200, Refresh: 7200 },
ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }],
},
ctx,
),
),
).toContain("abstract.origin.soa-retry-ge-refresh");
});
it("flags expire shorter than refresh + retry", () => {
expect(
ids(
validateOrigin(
{
soa: { ...okSOA, Expire: 1209600, Refresh: 86400, Retry: 7200 },
ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }],
},
ctx,
),
),
).not.toContain("abstract.origin.soa-expire-lt-refresh-retry");
});
it("flags single NS", () => {
expect(
ids(validateOrigin({ soa: okSOA, ns: [{ Ns: "ns1.example.com." }] }, ctx)),
).toContain("abstract.origin.single-ns");
});
it("flags SOA primary missing from NS list", () => {
expect(
ids(
validateOrigin(
{
soa: okSOA,
ns: [{ Ns: "ns2.example.com." }, { Ns: "ns3.example.com." }],
},
ctx,
),
),
).toContain("abstract.origin.soa-primary-missing-from-ns");
});
it("flags duplicate NS targets", () => {
expect(
ids(
validateOrigin(
{
soa: okSOA,
ns: [
{ Ns: "ns1.example.com." },
{ Ns: "ns1.example.com." },
{ Ns: "ns2.example.com." },
],
},
ctx,
),
),
).toContain("abstract.origin.duplicate-ns");
});
it("flags IP-literal NS", () => {
expect(
ids(
validateOrigin(
{
soa: okSOA,
ns: [{ Ns: "ns1.example.com." }, { Ns: "203.0.113.1" }],
},
ctx,
),
),
).toContain("abstract.origin.ns-target-is-ip");
});
});
describe("validateNSOnlyOrigin", () => {
it("accepts a two-NS origin", () => {
expect(
validateNSOnlyOrigin(
{ ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }] },
ctx,
),
).toEqual([]);
});
it("flags single NS", () => {
expect(
ids(validateNSOnlyOrigin({ ns: [{ Ns: "ns1.example.com." }] }, ctx)),
).toContain("abstract.origin.single-ns");
});
it("flags missing NS", () => {
expect(ids(validateNSOnlyOrigin({ ns: [] }, ctx))).toContain(
"abstract.origin.no-ns",
);
});
});

View file

@ -0,0 +1,373 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { isFqdn, isIpAddress, isValidHostname } from "$lib/services/_helpers/hostname";
import {
registerValidators,
type ComplianceContext,
type ComplianceIssue,
} from "$lib/services/compliance";
const RFC1912_URL = "https://datatracker.ietf.org/doc/html/rfc1912#section-2.2";
const RFC2182_URL = "https://datatracker.ietf.org/doc/html/rfc2182#section-3.1";
const RFC2308_URL = "https://datatracker.ietf.org/doc/html/rfc2308#section-5";
const RFC1034_URL = "https://datatracker.ietf.org/doc/html/rfc1034#section-4.2.2";
interface NSRecord {
Ns?: string;
}
interface SOARecord {
Ns?: string;
Mbox?: string;
Serial?: number;
Refresh?: number;
Retry?: number;
Expire?: number;
Minttl?: number;
}
interface OriginValue {
soa?: SOARecord | null;
ns?: NSRecord[] | null;
}
// RFC 1912 §2.2 ranges (in seconds).
const REFRESH_MIN = 1200; // 20 min
const REFRESH_MAX = 86400; // 1 day
const RETRY_MIN = 120; // 2 min
const RETRY_MAX = 7200; // 2 h
const EXPIRE_MIN = 1209600; // 2 weeks
const EXPIRE_MAX = 2419200; // 4 weeks
// RFC 2308 §5: negative TTL is normally ≤ 3h; we allow up to 1d before flagging.
const MINTTL_MIN = 60;
const MINTTL_MAX = 86400;
// YYYYMMDDNN convention (RFC 1912 §2.2 example).
const SERIAL_DATE_RE = /^(20\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{2}$/;
function normalizeHost(s: string): string {
return s.trim().toLowerCase().replace(/\.$/, "");
}
function validateNSList(
ns: NSRecord[],
issues: ComplianceIssue[],
soaPrimary: string | undefined,
): void {
if (ns.length === 0) {
issues.push({
id: "abstract.origin.no-ns",
severity: "error",
field: "ns",
docUrl: RFC1034_URL,
});
} else if (ns.length === 1) {
issues.push({
id: "abstract.origin.single-ns",
severity: "warning",
field: "ns",
docUrl: RFC2182_URL,
});
}
const seen = new Map<string, number>();
let primaryListed = soaPrimary === undefined;
const normalizedPrimary = soaPrimary ? normalizeHost(soaPrimary) : "";
ns.forEach((r, i) => {
const target = (r.Ns ?? "").trim();
if (!target) {
issues.push({
id: "abstract.origin.ns-missing-target",
severity: "error",
field: `ns[${i}].Ns`,
});
return;
}
if (isIpAddress(target)) {
issues.push({
id: "abstract.origin.ns-target-is-ip",
severity: "error",
field: `ns[${i}].Ns`,
params: { target },
docUrl: RFC1034_URL,
});
} else if (!isValidHostname(target)) {
issues.push({
id: "abstract.origin.ns-invalid-hostname",
severity: "error",
field: `ns[${i}].Ns`,
params: { target },
});
}
if (!isFqdn(target)) {
issues.push({
id: "abstract.origin.ns-target-not-fqdn",
severity: "info",
field: `ns[${i}].Ns`,
params: { target },
});
}
const key = normalizeHost(target);
if (seen.has(key)) {
issues.push({
id: "abstract.origin.duplicate-ns",
severity: "info",
field: `ns[${i}].Ns`,
params: { target: key },
});
} else {
seen.set(key, i);
}
if (normalizedPrimary && key === normalizedPrimary) {
primaryListed = true;
}
});
if (soaPrimary && !primaryListed) {
issues.push({
id: "abstract.origin.soa-primary-missing-from-ns",
severity: "warning",
params: { primary: soaPrimary },
docUrl: "https://datatracker.ietf.org/doc/html/rfc1912#section-2.7",
});
}
}
function validateSOA(soa: SOARecord, issues: ComplianceIssue[]): void {
const ns = (soa.Ns ?? "").trim();
if (!ns) {
issues.push({
id: "abstract.origin.soa-missing-primary",
severity: "error",
field: "soa.Ns",
});
} else if (isIpAddress(ns)) {
issues.push({
id: "abstract.origin.soa-primary-is-ip",
severity: "error",
field: "soa.Ns",
params: { target: ns },
});
} else if (!isValidHostname(ns)) {
issues.push({
id: "abstract.origin.soa-primary-invalid-hostname",
severity: "error",
field: "soa.Ns",
params: { target: ns },
});
} else if (!isFqdn(ns)) {
issues.push({
id: "abstract.origin.soa-primary-not-fqdn",
severity: "info",
field: "soa.Ns",
params: { target: ns },
});
}
const mbox = (soa.Mbox ?? "").trim();
if (!mbox) {
issues.push({
id: "abstract.origin.soa-missing-mbox",
severity: "error",
field: "soa.Mbox",
});
} else if (mbox.includes("@")) {
issues.push({
id: "abstract.origin.soa-mbox-with-at",
severity: "error",
field: "soa.Mbox",
params: { value: mbox },
});
} else if (!isValidHostname(mbox)) {
issues.push({
id: "abstract.origin.soa-mbox-invalid",
severity: "error",
field: "soa.Mbox",
params: { value: mbox },
});
} else if (!isFqdn(mbox)) {
issues.push({
id: "abstract.origin.soa-mbox-not-fqdn",
severity: "info",
field: "soa.Mbox",
params: { value: mbox },
});
}
const serial = soa.Serial;
if (serial === undefined || !Number.isInteger(serial) || serial < 0) {
issues.push({
id: "abstract.origin.soa-invalid-serial",
severity: "error",
field: "soa.Serial",
params: { value: serial ?? "?" },
});
} else if (serial === 0) {
issues.push({
id: "abstract.origin.soa-zero-serial",
severity: "warning",
field: "soa.Serial",
});
} else {
const s = String(serial);
if (s.length === 10 && !SERIAL_DATE_RE.test(s)) {
issues.push({
id: "abstract.origin.soa-serial-not-date",
severity: "info",
field: "soa.Serial",
params: { value: serial },
docUrl: RFC1912_URL,
});
}
}
const checkRange = (
value: number | undefined,
field: keyof SOARecord,
min: number,
max: number,
outOfRangeId: string,
rfcUrl: string,
) => {
if (value === undefined || !Number.isInteger(value) || value < 0) {
issues.push({
id: `abstract.origin.soa-invalid-${field.toLowerCase()}`,
severity: "error",
field: `soa.${field}`,
params: { value: value ?? "?" },
docUrl: rfcUrl,
});
return;
}
if (value < min || value > max) {
issues.push({
id: outOfRangeId,
severity: "warning",
field: `soa.${field}`,
params: { value, min, max },
docUrl: rfcUrl,
});
}
};
checkRange(
soa.Refresh,
"Refresh",
REFRESH_MIN,
REFRESH_MAX,
"abstract.origin.soa-refresh-out-of-range",
RFC1912_URL,
);
checkRange(
soa.Retry,
"Retry",
RETRY_MIN,
RETRY_MAX,
"abstract.origin.soa-retry-out-of-range",
RFC1912_URL,
);
checkRange(
soa.Expire,
"Expire",
EXPIRE_MIN,
EXPIRE_MAX,
"abstract.origin.soa-expire-out-of-range",
RFC1912_URL,
);
checkRange(
soa.Minttl,
"Minttl",
MINTTL_MIN,
MINTTL_MAX,
"abstract.origin.soa-minttl-out-of-range",
RFC2308_URL,
);
if (
Number.isInteger(soa.Retry) &&
Number.isInteger(soa.Refresh) &&
soa.Retry !== undefined &&
soa.Refresh !== undefined &&
soa.Retry >= soa.Refresh
) {
issues.push({
id: "abstract.origin.soa-retry-ge-refresh",
severity: "warning",
field: "soa.Retry",
docUrl: RFC1912_URL,
});
}
if (
Number.isInteger(soa.Expire) &&
Number.isInteger(soa.Refresh) &&
Number.isInteger(soa.Retry) &&
soa.Expire !== undefined &&
soa.Refresh !== undefined &&
soa.Retry !== undefined &&
soa.Expire < soa.Refresh + soa.Retry
) {
issues.push({
id: "abstract.origin.soa-expire-lt-refresh-retry",
severity: "warning",
field: "soa.Expire",
docUrl: RFC1912_URL,
});
}
}
export function validateOrigin(v: OriginValue, _ctx: ComplianceContext): ComplianceIssue[] {
const issues: ComplianceIssue[] = [];
const ns = Array.isArray(v.ns) ? v.ns : [];
const soa = v.soa ?? null;
if (!soa) {
issues.push({
id: "abstract.origin.missing-soa",
severity: "error",
field: "soa",
});
} else {
validateSOA(soa, issues);
}
validateNSList(ns, issues, soa?.Ns);
return issues;
}
export function validateNSOnlyOrigin(
v: { ns?: NSRecord[] | null },
_ctx: ComplianceContext,
): ComplianceIssue[] {
const issues: ComplianceIssue[] = [];
const ns = Array.isArray(v.ns) ? v.ns : [];
validateNSList(ns, issues, undefined);
return issues;
}
registerValidators("abstract.Origin", {
sync: (raw, ctx) => validateOrigin((raw ?? {}) as OriginValue, ctx),
});
registerValidators("abstract.NSOnlyOrigin", {
sync: (raw, ctx) => validateNSOnlyOrigin((raw ?? {}) as { ns?: NSRecord[] | null }, ctx),
});