Skip to content

Commit 78fe5e3

Browse files
authored
Merge pull request #5150 from logical-tech/feat/dns-records-management
feat(dns): rework provider management and support all record types
2 parents 8639605 + 8cd2ba8 commit 78fe5e3

20 files changed

Lines changed: 2013 additions & 638 deletions

File tree

‎apps/dokploy/__test__/dns/cloudflare.test.ts‎

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,224 @@ describe("cloudflareClient.listRecords", () => {
8787
});
8888
});
8989

90+
describe("cloudflareClient MX priority", () => {
91+
it("inlines the priority into the content when listing", async () => {
92+
mockFetch.mockResolvedValue(
93+
cfSuccess([
94+
{
95+
id: "mx-1",
96+
type: "MX",
97+
name: "example.com",
98+
content: "mail.example.com",
99+
ttl: 300,
100+
priority: 20,
101+
},
102+
]),
103+
);
104+
105+
const records = await cloudflareClient.listRecords(config, "zone-1");
106+
107+
expect(records[0]?.content).toBe("20 mail.example.com");
108+
});
109+
110+
it("splits the priority back out when writing", async () => {
111+
mockFetch
112+
.mockResolvedValueOnce(cfSuccess([]))
113+
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
114+
115+
await cloudflareClient.upsertRecord(config, {
116+
zoneId: "zone-1",
117+
type: "MX",
118+
name: "example.com",
119+
content: "20 mail.example.com",
120+
});
121+
122+
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
123+
expect(JSON.parse(init.body as string)).toMatchObject({
124+
content: "mail.example.com",
125+
priority: 20,
126+
});
127+
});
128+
129+
it("falls back to priority 10 when the content has no leading number", async () => {
130+
mockFetch
131+
.mockResolvedValueOnce(cfSuccess([]))
132+
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
133+
134+
await cloudflareClient.upsertRecord(config, {
135+
zoneId: "zone-1",
136+
type: "MX",
137+
name: "example.com",
138+
content: "mail.example.com",
139+
});
140+
141+
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
142+
expect(JSON.parse(init.body as string)).toMatchObject({
143+
content: "mail.example.com",
144+
priority: 10,
145+
});
146+
});
147+
148+
it("leaves non-MX content untouched", async () => {
149+
mockFetch
150+
.mockResolvedValueOnce(cfSuccess([]))
151+
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
152+
153+
await cloudflareClient.upsertRecord(config, {
154+
zoneId: "zone-1",
155+
type: "TXT",
156+
name: "example.com",
157+
content: "10 not-a-priority",
158+
});
159+
160+
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
161+
const body = JSON.parse(init.body as string);
162+
expect(body.content).toBe("10 not-a-priority");
163+
expect(body.priority).toBeUndefined();
164+
});
165+
});
166+
167+
describe("cloudflareClient structured records", () => {
168+
const stubCreate = () =>
169+
mockFetch
170+
.mockResolvedValueOnce(cfSuccess([]))
171+
.mockResolvedValueOnce(cfSuccess({ id: "rec-1" }));
172+
173+
it("sends SRV values as structured data", async () => {
174+
stubCreate();
175+
176+
await cloudflareClient.upsertRecord(config, {
177+
zoneId: "zone-1",
178+
type: "SRV",
179+
name: "_sip._tcp.example.com",
180+
content: "1 10 5269 talk.example.com",
181+
});
182+
183+
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
184+
const body = JSON.parse(init.body as string);
185+
expect(body.data).toEqual({
186+
priority: 1,
187+
weight: 10,
188+
port: 5269,
189+
target: "talk.example.com",
190+
});
191+
expect(body.content).toBeUndefined();
192+
});
193+
194+
it("sends CAA values as structured data and drops the quotes", async () => {
195+
stubCreate();
196+
197+
await cloudflareClient.upsertRecord(config, {
198+
zoneId: "zone-1",
199+
type: "CAA",
200+
name: "example.com",
201+
content: '0 issue "letsencrypt.org"',
202+
});
203+
204+
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
205+
const body = JSON.parse(init.body as string);
206+
expect(body.data).toEqual({
207+
flags: 0,
208+
tag: "issue",
209+
value: "letsencrypt.org",
210+
});
211+
expect(body.content).toBeUndefined();
212+
});
213+
214+
it("rejects an SRV value that is missing a part", async () => {
215+
await expect(
216+
cloudflareClient.upsertRecord(config, {
217+
zoneId: "zone-1",
218+
type: "SRV",
219+
name: "_sip._tcp.example.com",
220+
content: "1 10 5269",
221+
}),
222+
).rejects.toThrow(/SRV value/);
223+
});
224+
225+
it("rejects a CAA value that has no tag", async () => {
226+
await expect(
227+
cloudflareClient.upsertRecord(config, {
228+
zoneId: "zone-1",
229+
type: "CAA",
230+
name: "example.com",
231+
content: "0",
232+
}),
233+
).rejects.toThrow(/CAA value/);
234+
});
235+
});
236+
237+
describe("cloudflareClient proxy status", () => {
238+
it("sends the proxy status for proxiable types", async () => {
239+
mockFetch
240+
.mockResolvedValueOnce(cfSuccess([]))
241+
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
242+
243+
await cloudflareClient.upsertRecord(config, {
244+
zoneId: "zone-1",
245+
type: "A",
246+
name: "app.example.com",
247+
content: "1.2.3.4",
248+
proxied: true,
249+
});
250+
251+
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
252+
expect(JSON.parse(init.body as string).proxied).toBe(true);
253+
});
254+
255+
it("omits the proxy status for types Cloudflare cannot proxy", async () => {
256+
mockFetch
257+
.mockResolvedValueOnce(cfSuccess([]))
258+
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
259+
260+
await cloudflareClient.upsertRecord(config, {
261+
zoneId: "zone-1",
262+
type: "TXT",
263+
name: "example.com",
264+
content: "hello",
265+
proxied: true,
266+
});
267+
268+
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
269+
expect(JSON.parse(init.body as string).proxied).toBeUndefined();
270+
});
271+
272+
it("leaves the proxy status untouched when the caller does not set it", async () => {
273+
mockFetch
274+
.mockResolvedValueOnce(cfSuccess([]))
275+
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
276+
277+
await cloudflareClient.upsertRecord(config, {
278+
zoneId: "zone-1",
279+
type: "A",
280+
name: "app.example.com",
281+
content: "1.2.3.4",
282+
});
283+
284+
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
285+
expect("proxied" in JSON.parse(init.body as string)).toBe(false);
286+
});
287+
288+
it("returns the proxy status when listing records", async () => {
289+
mockFetch.mockResolvedValue(
290+
cfSuccess([
291+
{
292+
id: "a-1",
293+
type: "A",
294+
name: "app.example.com",
295+
content: "1.2.3.4",
296+
ttl: 1,
297+
proxied: true,
298+
},
299+
]),
300+
);
301+
302+
const records = await cloudflareClient.listRecords(config, "zone-1");
303+
304+
expect(records[0]?.proxied).toBe(true);
305+
});
306+
});
307+
90308
describe("cloudflareClient.upsertRecord", () => {
91309
it("creates a record when none exists for the name/type", async () => {
92310
mockFetch

‎apps/dokploy/__test__/dns/route53.test.ts‎

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,15 @@ describe("route53Client.listRecords", () => {
142142

143143
const records = await route53Client.listRecords(config, "Z123");
144144

145-
expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com");
145+
expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com");
146146
});
147147
});
148148

149149
describe("route53Client.upsertRecord", () => {
150-
it("sends a single UPSERT change", async () => {
151-
send.mockResolvedValueOnce({});
150+
it("sends a single UPSERT change when nothing exists yet", async () => {
151+
send
152+
.mockResolvedValueOnce({ ResourceRecordSets: [] })
153+
.mockResolvedValueOnce({});
152154

153155
const result = await route53Client.upsertRecord(config, {
154156
zoneId: "Z123",
@@ -158,7 +160,7 @@ describe("route53Client.upsertRecord", () => {
158160
});
159161

160162
expect(result).toEqual({ id: "A:app.example.com" });
161-
const command = send.mock.calls[0]?.[0] as HasInput;
163+
const command = send.mock.calls[1]?.[0] as HasInput;
162164
expect(command.input.HostedZoneId).toBe("Z123");
163165
expect(command.input.ChangeBatch.Changes).toEqual([
164166
{
@@ -172,6 +174,82 @@ describe("route53Client.upsertRecord", () => {
172174
},
173175
]);
174176
});
177+
178+
it("keeps the values already in the record set", async () => {
179+
send
180+
.mockResolvedValueOnce({
181+
ResourceRecordSets: [
182+
{
183+
Name: "app.example.com.",
184+
Type: "A",
185+
TTL: 300,
186+
ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }],
187+
},
188+
],
189+
})
190+
.mockResolvedValueOnce({});
191+
192+
await route53Client.upsertRecord(config, {
193+
zoneId: "Z123",
194+
type: "A",
195+
name: "app.example.com",
196+
content: "3.3.3.3",
197+
});
198+
199+
const command = send.mock.calls[1]?.[0] as HasInput;
200+
expect(
201+
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
202+
).toEqual([
203+
{ Value: "1.1.1.1" },
204+
{ Value: "2.2.2.2" },
205+
{ Value: "3.3.3.3" },
206+
]);
207+
});
208+
209+
it("does not duplicate a value that is already in the record set", async () => {
210+
send
211+
.mockResolvedValueOnce({
212+
ResourceRecordSets: [
213+
{
214+
Name: "app.example.com.",
215+
Type: "A",
216+
TTL: 300,
217+
ResourceRecords: [{ Value: "1.1.1.1" }],
218+
},
219+
],
220+
})
221+
.mockResolvedValueOnce({});
222+
223+
await route53Client.upsertRecord(config, {
224+
zoneId: "Z123",
225+
type: "A",
226+
name: "app.example.com",
227+
content: "1.1.1.1",
228+
});
229+
230+
const command = send.mock.calls[1]?.[0] as HasInput;
231+
expect(
232+
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
233+
).toEqual([{ Value: "1.1.1.1" }]);
234+
});
235+
236+
it("wraps unquoted TXT values in double quotes", async () => {
237+
send
238+
.mockResolvedValueOnce({ ResourceRecordSets: [] })
239+
.mockResolvedValueOnce({});
240+
241+
await route53Client.upsertRecord(config, {
242+
zoneId: "Z123",
243+
type: "TXT",
244+
name: "example.com",
245+
content: 'v=spf1 ~all\n"already quoted"',
246+
});
247+
248+
const command = send.mock.calls[1]?.[0] as HasInput;
249+
expect(
250+
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
251+
).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]);
252+
});
175253
});
176254

177255
describe("route53Client.updateRecord", () => {
@@ -222,6 +300,25 @@ describe("route53Client.updateRecord", () => {
222300
]);
223301
});
224302

303+
it("keeps every line of a multi-value record set", async () => {
304+
send.mockResolvedValueOnce({});
305+
306+
await route53Client.updateRecord(config, "Z123", "NS:example.com", {
307+
type: "NS",
308+
name: "example.com",
309+
content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ",
310+
});
311+
312+
const command = send.mock.calls[0]?.[0] as HasInput;
313+
expect(
314+
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
315+
).toEqual([
316+
{ Value: "ns1.example.com" },
317+
{ Value: "ns2.example.com" },
318+
{ Value: "ns3.example.com" },
319+
]);
320+
});
321+
225322
it("skips the DELETE when the old record no longer exists", async () => {
226323
send
227324
.mockResolvedValueOnce({ ResourceRecordSets: [] })

0 commit comments

Comments
 (0)