diff --git a/apps/dokploy/__test__/domains/domain-validation.test.ts b/apps/dokploy/__test__/domains/domain-validation.test.ts new file mode 100644 index 000000000..d9e3560c0 --- /dev/null +++ b/apps/dokploy/__test__/domains/domain-validation.test.ts @@ -0,0 +1,149 @@ +import os from "node:os"; +import { + getServerIpCandidates, + validateDomain, +} from "@dokploy/server/services/domain"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + execAsyncRemote: vi.fn(), + findServerById: vi.fn(), + getPublicIpWithFallback: vi.fn(), + getWebServerSettings: vi.fn(), + resolve4: vi.fn(), + resolve6: vi.fn(), +})); + +vi.mock("node:dns", () => ({ + default: { + resolve4: mocks.resolve4, + resolve6: mocks.resolve6, + }, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsyncRemote: mocks.execAsyncRemote, +})); + +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: mocks.findServerById, +})); + +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: mocks.getWebServerSettings, +})); + +vi.mock("@dokploy/server/wss/utils", () => ({ + getPublicIpWithFallback: mocks.getPublicIpWithFallback, +})); + +describe("getServerIpCandidates", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("includes every address reported by a multi-homed remote server", async () => { + mocks.findServerById.mockResolvedValue({ + ipAddress: "10.0.0.10", + }); + mocks.execAsyncRemote.mockResolvedValue({ + stdout: ["10.0.0.10", "192.0.2.10", "2001:db8::10"].join("\n"), + stderr: "", + }); + + await expect(getServerIpCandidates("server-id")).resolves.toEqual([ + "10.0.0.10", + "192.0.2.10", + "2001:db8::10", + ]); + expect(mocks.execAsyncRemote).toHaveBeenCalledWith( + "server-id", + expect.stringContaining("ip -o addr show scope global"), + ); + }); + + it("includes every address assigned to the local Dokploy host", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + serverIp: "10.0.0.10", + }); + mocks.getPublicIpWithFallback.mockResolvedValue("2001:db8::10"); + vi.spyOn(os, "networkInterfaces").mockReturnValue({ + eth0: [ + { + address: "192.0.2.10", + netmask: "255.255.255.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: false, + cidr: "192.0.2.10/24", + }, + ], + }); + + await expect(getServerIpCandidates()).resolves.toEqual([ + "10.0.0.10", + "192.0.2.10", + "2001:db8::10", + ]); + }); + + it("retains remote interface addresses when public IP detection times out", async () => { + vi.useFakeTimers(); + mocks.findServerById.mockResolvedValue({ + ipAddress: "10.0.0.10", + }); + mocks.execAsyncRemote.mockImplementation( + (_serverId: string, command: string) => { + if (command.includes("curl")) { + return new Promise(() => undefined); + } + return Promise.resolve({ + stdout: "192.0.2.10\n", + stderr: "", + }); + }, + ); + + const candidatesPromise = getServerIpCandidates("server-id"); + await vi.advanceTimersByTimeAsync(7000); + + await expect(candidatesPromise).resolves.toEqual([ + "10.0.0.10", + "192.0.2.10", + ]); + }); +}); + +describe("validateDomain", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("validates an IPv6-only domain against an IPv6 server address", async () => { + const noIpv4 = Object.assign(new Error("queryA ENODATA example.com"), { + code: "ENODATA", + }); + mocks.resolve4.mockImplementation( + (_domain: string, callback: (error: Error | null) => void) => + callback(noIpv4), + ); + mocks.resolve6.mockImplementation( + ( + _domain: string, + callback: (error: Error | null, addresses?: string[]) => void, + ) => callback(null, ["2001:db8::10"]), + ); + + await expect( + validateDomain("example.com", ["2001:db8::10"]), + ).resolves.toMatchObject({ + isValid: true, + resolvedIp: "2001:db8::10", + }); + }); +}); diff --git a/packages/server/src/services/domain.ts b/packages/server/src/services/domain.ts index a7833341b..e44db1ec0 100644 --- a/packages/server/src/services/domain.ts +++ b/packages/server/src/services/domain.ts @@ -1,4 +1,6 @@ import dns from "node:dns"; +import { isIP } from "node:net"; +import os from "node:os"; import { promisify } from "node:util"; import { db } from "@dokploy/server/db"; import { getWebServerSettings } from "@dokploy/server/services/web-server-settings"; @@ -152,7 +154,27 @@ export const getDomainHost = (domain: Domain) => { return `${domain.https ? "https" : "http"}://${domain.host}`; }; -const resolveDns = promisify(dns.resolve4); +const resolveDns4 = promisify(dns.resolve4); +const resolveDns6 = promisify(dns.resolve6); + +const resolveDns = async (domain: string): Promise => { + const results = await Promise.allSettled([ + resolveDns4(domain), + resolveDns6(domain), + ]); + const ips = results.flatMap((result) => + result.status === "fulfilled" ? result.value : [], + ); + + if (ips.length > 0) { + return ips; + } + + const failure = results.find((result) => result.status === "rejected"); + throw failure?.reason instanceof Error + ? failure.reason + : new Error("Failed to resolve domain"); +}; export const validateDomain = async ( domain: string, @@ -224,25 +246,42 @@ export const getServerIpCandidates = async ( candidates.add(server.ipAddress); } - const publicIp = await withTimeout( - execAsyncRemote( - serverId, - "curl -s -m 5 https://ifconfig.me || curl -s -m 5 https://icanhazip.com", + const [interfaceIps, publicIp] = await Promise.all([ + withTimeout( + execAsyncRemote( + serverId, + "ip -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1", + ), + 7000, ), - 7000, - ); - const detectedIp = publicIp?.stdout?.trim(); - if (detectedIp) { - candidates.add(detectedIp); + withTimeout( + execAsyncRemote( + serverId, + "curl -fsS -m 5 https://ifconfig.me || curl -fsS -m 5 https://icanhazip.com", + ), + 7000, + ), + ]); + for (const output of [interfaceIps?.stdout, publicIp?.stdout]) { + for (const detectedIp of parseIpCandidates(output)) { + candidates.add(detectedIp); + } } } else { const settings = await getWebServerSettings(); if (settings?.serverIp) { candidates.add(settings.serverIp); } + for (const addresses of Object.values(os.networkInterfaces())) { + for (const address of addresses ?? []) { + if (!address.internal && isIP(address.address)) { + candidates.add(address.address); + } + } + } const publicIp = await withTimeout(getPublicIpWithFallback(), 7000); - if (publicIp) { + if (publicIp && isIP(publicIp)) { candidates.add(publicIp); } } @@ -250,6 +289,12 @@ export const getServerIpCandidates = async ( return Array.from(candidates); }; +const parseIpCandidates = (output?: string): string[] => + (output ?? "") + .split(/\s+/) + .map((candidate) => candidate.trim()) + .filter((candidate) => isIP(candidate) !== 0); + const withTimeout = (promise: Promise, ms: number): Promise => { return Promise.race([ promise,