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
Co-authored-by: Cursor <[email protected]>
102 lines
2.9 KiB
TypeScript
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);
|
|
}
|