fix(dns): send SRV and CAA values to Cloudflare as structured data

Cloudflare treats content as read-only for SRV and CAA and expects a
data object instead, so both types were rejected on write. The client
now parses the inline value into the fields Cloudflare wants, and
builds the payload before the lookup request so a malformed value
fails without spending an API call.

The form rejects a malformed SRV or CAA value up front and shows the
expected shape, so the error lands on the field instead of coming
back from the provider.
This commit is contained in:
logical-tech 2026-08-21 11:47:29 +02:00
parent 9c4ad14c70
commit db2eb4f60a
3 changed files with 146 additions and 23 deletions

View File

@ -164,6 +164,76 @@ describe("cloudflareClient MX priority", () => {
});
});
describe("cloudflareClient structured records", () => {
const stubCreate = () =>
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "rec-1" }));
it("sends SRV values as structured data", async () => {
stubCreate();
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "SRV",
name: "_sip._tcp.example.com",
content: "1 10 5269 talk.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.data).toEqual({
priority: 1,
weight: 10,
port: 5269,
target: "talk.example.com",
});
expect(body.content).toBeUndefined();
});
it("sends CAA values as structured data and drops the quotes", async () => {
stubCreate();
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CAA",
name: "example.com",
content: '0 issue "letsencrypt.org"',
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.data).toEqual({
flags: 0,
tag: "issue",
value: "letsencrypt.org",
});
expect(body.content).toBeUndefined();
});
it("rejects an SRV value that is missing a part", async () => {
await expect(
cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "SRV",
name: "_sip._tcp.example.com",
content: "1 10 5269",
}),
).rejects.toThrow(/SRV value/);
});
it("rejects a CAA value that has no tag", async () => {
await expect(
cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CAA",
name: "example.com",
content: "0",
}),
).rejects.toThrow(/CAA value/);
});
});
describe("cloudflareClient proxy status", () => {
it("sends the proxy status for proxiable types", async () => {
mockFetch

View File

@ -68,13 +68,29 @@ const valueFields: Record<
PTR: { label: "Target", placeholder: "host.example.com" },
};
const DnsRecordSchema = z.object({
type: z.enum(DNS_RECORD_TYPES),
name: z.string().min(1, { message: "Name is required" }),
content: z.string().min(1, { message: "Content is required" }),
ttl: z.string(),
proxied: z.boolean(),
});
const structuredValuePatterns: Partial<Record<RecordType, RegExp>> = {
SRV: /^\d+\s+\d+\s+\d+\s+\S+$/,
CAA: /^\d+\s+\S+\s+.+$/,
};
const DnsRecordSchema = z
.object({
type: z.enum(DNS_RECORD_TYPES),
name: z.string().min(1, { message: "Name is required" }),
content: z.string().min(1, { message: "Content is required" }),
ttl: z.string(),
proxied: z.boolean(),
})
.superRefine((data, ctx) => {
const pattern = structuredValuePatterns[data.type];
if (pattern && !pattern.test(data.content.trim())) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["content"],
message: `Expected ${valueFields[data.type].placeholder}`,
});
}
});
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;

View File

@ -31,14 +31,51 @@ const proxySettings = (record: { type: string; proxied?: boolean }) =>
? { proxied: record.proxied }
: {};
const splitPriority = (record: { type: string; content: string }) => {
if (record.type !== "MX") {
return { content: record.content };
const buildValue = (record: { type: string; content: string }) => {
const value = record.content.trim();
if (record.type === "MX") {
const match = /^(\d+)\s+(\S.*)$/.exec(value);
return match
? { content: match[2] as string, priority: Number(match[1]) }
: { content: value, priority: 10 };
}
const match = /^\s*(\d+)\s+(\S.*)$/.exec(record.content);
return match
? { content: match[2] as string, priority: Number(match[1]) }
: { content: record.content.trim(), priority: 10 };
if (record.type === "SRV") {
const parts = value.split(/\s+/);
const [priority, weight, port, target] = parts;
if (parts.length !== 4 || !target) {
throw new Error(
`Cloudflare: an SRV value must be "priority weight port target", got "${value}"`,
);
}
return {
data: {
priority: Number(priority),
weight: Number(weight),
port: Number(port),
target,
},
};
}
if (record.type === "CAA") {
const match = /^(\d+)\s+(\S+)\s+"?([^"]+)"?$/.exec(value);
if (!match) {
throw new Error(
`Cloudflare: a CAA value must be \`flags tag "value"\`, got "${value}"`,
);
}
return {
data: {
flags: Number(match[1]),
tag: match[2] as string,
value: match[3] as string,
},
};
}
return { content: value };
};
const cfFetch = async <T>(
@ -123,19 +160,19 @@ export const cloudflareClient: DnsClient<CloudflareConfig> = {
},
async upsertRecord(config, record) {
const payload = {
type: record.type,
name: record.name,
...buildValue(record),
...proxySettings(record),
ttl: record.ttl ?? 1,
};
const existing = await cfFetch<{ id: string }[]>(
config,
`/zones/${record.zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.name)}`,
);
const payload = {
type: record.type,
name: record.name,
...splitPriority(record),
...proxySettings(record),
ttl: record.ttl ?? 1,
};
const existingRecord = existing[0];
if (existingRecord) {
const updated = await cfFetch<{ id: string }>(
@ -163,7 +200,7 @@ export const cloudflareClient: DnsClient<CloudflareConfig> = {
body: JSON.stringify({
type: record.type,
name: record.name,
...splitPriority(record),
...buildValue(record),
...proxySettings(record),
ttl: record.ttl ?? 1,
}),