Skip to content

Commit ab87f2d

Browse files
authored
Merge pull request #5273 from pparage/fix/issue-4658-interface-addresses
fix(domains): include server interface addresses in validation
2 parents 3b065db + e79ed76 commit ab87f2d

2 files changed

Lines changed: 205 additions & 11 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import os from "node:os";
2+
import {
3+
getServerIpCandidates,
4+
validateDomain,
5+
} from "@dokploy/server/services/domain";
6+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7+
8+
const mocks = vi.hoisted(() => ({
9+
execAsyncRemote: vi.fn(),
10+
findServerById: vi.fn(),
11+
getPublicIpWithFallback: vi.fn(),
12+
getWebServerSettings: vi.fn(),
13+
resolve4: vi.fn(),
14+
resolve6: vi.fn(),
15+
}));
16+
17+
vi.mock("node:dns", () => ({
18+
default: {
19+
resolve4: mocks.resolve4,
20+
resolve6: mocks.resolve6,
21+
},
22+
}));
23+
24+
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
25+
execAsyncRemote: mocks.execAsyncRemote,
26+
}));
27+
28+
vi.mock("@dokploy/server/services/server", () => ({
29+
findServerById: mocks.findServerById,
30+
}));
31+
32+
vi.mock("@dokploy/server/services/web-server-settings", () => ({
33+
getWebServerSettings: mocks.getWebServerSettings,
34+
}));
35+
36+
vi.mock("@dokploy/server/wss/utils", () => ({
37+
getPublicIpWithFallback: mocks.getPublicIpWithFallback,
38+
}));
39+
40+
describe("getServerIpCandidates", () => {
41+
beforeEach(() => {
42+
vi.clearAllMocks();
43+
});
44+
45+
afterEach(() => {
46+
vi.restoreAllMocks();
47+
vi.useRealTimers();
48+
});
49+
50+
it("includes every address reported by a multi-homed remote server", async () => {
51+
mocks.findServerById.mockResolvedValue({
52+
ipAddress: "10.0.0.10",
53+
});
54+
mocks.execAsyncRemote.mockResolvedValue({
55+
stdout: ["10.0.0.10", "192.0.2.10", "2001:db8::10"].join("\n"),
56+
stderr: "",
57+
});
58+
59+
await expect(getServerIpCandidates("server-id")).resolves.toEqual([
60+
"10.0.0.10",
61+
"192.0.2.10",
62+
"2001:db8::10",
63+
]);
64+
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
65+
"server-id",
66+
expect.stringContaining("ip -o addr show scope global"),
67+
);
68+
});
69+
70+
it("includes every address assigned to the local Dokploy host", async () => {
71+
mocks.getWebServerSettings.mockResolvedValue({
72+
serverIp: "10.0.0.10",
73+
});
74+
mocks.getPublicIpWithFallback.mockResolvedValue("2001:db8::10");
75+
vi.spyOn(os, "networkInterfaces").mockReturnValue({
76+
eth0: [
77+
{
78+
address: "192.0.2.10",
79+
netmask: "255.255.255.0",
80+
family: "IPv4",
81+
mac: "00:00:00:00:00:00",
82+
internal: false,
83+
cidr: "192.0.2.10/24",
84+
},
85+
],
86+
});
87+
88+
await expect(getServerIpCandidates()).resolves.toEqual([
89+
"10.0.0.10",
90+
"192.0.2.10",
91+
"2001:db8::10",
92+
]);
93+
});
94+
95+
it("retains remote interface addresses when public IP detection times out", async () => {
96+
vi.useFakeTimers();
97+
mocks.findServerById.mockResolvedValue({
98+
ipAddress: "10.0.0.10",
99+
});
100+
mocks.execAsyncRemote.mockImplementation(
101+
(_serverId: string, command: string) => {
102+
if (command.includes("curl")) {
103+
return new Promise(() => undefined);
104+
}
105+
return Promise.resolve({
106+
stdout: "192.0.2.10\n",
107+
stderr: "",
108+
});
109+
},
110+
);
111+
112+
const candidatesPromise = getServerIpCandidates("server-id");
113+
await vi.advanceTimersByTimeAsync(7000);
114+
115+
await expect(candidatesPromise).resolves.toEqual([
116+
"10.0.0.10",
117+
"192.0.2.10",
118+
]);
119+
});
120+
});
121+
122+
describe("validateDomain", () => {
123+
afterEach(() => {
124+
vi.clearAllMocks();
125+
});
126+
127+
it("validates an IPv6-only domain against an IPv6 server address", async () => {
128+
const noIpv4 = Object.assign(new Error("queryA ENODATA example.com"), {
129+
code: "ENODATA",
130+
});
131+
mocks.resolve4.mockImplementation(
132+
(_domain: string, callback: (error: Error | null) => void) =>
133+
callback(noIpv4),
134+
);
135+
mocks.resolve6.mockImplementation(
136+
(
137+
_domain: string,
138+
callback: (error: Error | null, addresses?: string[]) => void,
139+
) => callback(null, ["2001:db8::10"]),
140+
);
141+
142+
await expect(
143+
validateDomain("example.com", ["2001:db8::10"]),
144+
).resolves.toMatchObject({
145+
isValid: true,
146+
resolvedIp: "2001:db8::10",
147+
});
148+
});
149+
});

