Files
cloudflare-domain-manager/packages/shared/src/validators.ts
T
DenozordecandCursor 7f06466058
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 12s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m9s
quality / api (push) Successful in 1m3s
CD / quality (push) Successful in 2m29s
CD / publish (push) Successful in 1m49s
fix(dns): разрешить вложенный wildcard в имени DNS-записи
Co-authored-by: Cursor <[email protected]>
2026-08-31 16:02:20 +07:00

102 lines
2.9 KiB
TypeScript

import {
CERT_EXPIRED,
CERT_OK,
CERT_WARNING,
} from "./constants.js";
import type { ServiceGroup } from "./types.js";
const LABEL_RE = "[a-zA-Z0-9_](?:[a-zA-Z0-9_-]*[a-zA-Z0-9_])?";
/** Apex `@`, zone `*`, labels, or nested wildcard (`*.ndns`, `*.ndns.shnt.top`). */
const NAME_RE = new RegExp(
`^(@|\\*|(\\*\\.)?${LABEL_RE}(?:\\.${LABEL_RE})*)$`,
);
const IPV4_RE =
/^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
const IPV6_RE = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
const ALLOWED_TYPES = ["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"];
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
}
export function validateDnsRecord(
recordType: string,
name: string,
content: string,
ttl: number,
proxied: boolean,
): void {
const rt = recordType.toUpperCase();
if (!ALLOWED_TYPES.includes(rt)) {
throw new ValidationError(`unsupported record type: ${recordType}`);
}
if (!NAME_RE.test(name)) {
throw new ValidationError(`invalid record name: ${name}`);
}
if (ttl !== 1 && (ttl < 60 || ttl > 86400)) {
throw new ValidationError("ttl must be 1 (auto) or 60-86400");
}
if (proxied && !["A", "AAAA", "CNAME"].includes(rt)) {
throw new ValidationError("proxied only allowed for A, AAAA, CNAME");
}
switch (rt) {
case "A":
if (!IPV4_RE.test(content)) {
throw new ValidationError("A record requires valid IPv4");
}
break;
case "AAAA":
if (!IPV6_RE.test(content)) {
throw new ValidationError("AAAA record requires valid IPv6");
}
break;
case "CNAME":
case "NS":
if (!content || content.includes(" ")) {
throw new ValidationError("CNAME/NS requires valid hostname");
}
break;
case "TXT":
if (!content || content.length > 2048) {
throw new ValidationError("TXT content length 1-2048");
}
break;
}
}
export function certStatusFromExpiry(daysLeft: number): string {
if (daysLeft < 0) return CERT_EXPIRED;
if (daysLeft <= 30) return CERT_WARNING;
return CERT_OK;
}
export function shouldMonitorService(
service: { enabled?: boolean; service_group_id?: number | null },
group?: Pick<ServiceGroup, "enabled"> | null,
): boolean {
if (!service.enabled) return false;
if (!service.service_group_id) return true;
return group?.enabled ?? false;
}
export function isValidIpv4(ip: string): boolean {
const parts = ip.split(".");
if (parts.length !== 4) return false;
return parts.every((p) => {
const n = Number(p);
return Number.isInteger(n) && n >= 0 && n <= 255;
});
}
/** true для IPv4/IPv6; false для hostname (важно: CNAME content не должен считаться IP). */
export function isIpLiteral(value: string): boolean {
const v = value.trim();
if (!v) return false;
if (isValidIpv4(v)) return true;
return IPV6_RE.test(v);
}