mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
feat: add DNS provider integration (Cloudflare, AWS Route53)
Lets you connect a DNS provider and manage its records (create, update, delete) from Settings -> DNS Providers, instead of doing it by hand in Cloudflare/AWS. - dns_provider table, org-scoped, jsonb config as a discriminated union per provider type - Cloudflare adapter (REST, bearer token) - Route53 adapter (AWS SDK, SigV4); records are identified by type:name since Route53 has no native record id, so update handles renames as delete-old + upsert-new - listZones/listRecords/createRecord/updateRecord/deleteRecord/ testConnection wired through a shared DnsClient interface - Settings UI: provider management, zone browser, record CRUD, IP-fill dropdown for A records (panel IP + remote servers) - Access control: dnsProvider resource wired into custom roles - Unit tests for both adapters and the config mask/merge logic
This commit is contained in:
parent
3848fa9c0d
commit
4d58f3987f
214
apps/dokploy/__test__/dns/cloudflare.test.ts
Normal file
214
apps/dokploy/__test__/dns/cloudflare.test.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
import { cloudflareClient } from "@dokploy/server/utils/dns/cloudflare";
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as Response;
|
||||
|
||||
const cfSuccess = (result: unknown) =>
|
||||
jsonResponse({ success: true, errors: [], result });
|
||||
|
||||
const cfError = (message: string, status = 400) =>
|
||||
jsonResponse(
|
||||
{ success: false, errors: [{ code: 1, message }] },
|
||||
false,
|
||||
status,
|
||||
);
|
||||
|
||||
const config = { providerType: "cloudflare" as const, apiToken: "cf-token" };
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("cloudflareClient.listZones", () => {
|
||||
it("returns a single page of zones", async () => {
|
||||
mockFetch.mockResolvedValue(cfSuccess([{ id: "z1", name: "example.com" }]));
|
||||
|
||||
const zones = await cloudflareClient.listZones(config);
|
||||
|
||||
expect(zones).toEqual([{ id: "z1", name: "example.com" }]);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url] = mockFetch.mock.calls[0] as [string];
|
||||
expect(url).toContain("/zones?per_page=50&page=1");
|
||||
});
|
||||
|
||||
it("paginates until a short page is returned", async () => {
|
||||
const page = (count: number) =>
|
||||
cfSuccess(
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `z${i}`,
|
||||
name: `zone${i}.com`,
|
||||
})),
|
||||
);
|
||||
mockFetch.mockResolvedValueOnce(page(50)).mockResolvedValueOnce(page(3));
|
||||
|
||||
const zones = await cloudflareClient.listZones(config);
|
||||
|
||||
expect(zones).toHaveLength(53);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch.mock.calls[1]?.[0]).toContain("page=2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.listRecords", () => {
|
||||
it("lists records for a zone", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
cfSuccess([
|
||||
{
|
||||
id: "r1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 1,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await cloudflareClient.listRecords(config, "zone-1");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "r1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 1,
|
||||
},
|
||||
]);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toContain("/zones/zone-1/dns_records");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.upsertRecord", () => {
|
||||
it("creates a record when none exists for the name/type", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "new-1" }));
|
||||
|
||||
const result = await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "new-1" });
|
||||
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(createInit.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("updates the existing record instead of creating a duplicate", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
|
||||
|
||||
const result = await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "existing-1" });
|
||||
const [updateUrl, updateInit] = mockFetch.mock.calls[1] as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(updateUrl).toContain("/dns_records/existing-1");
|
||||
expect(updateInit.method).toBe("PUT");
|
||||
});
|
||||
|
||||
it("defaults ttl to 1 (automatic) when not provided", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "new-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "CNAME",
|
||||
name: "www.example.com",
|
||||
content: "example.com",
|
||||
});
|
||||
|
||||
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
const body = JSON.parse(createInit.body as string);
|
||||
expect(body.ttl).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.updateRecord", () => {
|
||||
it("PUTs directly to the given record id", async () => {
|
||||
mockFetch.mockResolvedValue(cfSuccess({ id: "r1" }));
|
||||
|
||||
const result = await cloudflareClient.updateRecord(config, "zone-1", "r1", {
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "9.9.9.9",
|
||||
ttl: 300,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "r1" });
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("/zones/zone-1/dns_records/r1");
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "9.9.9.9",
|
||||
ttl: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.deleteRecord", () => {
|
||||
it("DELETEs the given record id", async () => {
|
||||
mockFetch.mockResolvedValue(cfSuccess({}));
|
||||
|
||||
await cloudflareClient.deleteRecord(config, "zone-1", "r1");
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("/zones/zone-1/dns_records/r1");
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.testConnection", () => {
|
||||
it("succeeds when the token can list zones", async () => {
|
||||
mockFetch.mockResolvedValue(cfSuccess([]));
|
||||
await expect(
|
||||
cloudflareClient.testConnection(config),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces Cloudflare's error message on an invalid token", async () => {
|
||||
mockFetch.mockResolvedValue(cfError("Invalid API Token"));
|
||||
|
||||
await expect(cloudflareClient.testConnection(config)).rejects.toThrow(
|
||||
"Invalid API Token",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient auth header", () => {
|
||||
it("trims whitespace pasted into the token", async () => {
|
||||
mockFetch.mockResolvedValue(cfSuccess([]));
|
||||
|
||||
await cloudflareClient.testConnection({
|
||||
providerType: "cloudflare",
|
||||
apiToken: " cf-token\n",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer cf-token",
|
||||
);
|
||||
});
|
||||
});
|
||||
116
apps/dokploy/__test__/dns/dns-provider-service.test.ts
Normal file
116
apps/dokploy/__test__/dns/dns-provider-service.test.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: { dnsProvider: { findFirst: vi.fn(), findMany: vi.fn() } },
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
DNS_SECRET_MASK,
|
||||
maskDnsProviderConfig,
|
||||
mergeDnsProviderConfig,
|
||||
} from "@dokploy/server/services/dns-provider";
|
||||
|
||||
describe("maskDnsProviderConfig", () => {
|
||||
it("masks the apiToken for a cloudflare config", () => {
|
||||
const masked = maskDnsProviderConfig({
|
||||
providerType: "cloudflare",
|
||||
apiToken: "real-token",
|
||||
});
|
||||
|
||||
expect(masked).toEqual({
|
||||
providerType: "cloudflare",
|
||||
apiToken: DNS_SECRET_MASK,
|
||||
});
|
||||
});
|
||||
|
||||
it("masks only the secretAccessKey for a route53 config, keeping accessKeyId visible", () => {
|
||||
const masked = maskDnsProviderConfig({
|
||||
providerType: "route53",
|
||||
accessKeyId: "AKIA_VISIBLE",
|
||||
secretAccessKey: "shh",
|
||||
});
|
||||
|
||||
expect(masked).toEqual({
|
||||
providerType: "route53",
|
||||
accessKeyId: "AKIA_VISIBLE",
|
||||
secretAccessKey: DNS_SECRET_MASK,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an empty sensitive field untouched instead of masking a blank value", () => {
|
||||
const masked = maskDnsProviderConfig({
|
||||
providerType: "cloudflare",
|
||||
apiToken: "",
|
||||
});
|
||||
|
||||
expect(masked).toEqual({ providerType: "cloudflare", apiToken: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeDnsProviderConfig", () => {
|
||||
it("restores the real secret when the incoming config still has the mask placeholder", () => {
|
||||
const existing = {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: "real-token",
|
||||
};
|
||||
const incoming = {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: DNS_SECRET_MASK,
|
||||
};
|
||||
|
||||
expect(mergeDnsProviderConfig(incoming, existing)).toEqual(existing);
|
||||
});
|
||||
|
||||
it("keeps a freshly entered secret instead of the stored one", () => {
|
||||
const existing = {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: "old-token",
|
||||
};
|
||||
const incoming = {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: "new-token",
|
||||
};
|
||||
|
||||
expect(mergeDnsProviderConfig(incoming, existing)).toEqual(incoming);
|
||||
});
|
||||
|
||||
it("throws when switching provider type while the field is still masked", () => {
|
||||
const existing = {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: "real-token",
|
||||
};
|
||||
const incoming = {
|
||||
providerType: "route53" as const,
|
||||
accessKeyId: "AKIA",
|
||||
secretAccessKey: DNS_SECRET_MASK,
|
||||
};
|
||||
|
||||
expect(() => mergeDnsProviderConfig(incoming, existing)).toThrow(
|
||||
"Credentials must be re-entered",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not require re-entry for fields that are not sensitive", () => {
|
||||
const existing = {
|
||||
providerType: "route53" as const,
|
||||
accessKeyId: "AKIA_OLD",
|
||||
secretAccessKey: "old-secret",
|
||||
};
|
||||
const incoming = {
|
||||
providerType: "route53" as const,
|
||||
accessKeyId: "AKIA_NEW",
|
||||
secretAccessKey: DNS_SECRET_MASK,
|
||||
};
|
||||
|
||||
expect(mergeDnsProviderConfig(incoming, existing)).toEqual({
|
||||
providerType: "route53",
|
||||
accessKeyId: "AKIA_NEW",
|
||||
secretAccessKey: "old-secret",
|
||||
});
|
||||
});
|
||||
});
|
||||
288
apps/dokploy/__test__/dns/route53.test.ts
Normal file
288
apps/dokploy/__test__/dns/route53.test.ts
Normal file
@ -0,0 +1,288 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type HasInput = { input: any };
|
||||
|
||||
const {
|
||||
send,
|
||||
Route53Client,
|
||||
ListHostedZonesCommand,
|
||||
ListResourceRecordSetsCommand,
|
||||
ChangeResourceRecordSetsCommand,
|
||||
} = vi.hoisted(() => {
|
||||
class FakeCommand {
|
||||
input: any;
|
||||
constructor(input: any) {
|
||||
this.input = input;
|
||||
}
|
||||
}
|
||||
const send = vi.fn();
|
||||
class Route53Client {
|
||||
send(command: unknown) {
|
||||
return send(command);
|
||||
}
|
||||
}
|
||||
return {
|
||||
send,
|
||||
Route53Client,
|
||||
ListHostedZonesCommand: class extends FakeCommand {},
|
||||
ListResourceRecordSetsCommand: class extends FakeCommand {},
|
||||
ChangeResourceRecordSetsCommand: class extends FakeCommand {},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@aws-sdk/client-route-53", () => ({
|
||||
Route53Client,
|
||||
ListHostedZonesCommand,
|
||||
ListResourceRecordSetsCommand,
|
||||
ChangeResourceRecordSetsCommand,
|
||||
}));
|
||||
|
||||
import { route53Client } from "@dokploy/server/utils/dns/route53";
|
||||
|
||||
const config = {
|
||||
providerType: "route53" as const,
|
||||
accessKeyId: "AKIA_TEST",
|
||||
secretAccessKey: "secret",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
send.mockReset();
|
||||
});
|
||||
|
||||
describe("route53Client.listZones", () => {
|
||||
it("strips the hostedzone prefix and the trailing dot", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
HostedZones: [{ Id: "/hostedzone/Z123", Name: "example.com." }],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const zones = await route53Client.listZones(config);
|
||||
|
||||
expect(zones).toEqual([{ id: "Z123", name: "example.com" }]);
|
||||
});
|
||||
|
||||
it("follows the marker until IsTruncated is false", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({
|
||||
HostedZones: [{ Id: "/hostedzone/Z1", Name: "a.com." }],
|
||||
IsTruncated: true,
|
||||
NextMarker: "marker-1",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
HostedZones: [{ Id: "/hostedzone/Z2", Name: "b.com." }],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const zones = await route53Client.listZones(config);
|
||||
|
||||
expect(zones).toEqual([
|
||||
{ id: "Z1", name: "a.com" },
|
||||
{ id: "Z2", name: "b.com" },
|
||||
]);
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect((send.mock.calls[1]?.[0] as HasInput).input.Marker).toBe("marker-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.listRecords", () => {
|
||||
it("builds a type:name id and strips the trailing dot from the name", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||
},
|
||||
],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "A:app.example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 300,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{ Name: "alias.example.com.", Type: "A", ResourceRecords: [] },
|
||||
],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records).toEqual([]);
|
||||
});
|
||||
|
||||
it("joins multiple values for the same record", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "example.com.",
|
||||
Type: "NS",
|
||||
TTL: 172800,
|
||||
ResourceRecords: [
|
||||
{ Value: "ns1.example.com" },
|
||||
{ Value: "ns2.example.com" },
|
||||
],
|
||||
},
|
||||
],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.upsertRecord", () => {
|
||||
it("sends a single UPSERT change", async () => {
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
const result = await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "A:app.example.com" });
|
||||
const command = send.mock.calls[0]?.[0] as HasInput;
|
||||
expect(command.input.HostedZoneId).toBe("Z123");
|
||||
expect(command.input.ChangeBatch.Changes).toEqual([
|
||||
{
|
||||
Action: "UPSERT",
|
||||
ResourceRecordSet: {
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.updateRecord", () => {
|
||||
it("only UPSERTs when the name/type identity did not change", async () => {
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.updateRecord(config, "Z123", "A:app.example.com", {
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "9.9.9.9",
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
const command = send.mock.calls[0]?.[0] as HasInput;
|
||||
expect(command.input.ChangeBatch.Changes).toHaveLength(1);
|
||||
expect(command.input.ChangeBatch.Changes[0].Action).toBe("UPSERT");
|
||||
});
|
||||
|
||||
it("deletes the old record set and creates the new one on rename", async () => {
|
||||
const existingSet = {
|
||||
Name: "old.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.1.1.1" }],
|
||||
};
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] }) // findExactRecordSet lookup
|
||||
.mockResolvedValueOnce({}); // ChangeResourceRecordSets
|
||||
|
||||
await route53Client.updateRecord(config, "Z123", "A:old.example.com", {
|
||||
type: "A",
|
||||
name: "new.example.com",
|
||||
content: "1.1.1.1",
|
||||
});
|
||||
|
||||
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
|
||||
{ Action: "DELETE", ResourceRecordSet: existingSet },
|
||||
{
|
||||
Action: "UPSERT",
|
||||
ResourceRecordSet: {
|
||||
Name: "new.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.1.1.1" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips the DELETE when the old record no longer exists", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.updateRecord(config, "Z123", "A:gone.example.com", {
|
||||
type: "A",
|
||||
name: "new.example.com",
|
||||
content: "1.1.1.1",
|
||||
});
|
||||
|
||||
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(changeCommand.input.ChangeBatch.Changes).toHaveLength(1);
|
||||
expect(changeCommand.input.ChangeBatch.Changes[0].Action).toBe("UPSERT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.deleteRecord", () => {
|
||||
it("deletes the record set found by name/type", async () => {
|
||||
const existingSet = {
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||
};
|
||||
send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] });
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.deleteRecord(config, "Z123", "A:app.example.com");
|
||||
|
||||
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
|
||||
{ Action: "DELETE", ResourceRecordSet: existingSet },
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws when the record no longer exists", async () => {
|
||||
send.mockResolvedValueOnce({ ResourceRecordSets: [] });
|
||||
|
||||
await expect(
|
||||
route53Client.deleteRecord(config, "Z123", "A:gone.example.com"),
|
||||
).rejects.toThrow("not found");
|
||||
});
|
||||
|
||||
it("throws for a malformed record id", async () => {
|
||||
await expect(
|
||||
route53Client.deleteRecord(config, "Z123", "not-a-valid-id"),
|
||||
).rejects.toThrow("Invalid Route53 record id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.testConnection", () => {
|
||||
it("resolves when ListHostedZones succeeds", async () => {
|
||||
send.mockResolvedValueOnce({ HostedZones: [] });
|
||||
await expect(route53Client.testConnection(config)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("propagates SDK errors", async () => {
|
||||
send.mockRejectedValueOnce(new Error("InvalidClientTokenId"));
|
||||
await expect(route53Client.testConnection(config)).rejects.toThrow(
|
||||
"InvalidClientTokenId",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,361 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const providerLabels = {
|
||||
cloudflare: "Cloudflare",
|
||||
route53: "AWS Route53",
|
||||
} as const;
|
||||
|
||||
type ProviderType = keyof typeof providerLabels;
|
||||
|
||||
const DnsProviderSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: "Name is required" })
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, {
|
||||
message: "Only letters, numbers, dashes and underscores",
|
||||
}),
|
||||
providerType: z.enum(["cloudflare", "route53"]),
|
||||
apiToken: z.string(),
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
endpoint: z.string(),
|
||||
});
|
||||
|
||||
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
||||
|
||||
const defaultValues: DnsProviderForm = {
|
||||
name: "",
|
||||
providerType: "cloudflare",
|
||||
apiToken: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
endpoint: "",
|
||||
};
|
||||
|
||||
const buildConfig = (data: DnsProviderForm) => {
|
||||
switch (data.providerType) {
|
||||
case "cloudflare":
|
||||
return {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: data.apiToken,
|
||||
};
|
||||
case "route53":
|
||||
return {
|
||||
providerType: "route53" as const,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
endpoint: data.endpoint || undefined,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const extractErrorMessage = (err: unknown) => {
|
||||
if (!(err instanceof Error)) return undefined;
|
||||
try {
|
||||
const issues = JSON.parse(err.message) as { message?: string }[];
|
||||
if (Array.isArray(issues)) {
|
||||
return issues
|
||||
.map((issue) => issue.message)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
} catch {}
|
||||
return err.message;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
dnsProviderId?: string;
|
||||
}
|
||||
|
||||
export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data: provider } = api.dnsProvider.one.useQuery(
|
||||
{ dnsProviderId: dnsProviderId || "" },
|
||||
{ enabled: !!dnsProviderId && isOpen },
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending, error, isError } = dnsProviderId
|
||||
? api.dnsProvider.update.useMutation()
|
||||
: api.dnsProvider.create.useMutation();
|
||||
|
||||
const { mutateAsync: testConnection, isPending: isTesting } =
|
||||
api.dnsProvider.testConnection.useMutation();
|
||||
|
||||
const form = useForm<DnsProviderForm>({
|
||||
defaultValues,
|
||||
resolver: zodResolver(DnsProviderSchema),
|
||||
});
|
||||
|
||||
const providerType = form.watch("providerType");
|
||||
|
||||
useEffect(() => {
|
||||
if (provider) {
|
||||
form.reset({
|
||||
...defaultValues,
|
||||
name: provider.name,
|
||||
providerType: provider.config.providerType,
|
||||
...(provider.config.providerType === "cloudflare" && {
|
||||
apiToken: provider.config.apiToken,
|
||||
}),
|
||||
...(provider.config.providerType === "route53" && {
|
||||
accessKeyId: provider.config.accessKeyId,
|
||||
secretAccessKey: provider.config.secretAccessKey,
|
||||
endpoint: provider.config.endpoint ?? "",
|
||||
}),
|
||||
});
|
||||
} else if (!dnsProviderId) {
|
||||
form.reset(defaultValues);
|
||||
}
|
||||
}, [provider, dnsProviderId, form, isOpen]);
|
||||
|
||||
const onSubmit = async (data: DnsProviderForm) => {
|
||||
const payload: any = {
|
||||
name: data.name,
|
||||
config: buildConfig(data),
|
||||
...(dnsProviderId && { dnsProviderId }),
|
||||
};
|
||||
await mutateAsync(payload)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
dnsProviderId ? "DNS provider updated" : "DNS provider created",
|
||||
);
|
||||
utils.dnsProvider.all.invalidate();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const onTestConnection = async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
const data = form.getValues();
|
||||
await testConnection({
|
||||
config: buildConfig(data),
|
||||
...(dnsProviderId && { dnsProviderId }),
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Connection successful");
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error("Connection failed", {
|
||||
description: extractErrorMessage(err),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{dnsProviderId ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
>
|
||||
<PenBoxIcon className="size-4 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="cursor-pointer space-x-3">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Provider
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{dnsProviderId ? "Update DNS Provider" : "Add DNS Provider"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Connect a DNS provider to create records for your domains
|
||||
automatically instead of setting them up by hand.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="prod-cloudflare" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
disabled={!!dnsProviderId}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(providerLabels).map(([value, label]) => {
|
||||
const ProviderIcon =
|
||||
dnsProviderIcons[value as ProviderType];
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ProviderIcon className="size-4 shrink-0" />
|
||||
{label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{providerType === "cloudflare" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Create a token scoped to Zone → DNS → Edit for the zones
|
||||
you want Dokploy to manage. Avoid the Global API Key.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{providerType === "route53" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accessKeyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Key ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secretAccessKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Access Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use an IAM user/role scoped to{" "}
|
||||
<code>route53:ListHostedZones</code>,{" "}
|
||||
<code>route53:ListResourceRecordSets</code> and{" "}
|
||||
<code>route53:ChangeResourceRecordSets</code> — avoid
|
||||
root account credentials.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="http://localhost:4566" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Only for a LocalStack/API-compatible emulator. Leave
|
||||
empty to use AWS.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
isLoading={isTesting}
|
||||
onClick={onTestConnection}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{dnsProviderId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,264 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const DnsRecordSchema = z.object({
|
||||
type: z.enum(["A", "CNAME"]),
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
content: z.string().min(1, { message: "Content is required" }),
|
||||
ttl: z.string(),
|
||||
});
|
||||
|
||||
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
|
||||
|
||||
interface DnsRecordValue {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dnsProviderId: string;
|
||||
zoneId: string;
|
||||
record?: DnsRecordValue;
|
||||
}
|
||||
|
||||
export const HandleDnsRecord = ({ dnsProviderId, zoneId, record }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { mutateAsync, isPending, error, isError } = record
|
||||
? api.dnsProvider.updateRecord.useMutation()
|
||||
: api.dnsProvider.createRecord.useMutation();
|
||||
|
||||
const { data: servers } = api.server.all.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
const { data: panelPublicIp } = api.server.publicIp.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
const { data: panelStoredIp } = api.settings.getIp.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const panelIp = panelPublicIp || panelStoredIp;
|
||||
const ipSuggestions = [
|
||||
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
|
||||
...(servers ?? []).map((server) => ({
|
||||
ip: server.ipAddress,
|
||||
label: server.name,
|
||||
})),
|
||||
].filter(
|
||||
(suggestion, index, all) =>
|
||||
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
|
||||
);
|
||||
|
||||
const form = useForm<DnsRecordForm>({
|
||||
defaultValues: record
|
||||
? {
|
||||
type: record.type === "CNAME" ? "CNAME" : "A",
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
|
||||
}
|
||||
: { type: "A", name: "", content: "", ttl: "" },
|
||||
resolver: zodResolver(DnsRecordSchema),
|
||||
});
|
||||
|
||||
const type = form.watch("type");
|
||||
|
||||
const onSubmit = async (data: DnsRecordForm) => {
|
||||
const payload = {
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
type: data.type,
|
||||
name: data.name,
|
||||
content: data.content,
|
||||
ttl: data.ttl ? Number(data.ttl) : undefined,
|
||||
...(record && { recordId: record.id }),
|
||||
};
|
||||
await mutateAsync(payload as any)
|
||||
.then(() => {
|
||||
toast.success(record ? "Record updated" : "Record created");
|
||||
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{record ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 size-6"
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm">
|
||||
<PlusIcon className="size-3.5 mr-1.5" />
|
||||
Add Record
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{record ? "Edit Record" : "Add Record"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{record
|
||||
? "Update this DNS record."
|
||||
: "Create a new A or CNAME record in this zone."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="A">A</SelectItem>
|
||||
<SelectItem value="CNAME">CNAME</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="app.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use <code>@</code> for the root domain.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{type === "A" && ipSuggestions.length > 0 && (
|
||||
<FormItem>
|
||||
<FormLabel>Fill from server (optional)</FormLabel>
|
||||
<Select
|
||||
onValueChange={(ip) =>
|
||||
form.setValue("content", ip, { shouldValidate: true })
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{ipSuggestions.map((suggestion) => (
|
||||
<SelectItem key={suggestion.ip} value={suggestion.ip}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>{suggestion.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{suggestion.ip}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{type === "A" ? "IPv4 Address" : "Target"}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
type === "A" ? "203.0.113.10" : "app.example.com"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ttl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>TTL (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="Auto" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{record ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,211 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
Loader2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleDnsRecord } from "./handle-dns-record";
|
||||
|
||||
interface ZoneRecordsProps {
|
||||
dnsProviderId: string;
|
||||
zoneId: string;
|
||||
}
|
||||
|
||||
const ZoneRecords = ({ dnsProviderId, zoneId }: ZoneRecordsProps) => {
|
||||
const utils = api.useUtils();
|
||||
const { data, isLoading, isError, error } =
|
||||
api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId });
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { mutateAsync: deleteRecord, isPending: isDeleting } =
|
||||
api.dnsProvider.deleteRecord.useMutation();
|
||||
|
||||
const canWrite = !!permissions?.dnsProvider.update;
|
||||
const canDelete = !!permissions?.dnsProvider.delete;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pl-8 pb-2 pr-3">
|
||||
{isLoading && (
|
||||
<div className="flex flex-row gap-2 items-center text-sm text-muted-foreground py-3">
|
||||
<span>Loading records...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<p className="text-sm text-destructive py-2">{error?.message}</p>
|
||||
)}
|
||||
{data && data.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No records found in this zone.
|
||||
</p>
|
||||
)}
|
||||
{data?.map((record) => {
|
||||
const isEditable = record.type === "A" || record.type === "CNAME";
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs"
|
||||
>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{record.type}
|
||||
</Badge>
|
||||
<span className="font-medium truncate">{record.name}</span>
|
||||
<span className="text-muted-foreground truncate flex-1">
|
||||
→ {record.content}
|
||||
</span>
|
||||
{canWrite && isEditable && (
|
||||
<HandleDnsRecord
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zoneId}
|
||||
record={record}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete Record"
|
||||
description={`Delete the ${record.type} record "${record.name}"? This removes it from the DNS provider, not just from Dokploy.`}
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteRecord({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
recordId: record.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Record deleted");
|
||||
utils.dnsProvider.listRecords.invalidate({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting the record");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 size-6"
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{canWrite && (
|
||||
<div className="pt-1">
|
||||
<HandleDnsRecord dnsProviderId={dnsProviderId} zoneId={zoneId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
dnsProviderId: string;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
export const ShowDnsProviderZones = ({
|
||||
dnsProviderId,
|
||||
providerName,
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [expandedZoneId, setExpandedZoneId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isError, error } =
|
||||
api.dnsProvider.listZones.useQuery({ dnsProviderId }, { enabled: isOpen });
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
setExpandedZoneId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Globe className="size-3.5 mr-1.5" />
|
||||
View Domains
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Domains for {providerName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Zones this provider's API token can manage. Click a zone to see and
|
||||
manage its records.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isLoading && (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground py-6">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<p className="text-sm text-destructive py-2">{error?.message}</p>
|
||||
)}
|
||||
{data && data.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No zones found for this token. Make sure it has access to at least
|
||||
one zone.
|
||||
</p>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 max-h-[60vh] overflow-y-auto">
|
||||
{data.map((zone) => {
|
||||
const isExpanded = expandedZoneId === zone.id;
|
||||
return (
|
||||
<div key={zone.id} className="rounded-md border">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left"
|
||||
onClick={() =>
|
||||
setExpandedZoneId(isExpanded ? null : zone.id)
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-3.5 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<Globe className="size-3.5 text-muted-foreground shrink-0" />
|
||||
{zone.name}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<ZoneRecords
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zone.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,145 @@
|
||||
import { Globe, Loader2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleDnsProvider } from "./handle-dns-provider";
|
||||
import { ShowDnsProviderZones } from "./show-dns-provider-zones";
|
||||
|
||||
const providerLabels: Record<string, string> = {
|
||||
cloudflare: "Cloudflare",
|
||||
route53: "AWS Route53",
|
||||
};
|
||||
|
||||
export const ShowDnsProviders = () => {
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.dnsProvider.remove.useMutation();
|
||||
const { data, isPending, refetch } = api.dnsProvider.all.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Globe className="size-6 text-muted-foreground self-center" />
|
||||
DNS Providers
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Connect a DNS provider so Dokploy can create the A/CNAME record
|
||||
for a domain instead of you setting it up by hand.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
{isPending ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<Globe className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any DNS providers configured
|
||||
</span>
|
||||
{permissions?.dnsProvider.create && <HandleDnsProvider />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
<div className="flex flex-col gap-4 rounded-lg">
|
||||
{data?.map((provider) => {
|
||||
const ProviderIcon =
|
||||
dnsProviderIcons[provider.providerType];
|
||||
return (
|
||||
<div
|
||||
key={provider.dnsProviderId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<ProviderIcon className="size-7 shrink-0" />
|
||||
<div className="flex gap-2 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{provider.name}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{providerLabels[provider.providerType] ??
|
||||
provider.providerType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1">
|
||||
<ShowDnsProviderZones
|
||||
dnsProviderId={provider.dnsProviderId}
|
||||
providerName={provider.name}
|
||||
/>
|
||||
{permissions?.dnsProvider.update && (
|
||||
<HandleDnsProvider
|
||||
dnsProviderId={provider.dnsProviderId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.dnsProvider.delete && (
|
||||
<DialogAction
|
||||
title="Delete DNS Provider"
|
||||
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
dnsProviderId: provider.dnsProviderId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("DNS provider deleted");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting the DNS provider",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{permissions?.dnsProvider.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleDnsProvider />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
40
apps/dokploy/components/icons/dns-provider-icons.tsx
Normal file
40
apps/dokploy/components/icons/dns-provider-icons.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CloudflareIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Route53Icon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 304 182"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m86 66 2 9c0 3 1 5 3 8v2l-1 3-7 4-2 1-3-1-4-5-3-6c-8 9-18 14-29 14-9 0-16-3-20-8-5-4-8-11-8-19s3-15 9-20c6-6 14-8 25-8a79 79 0 0 1 22 3v-7c0-8-2-13-5-16-3-4-8-5-16-5l-11 1a80 80 0 0 0-14 5h-2c-1 0-2-1-2-3v-5l1-3c0-1 1-2 3-2l12-5 16-2c12 0 20 3 26 8 5 6 8 14 8 25v32zM46 82l10-2c4-1 7-4 10-7l3-6 1-9v-4a84 84 0 0 0-19-2c-6 0-11 1-15 4-3 2-4 6-4 11s1 8 3 11c3 2 6 4 11 4zm80 10-4-1-2-3-23-78-1-4 2-2h10l4 1 2 4 17 66 15-66 2-4 4-1h8l4 1 2 4 16 67 17-67 2-4 4-1h9c2 0 3 1 3 2v2l-1 2-24 78-2 4-4 1h-9l-4-1-1-4-16-65-15 64-2 4-4 1h-9zm129 3a66 66 0 0 1-27-6l-3-3-1-2v-5c0-2 1-3 2-3h2l3 1a54 54 0 0 0 23 5c6 0 11-2 14-4 4-2 5-5 5-9l-2-7-10-5-15-5c-7-2-13-6-16-10a24 24 0 0 1 5-34l10-5a44 44 0 0 1 20-2 110 110 0 0 1 12 3l4 2 3 2 1 4v4c0 3-1 4-2 4l-4-2c-6-2-12-3-19-3-6 0-11 0-14 2s-4 5-4 9c0 3 1 5 3 7s5 4 11 6l14 4c7 3 12 6 15 10s5 9 5 14l-3 12-7 8c-3 3-7 5-11 6l-14 2z"
|
||||
/>
|
||||
<path
|
||||
d="M274 144A220 220 0 0 1 4 124c-4-3-1-6 2-4a300 300 0 0 0 263 16c5-2 10 4 5 8z"
|
||||
fill="#f90"
|
||||
/>
|
||||
<path
|
||||
d="M287 128c-4-5-28-3-38-1-4 0-4-3-1-5 19-13 50-9 53-5 4 5-1 36-18 51-3 2-6 1-5-2 5-10 13-33 9-38z"
|
||||
fill="#f90"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const dnsProviderIcons = {
|
||||
cloudflare: CloudflareIcon,
|
||||
route53: Route53Icon,
|
||||
} as const;
|
||||
@ -19,6 +19,7 @@ import {
|
||||
Forward,
|
||||
GalleryVerticalEnd,
|
||||
GitBranch,
|
||||
Globe,
|
||||
House,
|
||||
Key,
|
||||
KeyRound,
|
||||
@ -370,6 +371,13 @@ const MENU: Menu = {
|
||||
icon: Vault,
|
||||
isEnabled: ({ permissions }) => !!permissions?.vaultProvider.create,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "DNS Providers",
|
||||
url: "/dashboard/settings/dns",
|
||||
icon: Globe,
|
||||
isEnabled: ({ permissions }) => !!permissions?.dnsProvider.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "S3 Destinations",
|
||||
|
||||
@ -175,6 +175,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
|
||||
description:
|
||||
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway) and where their secrets can be referenced",
|
||||
},
|
||||
dnsProvider: {
|
||||
label: "DNS Providers",
|
||||
description:
|
||||
"Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records",
|
||||
},
|
||||
};
|
||||
|
||||
/** Descriptions for each action within a resource */
|
||||
@ -447,6 +452,25 @@ const ACTION_META: Record<
|
||||
},
|
||||
delete: { label: "Delete", description: "Remove secret providers" },
|
||||
},
|
||||
dnsProvider: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View configured DNS providers and their zones/records",
|
||||
},
|
||||
create: {
|
||||
label: "Create",
|
||||
description:
|
||||
"Connect new DNS providers, test their connection, and create records",
|
||||
},
|
||||
update: {
|
||||
label: "Update",
|
||||
description: "Edit provider credentials and update existing records",
|
||||
},
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Remove DNS providers and delete their records",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Resources that should be hidden from the custom role editor (better-auth internals) */
|
||||
|
||||
12
apps/dokploy/drizzle/0185_needy_kingpin.sql
Normal file
12
apps/dokploy/drizzle/0185_needy_kingpin.sql
Normal file
@ -0,0 +1,12 @@
|
||||
CREATE TYPE "public"."DnsProviderType" AS ENUM('cloudflare', 'route53');--> statement-breakpoint
|
||||
CREATE TABLE "dns_provider" (
|
||||
"dnsProviderId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"providerType" "DnsProviderType" NOT NULL,
|
||||
"config" jsonb NOT NULL,
|
||||
"organizationId" text NOT NULL,
|
||||
"createdAt" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "dns_provider" ADD CONSTRAINT "dns_provider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "dns_provider_org_name_idx" ON "dns_provider" USING btree ("organizationId","name");
|
||||
9139
apps/dokploy/drizzle/meta/0185_snapshot.json
Normal file
9139
apps/dokploy/drizzle/meta/0185_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1296,6 +1296,13 @@
|
||||
"when": 1786500135945,
|
||||
"tag": "0184_wonderful_absorbing_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 185,
|
||||
"version": "7",
|
||||
"when": 1786526512677,
|
||||
"tag": "0185_needy_kingpin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -40,7 +40,6 @@
|
||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-secrets-manager": "^3.1097.0",
|
||||
"@ai-sdk/anthropic": "^3.0.44",
|
||||
"@ai-sdk/azure": "^3.0.30",
|
||||
"@ai-sdk/cohere": "^3.0.21",
|
||||
@ -48,6 +47,8 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@aws-sdk/client-route-53": "^3.1108.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.1097.0",
|
||||
"@better-auth/api-key": "1.6.23",
|
||||
"@better-auth/passkey": "1.6.23",
|
||||
"@better-auth/scim": "1.6.23",
|
||||
|
||||
53
apps/dokploy/pages/dashboard/settings/dns.tsx
Normal file
53
apps/dokploy/pages/dashboard/settings/dns.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowDnsProviders } from "@/components/dashboard/settings/dns/show-dns-providers";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<ShowDnsProviders />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="DNS Providers">{page}</DashboardLayout>;
|
||||
};
|
||||
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
const { req, res } = ctx;
|
||||
const { user, session } = await validateRequest(req);
|
||||
if (!user || user.role === "member") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
await helpers.user.get.prefetch();
|
||||
await helpers.settings.isCloud.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { clusterRouter } from "./routers/cluster";
|
||||
import { composeRouter } from "./routers/compose";
|
||||
import { deploymentRouter } from "./routers/deployment";
|
||||
import { destinationRouter } from "./routers/destination";
|
||||
import { dnsProviderRouter } from "./routers/dns-provider";
|
||||
import { dockerRouter } from "./routers/docker";
|
||||
import { dockerVolumeRouter } from "./routers/docker-volume";
|
||||
import { domainRouter } from "./routers/domain";
|
||||
@ -69,6 +70,7 @@ export const appRouter = createTRPCRouter({
|
||||
compose: composeRouter,
|
||||
deployment: deploymentRouter,
|
||||
destination: destinationRouter,
|
||||
dnsProvider: dnsProviderRouter,
|
||||
docker: dockerRouter,
|
||||
dockerVolume: dockerVolumeRouter,
|
||||
domain: domainRouter,
|
||||
|
||||
222
apps/dokploy/server/api/routers/dns-provider.ts
Normal file
222
apps/dokploy/server/api/routers/dns-provider.ts
Normal file
@ -0,0 +1,222 @@
|
||||
import {
|
||||
createDnsProvider,
|
||||
createDnsProviderRecord,
|
||||
deleteDnsProviderRecord,
|
||||
findDnsProviderInOrganization,
|
||||
findDnsProvidersByOrganizationId,
|
||||
listDnsProviderRecords,
|
||||
listDnsProviderZones,
|
||||
maskDnsProviderConfig,
|
||||
mergeDnsProviderConfig,
|
||||
removeDnsProvider,
|
||||
testDnsProviderConnection,
|
||||
updateDnsProvider,
|
||||
updateDnsProviderRecord,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import {
|
||||
apiCreateDnsProvider,
|
||||
apiCreateDnsRecord,
|
||||
apiDeleteDnsRecord,
|
||||
apiFindOneDnsProvider,
|
||||
apiListDnsRecords,
|
||||
apiListDnsZones,
|
||||
apiRemoveDnsProvider,
|
||||
apiTestDnsProvider,
|
||||
apiUpdateDnsProvider,
|
||||
apiUpdateDnsRecord,
|
||||
} from "@/server/db/schema";
|
||||
import { createTRPCRouter, withPermission } from "../trpc";
|
||||
|
||||
export const dnsProviderRouter = createTRPCRouter({
|
||||
create: withPermission("dnsProvider", "create")
|
||||
.input(apiCreateDnsProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const provider = await createDnsProvider(
|
||||
input,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "dnsProvider",
|
||||
resourceId: provider.dnsProviderId,
|
||||
resourceName: provider.name,
|
||||
});
|
||||
return { ...provider, config: maskDnsProviderConfig(provider.config) };
|
||||
}),
|
||||
|
||||
update: withPermission("dnsProvider", "update")
|
||||
.input(apiUpdateDnsProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await findDnsProviderInOrganization(
|
||||
input.dnsProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
const updated = await updateDnsProvider(
|
||||
input.dnsProviderId,
|
||||
input.name,
|
||||
input.config,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "dnsProvider",
|
||||
resourceId: updated.dnsProviderId,
|
||||
resourceName: updated.name,
|
||||
});
|
||||
return { ...updated, config: maskDnsProviderConfig(updated.config) };
|
||||
}),
|
||||
|
||||
remove: withPermission("dnsProvider", "delete")
|
||||
.input(apiRemoveDnsProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const provider = await findDnsProviderInOrganization(
|
||||
input.dnsProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "dnsProvider",
|
||||
resourceId: provider.dnsProviderId,
|
||||
resourceName: provider.name,
|
||||
});
|
||||
await removeDnsProvider(input.dnsProviderId);
|
||||
return true;
|
||||
}),
|
||||
|
||||
all: withPermission("dnsProvider", "read").query(async ({ ctx }) => {
|
||||
const providers = await findDnsProvidersByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return providers.map((provider) => ({
|
||||
...provider,
|
||||
config: maskDnsProviderConfig(provider.config),
|
||||
}));
|
||||
}),
|
||||
|
||||
one: withPermission("dnsProvider", "read")
|
||||
.input(apiFindOneDnsProvider)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const provider = await findDnsProviderInOrganization(
|
||||
input.dnsProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return { ...provider, config: maskDnsProviderConfig(provider.config) };
|
||||
}),
|
||||
|
||||
testConnection: withPermission("dnsProvider", "create")
|
||||
.input(apiTestDnsProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!input.config && !input.dnsProviderId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Provide a config or a dnsProviderId to test",
|
||||
});
|
||||
}
|
||||
|
||||
let config = input.config;
|
||||
if (input.dnsProviderId) {
|
||||
const provider = await findDnsProviderInOrganization(
|
||||
input.dnsProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
config = config
|
||||
? mergeDnsProviderConfig(config, provider.config)
|
||||
: provider.config;
|
||||
}
|
||||
|
||||
await testDnsProviderConnection(config!);
|
||||
return true;
|
||||
}),
|
||||
|
||||
listZones: withPermission("dnsProvider", "read")
|
||||
.input(apiListDnsZones)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const provider = await findDnsProviderInOrganization(
|
||||
input.dnsProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return await listDnsProviderZones(provider.config);
|
||||
}),
|
||||
|
||||
listRecords: withPermission("dnsProvider", "read")
|
||||
.input(apiListDnsRecords)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const provider = await findDnsProviderInOrganization(
|
||||
input.dnsProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return await listDnsProviderRecords(provider.config, input.zoneId);
|
||||
}),
|
||||
|
||||
createRecord: withPermission("dnsProvider", "update")
|
||||
.input(apiCreateDnsRecord)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const provider = await findDnsProviderInOrganization(
|
||||
input.dnsProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
const record = await createDnsProviderRecord(provider.config, {
|
||||
zoneId: input.zoneId,
|
||||
type: input.type,
|
||||
name: input.name,
|
||||
content: input.content,
|
||||
ttl: input.ttl,
|
||||
});
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "dnsProvider",
|
||||
resourceId: record.id,
|
||||
resourceName: input.name,
|
||||
});
|
||||
return record;
|
||||
}),
|
||||
|
||||
updateRecord: withPermission("dnsProvider", "update")
|
||||
.input(apiUpdateDnsRecord)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const provider = await findDnsProviderInOrganization(
|
||||
input.dnsProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
const record = await updateDnsProviderRecord(
|
||||
provider.config,
|
||||
input.zoneId,
|
||||
input.recordId,
|
||||
{
|
||||
type: input.type,
|
||||
name: input.name,
|
||||
content: input.content,
|
||||
ttl: input.ttl,
|
||||
},
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "dnsProvider",
|
||||
resourceId: record.id,
|
||||
resourceName: input.name,
|
||||
});
|
||||
return record;
|
||||
}),
|
||||
|
||||
deleteRecord: withPermission("dnsProvider", "delete")
|
||||
.input(apiDeleteDnsRecord)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const provider = await findDnsProviderInOrganization(
|
||||
input.dnsProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await deleteDnsProviderRecord(
|
||||
provider.config,
|
||||
input.zoneId,
|
||||
input.recordId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "dnsProvider",
|
||||
resourceId: input.recordId,
|
||||
resourceName: input.zoneId,
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
@ -37,6 +37,7 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@aws-sdk/client-route-53": "^3.1108.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.1097.0",
|
||||
"@better-auth/api-key": "1.6.23",
|
||||
"@better-auth/passkey": "1.6.23",
|
||||
|
||||
@ -93,4 +93,5 @@ export type AuditResourceType =
|
||||
| "application"
|
||||
| "compose"
|
||||
| "network"
|
||||
| "vaultProvider";
|
||||
| "vaultProvider"
|
||||
| "dnsProvider";
|
||||
|
||||
122
packages/server/src/db/schema/dns-provider.ts
Normal file
122
packages/server/src/db/schema/dns-provider.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { jsonb, pgEnum, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
|
||||
export const dnsProviderType = pgEnum("DnsProviderType", [
|
||||
"cloudflare",
|
||||
"route53",
|
||||
]);
|
||||
|
||||
export const cloudflareDnsConfigSchema = z.object({
|
||||
providerType: z.literal("cloudflare"),
|
||||
apiToken: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const route53DnsConfigSchema = z.object({
|
||||
providerType: z.literal("route53"),
|
||||
accessKeyId: z.string().trim().min(1),
|
||||
secretAccessKey: z.string().trim().min(1),
|
||||
endpoint: z.string().trim().url().optional(),
|
||||
});
|
||||
|
||||
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
cloudflareDnsConfigSchema,
|
||||
route53DnsConfigSchema,
|
||||
]);
|
||||
|
||||
export type DnsProviderConfig = z.infer<typeof dnsProviderConfigSchema>;
|
||||
|
||||
export const dnsProvider = pgTable(
|
||||
"dns_provider",
|
||||
{
|
||||
dnsProviderId: text("dnsProviderId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
providerType: dnsProviderType("providerType").notNull(),
|
||||
config: jsonb("config").$type<DnsProviderConfig>().notNull(),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("dns_provider_org_name_idx").on(
|
||||
table.organizationId,
|
||||
table.name,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const dnsProviderNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9_-]+$/,
|
||||
"Name can only contain letters, numbers, dashes and underscores",
|
||||
);
|
||||
|
||||
const createSchema = createInsertSchema(dnsProvider);
|
||||
|
||||
export const apiCreateDnsProvider = createSchema.pick({}).extend({
|
||||
name: dnsProviderNameSchema,
|
||||
config: dnsProviderConfigSchema,
|
||||
});
|
||||
|
||||
export const apiUpdateDnsProvider = createSchema.pick({}).extend({
|
||||
dnsProviderId: z.string().min(1),
|
||||
name: dnsProviderNameSchema,
|
||||
config: dnsProviderConfigSchema,
|
||||
});
|
||||
|
||||
export const apiFindOneDnsProvider = z.object({
|
||||
dnsProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiRemoveDnsProvider = z.object({
|
||||
dnsProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiTestDnsProvider = z.object({
|
||||
dnsProviderId: z.string().min(1).optional(),
|
||||
config: dnsProviderConfigSchema.optional(),
|
||||
});
|
||||
|
||||
export const apiListDnsZones = z.object({
|
||||
dnsProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiListDnsRecords = z.object({
|
||||
dnsProviderId: z.string().min(1),
|
||||
zoneId: z.string().min(1),
|
||||
});
|
||||
|
||||
const dnsRecordFieldsSchema = z.object({
|
||||
type: z.enum(["A", "CNAME"]),
|
||||
name: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
ttl: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateDnsRecord = dnsRecordFieldsSchema.extend({
|
||||
dnsProviderId: z.string().min(1),
|
||||
zoneId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiUpdateDnsRecord = dnsRecordFieldsSchema.extend({
|
||||
dnsProviderId: z.string().min(1),
|
||||
zoneId: z.string().min(1),
|
||||
recordId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiDeleteDnsRecord = z.object({
|
||||
dnsProviderId: z.string().min(1),
|
||||
zoneId: z.string().min(1),
|
||||
recordId: z.string().min(1),
|
||||
});
|
||||
@ -8,6 +8,7 @@ export * from "./certificate";
|
||||
export * from "./compose";
|
||||
export * from "./deployment";
|
||||
export * from "./destination";
|
||||
export * from "./dns-provider";
|
||||
export * from "./domain";
|
||||
export * from "./environment";
|
||||
export * from "./forward-auth";
|
||||
|
||||
@ -16,6 +16,7 @@ export * from "./services/cluster";
|
||||
export * from "./services/compose";
|
||||
export * from "./services/deployment";
|
||||
export * from "./services/destination";
|
||||
export * from "./services/dns-provider";
|
||||
export * from "./services/docker";
|
||||
export * from "./services/docker-volume";
|
||||
export * from "./services/domain";
|
||||
|
||||
@ -49,6 +49,7 @@ export const statements = {
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@ -76,6 +77,7 @@ export const enterpriseOnlyResources = new Set<string>([
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
"dnsProvider",
|
||||
]);
|
||||
|
||||
export const ac = createAccessControl(statements);
|
||||
@ -116,6 +118,7 @@ export const ownerRole = ac.newRole({
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@ -154,6 +157,7 @@ export const adminRole = ac.newRole({
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@ -198,4 +202,6 @@ export const memberRole = ac.newRole({
|
||||
auditLog: [],
|
||||
// Members need provider/secret names for env editor autocomplete; values are never exposed
|
||||
vaultProvider: ["read"],
|
||||
// Members can see configured DNS providers to pick one when adding a domain
|
||||
dnsProvider: ["read"],
|
||||
});
|
||||
|
||||
278
packages/server/src/services/dns-provider.ts
Normal file
278
packages/server/src/services/dns-provider.ts
Normal file
@ -0,0 +1,278 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreateDnsProvider,
|
||||
type DnsProviderConfig,
|
||||
dnsProvider,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import type { DnsRecordInput } from "@dokploy/server/utils/dns";
|
||||
import { getDnsClient } from "@dokploy/server/utils/dns";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
|
||||
export type DnsProvider = typeof dnsProvider.$inferSelect;
|
||||
|
||||
export const DNS_SECRET_MASK = "********";
|
||||
|
||||
const SENSITIVE_FIELDS: Record<DnsProviderConfig["providerType"], string[]> = {
|
||||
cloudflare: ["apiToken"],
|
||||
route53: ["secretAccessKey"],
|
||||
};
|
||||
|
||||
export const maskDnsProviderConfig = (
|
||||
config: DnsProviderConfig,
|
||||
): DnsProviderConfig => {
|
||||
const masked: Record<string, unknown> = { ...config };
|
||||
for (const field of SENSITIVE_FIELDS[config.providerType]) {
|
||||
if (masked[field]) {
|
||||
masked[field] = DNS_SECRET_MASK;
|
||||
}
|
||||
}
|
||||
return masked as DnsProviderConfig;
|
||||
};
|
||||
|
||||
export const mergeDnsProviderConfig = (
|
||||
incoming: DnsProviderConfig,
|
||||
existing: DnsProviderConfig,
|
||||
): DnsProviderConfig => {
|
||||
const merged: Record<string, unknown> = { ...incoming };
|
||||
for (const field of SENSITIVE_FIELDS[incoming.providerType]) {
|
||||
if (merged[field] === DNS_SECRET_MASK) {
|
||||
if (incoming.providerType !== existing.providerType) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Credentials must be re-entered when changing the provider type",
|
||||
});
|
||||
}
|
||||
merged[field] = (existing as Record<string, unknown>)[field];
|
||||
}
|
||||
}
|
||||
return merged as DnsProviderConfig;
|
||||
};
|
||||
|
||||
const isUniqueNameViolation = (error: unknown) =>
|
||||
error instanceof Error && error.message.includes("dns_provider_org_name_idx");
|
||||
|
||||
export const createDnsProvider = async (
|
||||
input: z.infer<typeof apiCreateDnsProvider>,
|
||||
organizationId: string,
|
||||
) => {
|
||||
try {
|
||||
const newProvider = await db
|
||||
.insert(dnsProvider)
|
||||
.values({
|
||||
name: input.name,
|
||||
providerType: input.config.providerType,
|
||||
config: input.config,
|
||||
organizationId,
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
if (!newProvider) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error creating the DNS provider",
|
||||
});
|
||||
}
|
||||
return newProvider;
|
||||
} catch (error) {
|
||||
if (isUniqueNameViolation(error)) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `A DNS provider named "${input.name}" already exists in this organization`,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const findDnsProviderById = async (dnsProviderId: string) => {
|
||||
const provider = await db.query.dnsProvider.findFirst({
|
||||
where: eq(dnsProvider.dnsProviderId, dnsProviderId),
|
||||
});
|
||||
if (!provider) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "DNS provider not found",
|
||||
});
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const findDnsProviderInOrganization = async (
|
||||
dnsProviderId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
const provider = await findDnsProviderById(dnsProviderId);
|
||||
if (provider.organizationId !== organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not allowed to access this DNS provider",
|
||||
});
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const findDnsProvidersByOrganizationId = async (
|
||||
organizationId: string,
|
||||
) => {
|
||||
return await db.query.dnsProvider.findMany({
|
||||
where: eq(dnsProvider.organizationId, organizationId),
|
||||
orderBy: (providers, { asc }) => [asc(providers.name)],
|
||||
});
|
||||
};
|
||||
|
||||
export const updateDnsProvider = async (
|
||||
dnsProviderId: string,
|
||||
name: string,
|
||||
config: DnsProviderConfig,
|
||||
) => {
|
||||
const existing = await findDnsProviderById(dnsProviderId);
|
||||
const mergedConfig = mergeDnsProviderConfig(config, existing.config);
|
||||
|
||||
try {
|
||||
const updated = await db
|
||||
.update(dnsProvider)
|
||||
.set({
|
||||
name,
|
||||
providerType: mergedConfig.providerType,
|
||||
config: mergedConfig,
|
||||
})
|
||||
.where(eq(dnsProvider.dnsProviderId, dnsProviderId))
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
if (!updated) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error updating the DNS provider",
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (isUniqueNameViolation(error)) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `A DNS provider named "${name}" already exists in this organization`,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeDnsProvider = async (dnsProviderId: string) => {
|
||||
const removed = await db
|
||||
.delete(dnsProvider)
|
||||
.where(eq(dnsProvider.dnsProviderId, dnsProviderId))
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
if (!removed) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "DNS provider not found",
|
||||
});
|
||||
}
|
||||
return removed;
|
||||
};
|
||||
|
||||
export const testDnsProviderConnection = async (config: DnsProviderConfig) => {
|
||||
const client = getDnsClient(config.providerType);
|
||||
try {
|
||||
await client.testConnection(config);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error connecting to the DNS provider",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const listDnsProviderZones = async (config: DnsProviderConfig) => {
|
||||
const client = getDnsClient(config.providerType);
|
||||
try {
|
||||
return await client.listZones(config);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error listing zones for this DNS provider",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const listDnsProviderRecords = async (
|
||||
config: DnsProviderConfig,
|
||||
zoneId: string,
|
||||
) => {
|
||||
const client = getDnsClient(config.providerType);
|
||||
try {
|
||||
return await client.listRecords(config, zoneId);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error listing records for this zone",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createDnsProviderRecord = async (
|
||||
config: DnsProviderConfig,
|
||||
record: DnsRecordInput,
|
||||
) => {
|
||||
const client = getDnsClient(config.providerType);
|
||||
try {
|
||||
return await client.upsertRecord(config, record);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Error creating the record",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateDnsProviderRecord = async (
|
||||
config: DnsProviderConfig,
|
||||
zoneId: string,
|
||||
recordId: string,
|
||||
record: Omit<DnsRecordInput, "zoneId">,
|
||||
) => {
|
||||
const client = getDnsClient(config.providerType);
|
||||
try {
|
||||
return await client.updateRecord(config, zoneId, recordId, record);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Error updating the record",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteDnsProviderRecord = async (
|
||||
config: DnsProviderConfig,
|
||||
zoneId: string,
|
||||
recordId: string,
|
||||
) => {
|
||||
const client = getDnsClient(config.providerType);
|
||||
try {
|
||||
await client.deleteRecord(config, zoneId, recordId);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Error deleting the record",
|
||||
});
|
||||
}
|
||||
};
|
||||
143
packages/server/src/utils/dns/cloudflare.ts
Normal file
143
packages/server/src/utils/dns/cloudflare.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import type { cloudflareDnsConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type DnsClient, dnsFetch } from "./types";
|
||||
|
||||
type CloudflareConfig = z.infer<typeof cloudflareDnsConfigSchema>;
|
||||
|
||||
type CloudflareResponse<T> = {
|
||||
success: boolean;
|
||||
errors: { code: number; message: string }[];
|
||||
result: T;
|
||||
result_info?: { page: number; per_page: number; total_pages: number };
|
||||
};
|
||||
|
||||
const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4";
|
||||
|
||||
const cfFetch = async <T>(
|
||||
config: CloudflareConfig,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> => {
|
||||
const response = await dnsFetch(`${CLOUDFLARE_API}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiToken.trim()}`,
|
||||
"Content-Type": "application/json",
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
const body = (await response.json()) as CloudflareResponse<T>;
|
||||
if (!response.ok || !body.success) {
|
||||
const detail = body.errors?.map((e) => e.message).join(", ");
|
||||
throw new Error(
|
||||
`Cloudflare: request to ${path} failed${detail ? `: ${detail}` : ` (status ${response.status})`}`,
|
||||
);
|
||||
}
|
||||
return body.result;
|
||||
};
|
||||
|
||||
export const cloudflareClient: DnsClient<CloudflareConfig> = {
|
||||
async listZones(config) {
|
||||
const zones: { id: string; name: string }[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const result = await cfFetch<{ id: string; name: string }[]>(
|
||||
config,
|
||||
`/zones?per_page=50&page=${page}`,
|
||||
);
|
||||
zones.push(...result.map((zone) => ({ id: zone.id, name: zone.name })));
|
||||
if (result.length < 50) {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
return zones;
|
||||
},
|
||||
|
||||
async listRecords(config, zoneId) {
|
||||
const records: {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
}[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const result = await cfFetch<
|
||||
{
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
}[]
|
||||
>(config, `/zones/${zoneId}/dns_records?per_page=50&page=${page}`);
|
||||
records.push(...result);
|
||||
if (result.length < 50) {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
return records;
|
||||
},
|
||||
|
||||
async upsertRecord(config, record) {
|
||||
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,
|
||||
content: record.content,
|
||||
ttl: record.ttl ?? 1,
|
||||
};
|
||||
|
||||
const existingRecord = existing[0];
|
||||
if (existingRecord) {
|
||||
const updated = await cfFetch<{ id: string }>(
|
||||
config,
|
||||
`/zones/${record.zoneId}/dns_records/${existingRecord.id}`,
|
||||
{ method: "PUT", body: JSON.stringify(payload) },
|
||||
);
|
||||
return { id: updated.id };
|
||||
}
|
||||
|
||||
const created = await cfFetch<{ id: string }>(
|
||||
config,
|
||||
`/zones/${record.zoneId}/dns_records`,
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
);
|
||||
return { id: created.id };
|
||||
},
|
||||
|
||||
async updateRecord(config, zoneId, recordId, record) {
|
||||
const updated = await cfFetch<{ id: string }>(
|
||||
config,
|
||||
`/zones/${zoneId}/dns_records/${recordId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
ttl: record.ttl ?? 1,
|
||||
}),
|
||||
},
|
||||
);
|
||||
return { id: updated.id };
|
||||
},
|
||||
|
||||
async deleteRecord(config, zoneId, recordId) {
|
||||
await cfFetch(config, `/zones/${zoneId}/dns_records/${recordId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
await cfFetch(config, "/zones?per_page=1");
|
||||
},
|
||||
};
|
||||
14
packages/server/src/utils/dns/index.ts
Normal file
14
packages/server/src/utils/dns/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
|
||||
import { cloudflareClient } from "./cloudflare";
|
||||
import { route53Client } from "./route53";
|
||||
import type { DnsClient } from "./types";
|
||||
|
||||
const clients: Record<DnsProviderConfig["providerType"], DnsClient> = {
|
||||
cloudflare: cloudflareClient as DnsClient,
|
||||
route53: route53Client as DnsClient,
|
||||
};
|
||||
|
||||
export const getDnsClient = (providerType: DnsProviderConfig["providerType"]) =>
|
||||
clients[providerType];
|
||||
|
||||
export * from "./types";
|
||||
216
packages/server/src/utils/dns/route53.ts
Normal file
216
packages/server/src/utils/dns/route53.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import {
|
||||
ChangeResourceRecordSetsCommand,
|
||||
ListHostedZonesCommand,
|
||||
ListResourceRecordSetsCommand,
|
||||
type ResourceRecordSet,
|
||||
Route53Client,
|
||||
} from "@aws-sdk/client-route-53";
|
||||
import type { route53DnsConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import type { DnsClient } from "./types";
|
||||
|
||||
type Route53Config = z.infer<typeof route53DnsConfigSchema>;
|
||||
|
||||
const createClient = (config: Route53Config) =>
|
||||
new Route53Client({
|
||||
region: "us-east-1",
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
...(config.endpoint && { endpoint: config.endpoint }),
|
||||
});
|
||||
|
||||
const stripTrailingDot = (name: string) => name.replace(/\.$/, "");
|
||||
const ensureTrailingDot = (name: string) =>
|
||||
name.endsWith(".") ? name : `${name}.`;
|
||||
const stripZonePrefix = (id: string) => id.replace(/^\/hostedzone\//, "");
|
||||
|
||||
const buildRecordId = (type: string, name: string) =>
|
||||
`${type}:${stripTrailingDot(name)}`;
|
||||
|
||||
const parseRecordId = (id: string) => {
|
||||
const separatorIndex = id.indexOf(":");
|
||||
if (separatorIndex === -1) {
|
||||
throw new Error(`Invalid Route53 record id: "${id}"`);
|
||||
}
|
||||
return {
|
||||
type: id.slice(0, separatorIndex),
|
||||
name: id.slice(separatorIndex + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const findExactRecordSet = async (
|
||||
config: Route53Config,
|
||||
zoneId: string,
|
||||
type: string,
|
||||
name: string,
|
||||
): Promise<ResourceRecordSet | undefined> => {
|
||||
const client = createClient(config);
|
||||
const response = await client.send(
|
||||
new ListResourceRecordSetsCommand({
|
||||
HostedZoneId: zoneId,
|
||||
StartRecordName: ensureTrailingDot(name),
|
||||
StartRecordType: type as ResourceRecordSet["Type"],
|
||||
MaxItems: 1,
|
||||
}),
|
||||
);
|
||||
const candidate = response.ResourceRecordSets?.[0];
|
||||
if (
|
||||
candidate &&
|
||||
candidate.Type === type &&
|
||||
stripTrailingDot(candidate.Name ?? "") === stripTrailingDot(name)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const buildRecordSet = (record: {
|
||||
type: "A" | "CNAME";
|
||||
name: string;
|
||||
content: string;
|
||||
ttl?: number;
|
||||
}): ResourceRecordSet => ({
|
||||
Name: ensureTrailingDot(record.name),
|
||||
Type: record.type,
|
||||
TTL: record.ttl ?? 300,
|
||||
ResourceRecords: [{ Value: record.content }],
|
||||
});
|
||||
|
||||
export const route53Client: DnsClient<Route53Config> = {
|
||||
async listZones(config) {
|
||||
const client = createClient(config);
|
||||
const zones: { id: string; name: string }[] = [];
|
||||
let marker: string | undefined;
|
||||
do {
|
||||
const response = await client.send(
|
||||
new ListHostedZonesCommand({ Marker: marker, MaxItems: 100 }),
|
||||
);
|
||||
for (const zone of response.HostedZones ?? []) {
|
||||
if (zone.Id && zone.Name) {
|
||||
zones.push({
|
||||
id: stripZonePrefix(zone.Id),
|
||||
name: stripTrailingDot(zone.Name),
|
||||
});
|
||||
}
|
||||
}
|
||||
marker = response.IsTruncated ? response.NextMarker : undefined;
|
||||
} while (marker);
|
||||
return zones;
|
||||
},
|
||||
|
||||
async listRecords(config, zoneId) {
|
||||
const client = createClient(config);
|
||||
const records: {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
}[] = [];
|
||||
let nextName: string | undefined;
|
||||
let nextType: string | undefined;
|
||||
do {
|
||||
const response = await client.send(
|
||||
new ListResourceRecordSetsCommand({
|
||||
HostedZoneId: zoneId,
|
||||
StartRecordName: nextName,
|
||||
StartRecordType: nextType as ResourceRecordSet["Type"] | undefined,
|
||||
}),
|
||||
);
|
||||
for (const set of response.ResourceRecordSets ?? []) {
|
||||
if (!set.Name || !set.Type || !set.ResourceRecords?.length) {
|
||||
continue;
|
||||
}
|
||||
records.push({
|
||||
id: buildRecordId(set.Type, set.Name),
|
||||
type: set.Type,
|
||||
name: stripTrailingDot(set.Name),
|
||||
content: set.ResourceRecords.map((r) => r.Value).join(", "),
|
||||
ttl: set.TTL ?? 300,
|
||||
});
|
||||
}
|
||||
nextName = response.IsTruncated ? response.NextRecordName : undefined;
|
||||
nextType = response.IsTruncated ? response.NextRecordType : undefined;
|
||||
} while (nextName);
|
||||
return records;
|
||||
},
|
||||
|
||||
async upsertRecord(config, record) {
|
||||
const client = createClient(config);
|
||||
await client.send(
|
||||
new ChangeResourceRecordSetsCommand({
|
||||
HostedZoneId: record.zoneId,
|
||||
ChangeBatch: {
|
||||
Changes: [
|
||||
{ Action: "UPSERT", ResourceRecordSet: buildRecordSet(record) },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { id: buildRecordId(record.type, record.name) };
|
||||
},
|
||||
|
||||
async updateRecord(config, zoneId, recordIdInput, record) {
|
||||
const { type: oldType, name: oldName } = parseRecordId(recordIdInput);
|
||||
const client = createClient(config);
|
||||
|
||||
const changes: {
|
||||
Action: "DELETE" | "UPSERT";
|
||||
ResourceRecordSet: ResourceRecordSet;
|
||||
}[] = [];
|
||||
|
||||
const sameIdentity =
|
||||
oldType === record.type &&
|
||||
stripTrailingDot(oldName) === stripTrailingDot(record.name);
|
||||
|
||||
if (!sameIdentity) {
|
||||
const existing = await findExactRecordSet(
|
||||
config,
|
||||
zoneId,
|
||||
oldType,
|
||||
oldName,
|
||||
);
|
||||
if (existing) {
|
||||
changes.push({ Action: "DELETE", ResourceRecordSet: existing });
|
||||
}
|
||||
}
|
||||
|
||||
changes.push({
|
||||
Action: "UPSERT",
|
||||
ResourceRecordSet: buildRecordSet(record),
|
||||
});
|
||||
|
||||
await client.send(
|
||||
new ChangeResourceRecordSetsCommand({
|
||||
HostedZoneId: zoneId,
|
||||
ChangeBatch: { Changes: changes },
|
||||
}),
|
||||
);
|
||||
|
||||
return { id: buildRecordId(record.type, record.name) };
|
||||
},
|
||||
|
||||
async deleteRecord(config, zoneId, recordIdInput) {
|
||||
const { type, name } = parseRecordId(recordIdInput);
|
||||
const existing = await findExactRecordSet(config, zoneId, type, name);
|
||||
if (!existing) {
|
||||
throw new Error(`Route53: record "${name}" (${type}) not found`);
|
||||
}
|
||||
const client = createClient(config);
|
||||
await client.send(
|
||||
new ChangeResourceRecordSetsCommand({
|
||||
HostedZoneId: zoneId,
|
||||
ChangeBatch: {
|
||||
Changes: [{ Action: "DELETE", ResourceRecordSet: existing }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
const client = createClient(config);
|
||||
await client.send(new ListHostedZonesCommand({ MaxItems: 1 }));
|
||||
},
|
||||
};
|
||||
45
packages/server/src/utils/dns/types.ts
Normal file
45
packages/server/src/utils/dns/types.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
|
||||
|
||||
export interface DnsZone {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DnsRecordInput {
|
||||
zoneId: string;
|
||||
type: "A" | "CNAME";
|
||||
name: string;
|
||||
content: string;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export interface DnsRecord {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
export interface DnsClient<C extends DnsProviderConfig = DnsProviderConfig> {
|
||||
listZones(config: C): Promise<DnsZone[]>;
|
||||
listRecords(config: C, zoneId: string): Promise<DnsRecord[]>;
|
||||
upsertRecord(config: C, record: DnsRecordInput): Promise<{ id: string }>;
|
||||
updateRecord(
|
||||
config: C,
|
||||
zoneId: string,
|
||||
recordId: string,
|
||||
record: Omit<DnsRecordInput, "zoneId">,
|
||||
): Promise<{ id: string }>;
|
||||
deleteRecord(config: C, zoneId: string, recordId: string): Promise<void>;
|
||||
testConnection(config: C): Promise<void>;
|
||||
}
|
||||
|
||||
export const DNS_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
export const dnsFetch = async (url: string, init: RequestInit = {}) => {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(DNS_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
};
|
||||
312
pnpm-lock.yaml
312
pnpm-lock.yaml
@ -115,6 +115,9 @@ importers:
|
||||
'@ai-sdk/openai-compatible':
|
||||
specifier: ^2.0.30
|
||||
version: 2.0.30(zod@4.3.6)
|
||||
'@aws-sdk/client-route-53':
|
||||
specifier: ^3.1108.0
|
||||
version: 3.1108.0
|
||||
'@aws-sdk/client-secrets-manager':
|
||||
specifier: ^3.1097.0
|
||||
version: 3.1097.0
|
||||
@ -585,6 +588,9 @@ importers:
|
||||
'@ai-sdk/openai-compatible':
|
||||
specifier: ^2.0.30
|
||||
version: 2.0.30(zod@4.3.6)
|
||||
'@aws-sdk/client-route-53':
|
||||
specifier: ^3.1108.0
|
||||
version: 3.1108.0
|
||||
'@aws-sdk/client-secrets-manager':
|
||||
specifier: ^3.1097.0
|
||||
version: 3.1097.0
|
||||
@ -877,6 +883,10 @@ packages:
|
||||
resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@aws-sdk/client-route-53@3.1108.0':
|
||||
resolution: {integrity: sha512-fbHGq25KlbqSIEQC3T+3ghsXmnpknjU3VxMgTOmqbKAqTLVB0ZLhxzPOxKuJFTd5m5mzSh9lCAZR5Nnlg+V9kQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-secrets-manager@3.1097.0':
|
||||
resolution: {integrity: sha512-EAyknLfabMWA1kAVgcBO6iTYBWFVnoX8WRrKEbPf6npNZUKv7oJoZBK+I0OvRK0o3P0ynbey64pDlspWXB9dag==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@ -884,15 +894,34 @@ packages:
|
||||
'@aws-sdk/core@3.977.2':
|
||||
resolution: {integrity: sha512-8sT/M5vDcagx5/iM0Bfx7f6i3mfVOQkA34+GTMwp0lIWZb6ma+bjkzDS/r9yqU2yTPBqqMBFPT3+d9kUuuNDJA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
deprecated: |-
|
||||
Deprecated due to Document number parsing bug in JSON, see
|
||||
https://github.com/aws/aws-sdk-js-v3/issues/8246. Newer version available.
|
||||
|
||||
'@aws-sdk/core@3.977.7':
|
||||
resolution: {integrity: sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.63':
|
||||
resolution: {integrity: sha512-VSS9dftt7r7GiZ4gs8z0PNaMLVAaSj/MXVr6WQBtsrQQB9miJo7I6lQuJND1/ugFwK9x7OHCYZDkLSYh0FIZtA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.68':
|
||||
resolution: {integrity: sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.65':
|
||||
resolution: {integrity: sha512-SH/ec7p1J0CfC28+ypH38IwGENd7tQEvTpmuRSlinthiGxKlwzJbXGXxIMAhn0/lpxnIxudNmCsw3Cy0PDRoAg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.70':
|
||||
resolution: {integrity: sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.13':
|
||||
resolution: {integrity: sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.8':
|
||||
resolution: {integrity: sha512-alkQpDUHsjHGVXvlV0XFXpPfh9+aTMmN6UYRky0Qky8SbvdxoQdDHftT4uugq8XShP6WtDQW7bo5YQ0SfNSxRQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@ -901,14 +930,30 @@ packages:
|
||||
resolution: {integrity: sha512-JlUjK6bYJAxN9PkWWCI/TiOYEdvXNKq61x2DTaEKxRMxAOYNk2LX8m4wVtDFxTZwyXx7Tpmxb49dNprkW/uqXQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.75':
|
||||
resolution: {integrity: sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.74':
|
||||
resolution: {integrity: sha512-V+7pzT0OzROL2uKcQ2+MpnfwKONvozYojmdn8RguAMX9o48gtSVvt+7aCkwWCH2thDXOnUPCN6qn4kiFDelZWA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.79':
|
||||
resolution: {integrity: sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.63':
|
||||
resolution: {integrity: sha512-lPt2oGMcvP3uPhhxX5EquHrzBI/ZgJce+CHKcOGZl2ZQAXLLSxu7k/Cgo0HIktyi9dmDFljbOkj4XAnXD93YVQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.68':
|
||||
resolution: {integrity: sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.12':
|
||||
resolution: {integrity: sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.7':
|
||||
resolution: {integrity: sha512-FR2b+7QNXP/q+eslVzrCjGKvso8Lcr/B18BvFyD2iLNhq42XSo+wnh8FfX6mtqgaVsL1vuB27uGXuY+xUTa7pg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@ -917,26 +962,54 @@ packages:
|
||||
resolution: {integrity: sha512-RWNTKGXRkzMJe8bgIAdlz9q0N97m7fThD9KOjBt2CSY+/xnIbrA1/Dnm/ZEz8ZeQ1Of5D+fLaPeoD3lGt6AU4Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.74':
|
||||
resolution: {integrity: sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-sdk-route53@3.972.24':
|
||||
resolution: {integrity: sha512-+z3OqGhqLA46vF5KbnjuygzSQo9A2cWsbuDy1rdrbWSPDCNT5CCRC+veIxRnqvxwpj/BY8P8XNz+WwCmX0e8rg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.37':
|
||||
resolution: {integrity: sha512-vfDmA6APjX1LWxvt6/zcAmTCgRXCj35M+bC9Ujmy40QxYs9Fa9bE7oblOB3ODZ4mdN9R5osU0hTzoJjJlQqqTg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.42':
|
||||
resolution: {integrity: sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.42':
|
||||
resolution: {integrity: sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.44':
|
||||
resolution: {integrity: sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1097.0':
|
||||
resolution: {integrity: sha512-EIsdmy/f5IGc5r01RjKWNvrbBra6z0xudQM0D6Wf8DeGuPoRlubkLqr7VgWijFucO4kg0mtev9H3RX/ZOubUhg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1108.0':
|
||||
resolution: {integrity: sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/types@3.974.2':
|
||||
resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/types@3.974.3':
|
||||
resolution: {integrity: sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.37':
|
||||
resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.38':
|
||||
resolution: {integrity: sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws/lambda-invoke-store@0.3.0':
|
||||
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@ -3870,14 +3943,30 @@ packages:
|
||||
resolution: {integrity: sha512-sylYk2l9d7CmRv8ts8p0SDQUr3VO+HMeS1nrjL6+UtbO8ktJHTOeQ1McX+aAyvGGccp5aZX9eNtdcXrSwzoZaw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/core@3.32.0':
|
||||
resolution: {integrity: sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/credential-provider-imds@4.4.15':
|
||||
resolution: {integrity: sha512-xYVGrisQqTJWhOnScUhbx8s9H63TMtoxzuUoxG6mP8J+B/YbX3vZxVsgV0xDf43abJnJP0fjP7BkQh7OESwuRA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/credential-provider-imds@4.5.0':
|
||||
resolution: {integrity: sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/fetch-http-handler@5.6.12':
|
||||
resolution: {integrity: sha512-OpQgP6IGH4j0NJ2zjfYZLjQL85ai+Wi/q51EmZJovXsEwKSvu89qiXUq77Q6EmwZ/hSl7fKpn2Z9mhiDN6OM+Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/fetch-http-handler@5.7.0':
|
||||
resolution: {integrity: sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/node-http-handler@4.10.0':
|
||||
resolution: {integrity: sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/node-http-handler@4.9.12':
|
||||
resolution: {integrity: sha512-dWW5KRt4mnEvjNzbGqGeCuAvgum85Y9ZoyuMQqcTEfapndyVJ1k9BEHK7kdXJZ32enyRmmwcFjMwlB/KgLKI3Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@ -3886,10 +3975,18 @@ packages:
|
||||
resolution: {integrity: sha512-7HsspeiNCZvZHEJ22vV5L/QYuJdTyJvPJvMrYD3AgkM3IJB0pkln4jkjPvtpTWRMkHXbO8WKwNjoVdVlBFwHmw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/signature-v4@5.7.0':
|
||||
resolution: {integrity: sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/types@4.16.1':
|
||||
resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/types@4.17.0':
|
||||
resolution: {integrity: sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@stablelib/base64@1.0.1':
|
||||
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||
|
||||
@ -9219,6 +9316,18 @@ snapshots:
|
||||
escape-html: 1.0.3
|
||||
xpath: 0.0.32
|
||||
|
||||
'@aws-sdk/client-route-53@3.1108.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
'@aws-sdk/credential-provider-node': 3.972.79
|
||||
'@aws-sdk/middleware-sdk-route53': 3.972.24
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/fetch-http-handler': 5.7.0
|
||||
'@smithy/node-http-handler': 4.10.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-secrets-manager@3.1097.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
@ -9241,6 +9350,17 @@ snapshots:
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/core@3.977.7':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@aws-sdk/xml-builder': 3.972.38
|
||||
'@aws/lambda-invoke-store': 0.3.0
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/signature-v4': 5.7.0
|
||||
'@smithy/types': 4.17.0
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.63':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
@ -9249,6 +9369,14 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.68':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.65':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
@ -9259,6 +9387,32 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.70':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/fetch-http-handler': 5.7.0
|
||||
'@smithy/node-http-handler': 4.10.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.13':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
'@aws-sdk/credential-provider-env': 3.972.68
|
||||
'@aws-sdk/credential-provider-http': 3.972.70
|
||||
'@aws-sdk/credential-provider-login': 3.972.75
|
||||
'@aws-sdk/credential-provider-process': 3.972.68
|
||||
'@aws-sdk/credential-provider-sso': 3.973.12
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.74
|
||||
'@aws-sdk/nested-clients': 3.997.42
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/credential-provider-imds': 4.5.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.8':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
@ -9277,11 +9431,20 @@ snapshots:
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.70':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
'@aws-sdk/core': 3.977.7
|
||||
'@aws-sdk/nested-clients': 3.997.37
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/types': 4.16.1
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.75':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
'@aws-sdk/nested-clients': 3.997.42
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.74':
|
||||
@ -9298,6 +9461,20 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.79':
|
||||
dependencies:
|
||||
'@aws-sdk/credential-provider-env': 3.972.68
|
||||
'@aws-sdk/credential-provider-http': 3.972.70
|
||||
'@aws-sdk/credential-provider-ini': 3.973.13
|
||||
'@aws-sdk/credential-provider-process': 3.972.68
|
||||
'@aws-sdk/credential-provider-sso': 3.973.12
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.74
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/credential-provider-imds': 4.5.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.63':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
@ -9306,6 +9483,24 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.68':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.12':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
'@aws-sdk/nested-clients': 3.997.42
|
||||
'@aws-sdk/token-providers': 3.1108.0
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.7':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
@ -9325,31 +9520,73 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.74':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
'@aws-sdk/nested-clients': 3.997.42
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-sdk-route53@3.972.24':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.37':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
'@aws-sdk/core': 3.977.7
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.42
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/fetch-http-handler': 5.6.12
|
||||
'@smithy/node-http-handler': 4.9.12
|
||||
'@smithy/types': 4.16.1
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/fetch-http-handler': 5.7.0
|
||||
'@smithy/node-http-handler': 4.10.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.42':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.44
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/fetch-http-handler': 5.7.0
|
||||
'@smithy/node-http-handler': 4.10.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.42':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/signature-v4': 5.6.11
|
||||
'@smithy/types': 4.16.1
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.44':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/signature-v4': 5.7.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1097.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
'@aws-sdk/core': 3.977.7
|
||||
'@aws-sdk/nested-clients': 3.997.37
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/types': 4.16.1
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1108.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
'@aws-sdk/nested-clients': 3.997.42
|
||||
'@aws-sdk/types': 3.974.3
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/types@3.974.2':
|
||||
@ -9357,11 +9594,21 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/types@3.974.3':
|
||||
dependencies:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.37':
|
||||
dependencies:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.38':
|
||||
dependencies:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws/lambda-invoke-store@0.3.0': {}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
@ -12668,18 +12915,41 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/core@3.32.0':
|
||||
dependencies:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/credential-provider-imds@4.4.15':
|
||||
dependencies:
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/credential-provider-imds@4.5.0':
|
||||
dependencies:
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/fetch-http-handler@5.6.12':
|
||||
dependencies:
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/fetch-http-handler@5.7.0':
|
||||
dependencies:
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@4.10.0':
|
||||
dependencies:
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@4.9.12':
|
||||
dependencies:
|
||||
'@smithy/core': 3.31.0
|
||||
@ -12692,10 +12962,20 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/signature-v4@5.7.0':
|
||||
dependencies:
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/types@4.16.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/types@4.17.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@stablelib/base64@1.0.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user