‎packages/server/src/services/domain.ts‎

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import dns from "node:dns";
2+
import { isIP } from "node:net";
3+
import os from "node:os";
24
import { promisify } from "node:util";
35
import { db } from "@dokploy/server/db";
46
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
@@ -152,7 +154,27 @@ export const getDomainHost = (domain: Domain) => {
152154
return `${domain.https ? "https" : "http"}://${domain.host}`;
153155
};
154156

155-
const resolveDns = promisify(dns.resolve4);
157+
const resolveDns4 = promisify(dns.resolve4);
158+
const resolveDns6 = promisify(dns.resolve6);
159+
160+
const resolveDns = async (domain: string): Promise<string[]> => {
161+
const results = await Promise.allSettled([
162+
resolveDns4(domain),
163+
resolveDns6(domain),
164+
]);
165+
const ips = results.flatMap((result) =>
166+
result.status === "fulfilled" ? result.value : [],
167+
);
168+
169+
if (ips.length > 0) {
170+
return ips;
171+
}
172+
173+
const failure = results.find((result) => result.status === "rejected");
174+
throw failure?.reason instanceof Error
175+
? failure.reason
176+
: new Error("Failed to resolve domain");
177+
};
156178

157179
export const validateDomain = async (
158180
domain: string,
@@ -224,32 +246,55 @@ export const getServerIpCandidates = async (
224246
candidates.add(server.ipAddress);
225247
}
226248

227-
const publicIp = await withTimeout(
228-
execAsyncRemote(
229-
serverId,
230-
"curl -s -m 5 https://ifconfig.me || curl -s -m 5 https://icanhazip.com",
249+
const [interfaceIps, publicIp] = await Promise.all([
250+
withTimeout(
251+
execAsyncRemote(
252+
serverId,
253+
"ip -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1",
254+
),
255+
7000,
256+
),
257+
withTimeout(
258+
execAsyncRemote(
259+
serverId,
260+
"curl -fsS -m 5 https://ifconfig.me || curl -fsS -m 5 https://icanhazip.com",
261+
),
262+
7000,
231263
),
232-
7000,
233-
);
234-
const detectedIp = publicIp?.stdout?.trim();
235-
if (detectedIp) {
236-
candidates.add(detectedIp);
264+
]);
265+
for (const output of [interfaceIps?.stdout, publicIp?.stdout]) {
266+
for (const detectedIp of parseIpCandidates(output)) {
267+
candidates.add(detectedIp);
268+
}
237269
}
238270
} else {
239271
const settings = await getWebServerSettings();
240272
if (settings?.serverIp) {
241273
candidates.add(settings.serverIp);
242274
}
275+
for (const addresses of Object.values(os.networkInterfaces())) {
276+
for (const address of addresses ?? []) {
277+
if (!address.internal && isIP(address.address)) {
278+
candidates.add(address.address);
279+
}
280+
}
281+
}
243282

244283
const publicIp = await withTimeout(getPublicIpWithFallback(), 7000);
245-
if (publicIp) {
284+
if (publicIp && isIP(publicIp)) {
246285
candidates.add(publicIp);
247286
}
248287
}
249288

250289
return Array.from(candidates);
251290
};
252291

292+
const parseIpCandidates = (output?: string): string[] =>
293+
(output ?? "")
294+
.split(/\s+/)
295+
.map((candidate) => candidate.trim())
296+
.filter((candidate) => isIP(candidate) !== 0);
297+
253298
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
254299
return Promise.race([
255300
promise,

0 commit comments

Comments
 (0)