mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
fix(dns): preserve every value of a Route53 record set
Route53 returns a record set as a list of values, but listing joined them into one string and writing sent that string back as a single ResourceRecord. Editing a multi-value NS, MX or TXT set therefore either failed validation or collapsed the set into one bogus value, and creating a record for a name that already had values replaced them silently. Values are now newline separated end to end: listing joins with a newline, writing splits back into one ResourceRecord per line, and creating merges into the existing set instead of replacing it. Unquoted TXT values get the quotes Route53 requires. The record panel shows a textarea for Route53 and validates every line.
This commit is contained in:
parent
130be40b49
commit
38e855bc5e
@ -142,13 +142,15 @@ describe("route53Client.listRecords", () => {
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com");
|
||||
expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.upsertRecord", () => {
|
||||
it("sends a single UPSERT change", async () => {
|
||||
send.mockResolvedValueOnce({});
|
||||
it("sends a single UPSERT change when nothing exists yet", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
const result = await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
@ -158,7 +160,7 @@ describe("route53Client.upsertRecord", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "A:app.example.com" });
|
||||
const command = send.mock.calls[0]?.[0] as HasInput;
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(command.input.HostedZoneId).toBe("Z123");
|
||||
expect(command.input.ChangeBatch.Changes).toEqual([
|
||||
{
|
||||
@ -172,6 +174,82 @@ describe("route53Client.upsertRecord", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the values already in the record set", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "3.3.3.3",
|
||||
});
|
||||
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([
|
||||
{ Value: "1.1.1.1" },
|
||||
{ Value: "2.2.2.2" },
|
||||
{ Value: "3.3.3.3" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate a value that is already in the record set", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.1.1.1" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.1.1.1",
|
||||
});
|
||||
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([{ Value: "1.1.1.1" }]);
|
||||
});
|
||||
|
||||
it("wraps unquoted TXT values in double quotes", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
type: "TXT",
|
||||
name: "example.com",
|
||||
content: 'v=spf1 ~all\n"already quoted"',
|
||||
});
|
||||
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.updateRecord", () => {
|
||||
@ -222,6 +300,25 @@ describe("route53Client.updateRecord", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps every line of a multi-value record set", async () => {
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.updateRecord(config, "Z123", "NS:example.com", {
|
||||
type: "NS",
|
||||
name: "example.com",
|
||||
content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ",
|
||||
});
|
||||
|
||||
const command = send.mock.calls[0]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([
|
||||
{ Value: "ns1.example.com" },
|
||||
{ Value: "ns2.example.com" },
|
||||
{ Value: "ns3.example.com" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips the DELETE when the old record no longer exists", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
@ -82,8 +83,20 @@ const DnsRecordSchema = z
|
||||
proxied: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const values = data.content
|
||||
.split("\n")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
if (!values.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["content"],
|
||||
message: "Content is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pattern = structuredValuePatterns[data.type];
|
||||
if (pattern && !pattern.test(data.content.trim())) {
|
||||
if (pattern && !values.every((value) => pattern.test(value))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["content"],
|
||||
@ -161,6 +174,7 @@ export const DnsRecordPanel = ({
|
||||
const proxied = form.watch("proxied");
|
||||
const canProxy =
|
||||
provider?.providerType === "cloudflare" && PROXIABLE_TYPES.includes(type);
|
||||
const supportsMultipleValues = provider?.providerType === "route53";
|
||||
const usesAutomaticTtl = canProxy && proxied;
|
||||
|
||||
const onSubmit = async (data: DnsRecordForm) => {
|
||||
@ -286,13 +300,29 @@ export const DnsRecordPanel = ({
|
||||
<FormItem>
|
||||
<FormLabel>{valueFields[type].label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={valueFields[type].placeholder}
|
||||
{...field}
|
||||
/>
|
||||
{supportsMultipleValues ? (
|
||||
<Textarea
|
||||
className="min-h-[60px] font-mono text-xs"
|
||||
placeholder={valueFields[type].placeholder}
|
||||
{...field}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={valueFields[type].placeholder}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
{valueFields[type].hint && (
|
||||
<FormDescription>{valueFields[type].hint}</FormDescription>
|
||||
{(supportsMultipleValues || valueFields[type].hint) && (
|
||||
<FormDescription>
|
||||
{[
|
||||
valueFields[type].hint,
|
||||
supportsMultipleValues &&
|
||||
"One value per line: every line belongs to the same record set.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@ -68,6 +68,9 @@ const findExactRecordSet = async (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const formatValue = (type: DnsRecordType, value: string) =>
|
||||
type === "TXT" && !value.startsWith('"') ? JSON.stringify(value) : value;
|
||||
|
||||
const buildRecordSet = (record: {
|
||||
type: DnsRecordType;
|
||||
name: string;
|
||||
@ -77,7 +80,11 @@ const buildRecordSet = (record: {
|
||||
Name: ensureTrailingDot(record.name),
|
||||
Type: record.type as ResourceRecordSet["Type"],
|
||||
TTL: record.ttl ?? 300,
|
||||
ResourceRecords: [{ Value: record.content }],
|
||||
ResourceRecords: record.content
|
||||
.split("\n")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.map((value) => ({ Value: formatValue(record.type, value) })),
|
||||
});
|
||||
|
||||
export const route53Client: DnsClient<Route53Config> = {
|
||||
@ -129,7 +136,7 @@ export const route53Client: DnsClient<Route53Config> = {
|
||||
id: buildRecordId(set.Type, set.Name),
|
||||
type: set.Type,
|
||||
name: stripTrailingDot(set.Name),
|
||||
content: set.ResourceRecords.map((r) => r.Value).join(", "),
|
||||
content: set.ResourceRecords.map((r) => r.Value).join("\n"),
|
||||
ttl: set.TTL ?? 300,
|
||||
});
|
||||
}
|
||||
@ -141,13 +148,25 @@ export const route53Client: DnsClient<Route53Config> = {
|
||||
|
||||
async upsertRecord(config, record) {
|
||||
const client = createClient(config);
|
||||
const existing = await findExactRecordSet(
|
||||
config,
|
||||
record.zoneId,
|
||||
record.type,
|
||||
record.name,
|
||||
);
|
||||
const recordSet = buildRecordSet(record);
|
||||
if (existing?.ResourceRecords?.length) {
|
||||
const values = new Set([
|
||||
...existing.ResourceRecords.map((r) => r.Value),
|
||||
...(recordSet.ResourceRecords ?? []).map((r) => r.Value),
|
||||
]);
|
||||
recordSet.ResourceRecords = [...values].map((Value) => ({ Value }));
|
||||
}
|
||||
await client.send(
|
||||
new ChangeResourceRecordSetsCommand({
|
||||
HostedZoneId: record.zoneId,
|
||||
ChangeBatch: {
|
||||
Changes: [
|
||||
{ Action: "UPSERT", ResourceRecordSet: buildRecordSet(record) },
|
||||
],
|
||||
Changes: [{ Action: "UPSERT", ResourceRecordSet: recordSet }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user