Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ describe("Host rule format regression tests", () => {
customEntrypoint: null,
middlewares: null,
forwardAuthEnabled: false,
validationMode: "auto",
expectedIp: null,
};

describe("Host rule format validation", () => {
Expand Down
2 changes: 2 additions & 0 deletions apps/dokploy/__test__/compose/domain/labels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ describe("createDomainLabels", () => {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
validationMode: "auto",
expectedIp: null,
};

it("should create basic labels for web entrypoint", async () => {
Expand Down
117 changes: 117 additions & 0 deletions apps/dokploy/__test__/domain/validate-domain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { validateDomain, validateDomainForServer } from "@dokploy/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

// Mutable holder so each test can control what the domain resolves to.
const dnsState = vi.hoisted(() => ({
addresses: [] as string[],
error: null as Error | null,
}));

vi.mock("node:dns", () => ({
default: {
resolve4: (
_host: string,
cb: (err: Error | null, addresses?: string[]) => void,
) => cb(dnsState.error, dnsState.addresses),
},
}));

beforeEach(() => {
dnsState.addresses = [];
dnsState.error = null;
});

describe("validateDomain (multiple server IPs)", () => {
it("passes when the domain resolves to any one of the server's IPs", async () => {
// The bug: a server reachable on an internal SSH IP and a separate public
// IP. DNS points to the public IP, which must still validate.
dnsState.addresses = ["203.0.113.10"];

const result = await validateDomain("multi.example.com", [
"10.0.0.2",
"203.0.113.10",
]);

expect(result.isValid).toBe(true);
});

it("fails when the domain resolves to none of the server's IPs", async () => {
dnsState.addresses = ["198.51.100.5"];

const result = await validateDomain("wrong.example.com", [
"10.0.0.2",
"203.0.113.10",
]);

expect(result.isValid).toBe(false);
expect(result.error).toContain("198.51.100.5");
});

it("is valid when no expected IPs are provided (resolve-only)", async () => {
dnsState.addresses = ["203.0.113.10"];

const result = await validateDomain("any.example.com");

expect(result.isValid).toBe(true);
expect(result.resolvedIp).toBe("203.0.113.10");
});
});

describe("validateDomainForServer (validation modes)", () => {
it("proxy mode validates against the user provided IP", async () => {
dnsState.addresses = ["203.0.113.10"];

const valid = await validateDomainForServer({
domain: "proxied.example.com",
validationMode: "proxy",
expectedIp: "203.0.113.10",
});
expect(valid.isValid).toBe(true);

dnsState.addresses = ["198.51.100.5"];
const invalid = await validateDomainForServer({
domain: "proxied.example.com",
validationMode: "proxy",
expectedIp: "203.0.113.10",
});
expect(invalid.isValid).toBe(false);
});

it("proxy mode without an expected IP is rejected (does not behave like skip)", async () => {
dnsState.addresses = ["198.51.100.5"];

for (const expectedIp of [undefined, null, "", " "]) {
const result = await validateDomainForServer({
domain: "proxied.example.com",
validationMode: "proxy",
expectedIp,
});
expect(result.isValid).toBe(false);
expect(result.error).toContain("expected IP");
}
});

it("skip mode only confirms the domain resolves", async () => {
dnsState.addresses = ["198.51.100.5"];

const result = await validateDomainForServer({
domain: "skipped.example.com",
validationMode: "skip",
// An IP that does not match anything; skip must ignore it.
expectedIp: "203.0.113.10",
});

expect(result.isValid).toBe(true);
});

it("skip mode reports failure when the domain does not resolve", async () => {
dnsState.error = new Error("ENOTFOUND");

const result = await validateDomainForServer({
domain: "missing.example.com",
validationMode: "skip",
});

expect(result.isValid).toBe(false);
});
});
2 changes: 2 additions & 0 deletions apps/dokploy/__test__/traefik/forward-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const baseDomain: Domain = {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
validationMode: "auto",
expectedIp: null,
};

describe("forwardAuthMiddlewareName", () => {
Expand Down
2 changes: 2 additions & 0 deletions apps/dokploy/__test__/traefik/traefik.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ const baseDomain: Domain = {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
validationMode: "auto",
expectedIp: null,
};

const baseRedirect: Redirect = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ interface ColumnsProps {
id: string;
type: "application" | "compose";
validationStates: ValidationStates;
handleValidateDomain: (host: string) => Promise<void>;
handleValidateDomain: (domain: Domain) => Promise<void>;
handleDeleteDomain: (domainId: string) => Promise<void>;
isDeleting: boolean;
serverIp?: string;
Expand Down Expand Up @@ -181,7 +181,7 @@ export const createColumns = ({
? "bg-red-500/10 text-red-500 cursor-pointer"
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
}
onClick={() => handleValidateDomain(domain.host)}
onClick={() => handleValidateDomain(domain)}
>
{validationState?.isLoading ? (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export const domain = z
serviceName: z.string().optional(),
domainType: z.enum(["application", "compose", "preview"]).optional(),
middlewares: z.array(z.string()).optional(),
validationMode: z.enum(["auto", "proxy", "skip"]).optional(),
expectedIp: z.string().optional(),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {
Expand Down Expand Up @@ -126,6 +128,14 @@ export const domain = z
message: "Custom entry point must be specified",
});
}

if (input.validationMode === "proxy" && !input.expectedIp?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["expectedIp"],
message: "Enter the IP the domain should resolve to",
});
}
});

type Domain = z.infer<typeof domain>;
Expand Down Expand Up @@ -216,6 +226,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
serviceName: undefined,
domainType: type,
middlewares: [],
validationMode: "auto",
expectedIp: undefined,
},
mode: "onChange",
});
Expand All @@ -224,6 +236,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
const https = form.watch("https");
const domainType = form.watch("domainType");
const validationMode = form.watch("validationMode");
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("sslip.io") || false;

Expand All @@ -243,6 +256,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
serviceName: data?.serviceName || undefined,
domainType: data?.domainType || type,
middlewares: data?.middlewares || [],
validationMode: data?.validationMode || "auto",
expectedIp: data?.expectedIp || undefined,
});
}

Expand All @@ -260,6 +275,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
customCertResolver: undefined,
domainType: type,
middlewares: [],
validationMode: "auto",
expectedIp: undefined,
});
}
}, [form, data, isPending, domainId]);
Expand Down Expand Up @@ -879,6 +896,70 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
</FormItem>
)}
/>

<FormField
control={form.control}
name="validationMode"
render={({ field }) => (
<FormItem>
<FormLabel>DNS Validation</FormLabel>
<FormDescription>
How the Validate badge checks this domain's DNS.
</FormDescription>
<Select
onValueChange={(value) => {
field.onChange(value);
if (value !== "proxy") {
form.setValue("expectedIp", undefined);
}
}}
value={field.value || "auto"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a validation mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="auto">
Auto (match the server's IPs)
</SelectItem>
<SelectItem value="proxy">
Behind a proxy (match a custom IP)
</SelectItem>
<SelectItem value="skip">
Skip (only check it resolves)
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>

{validationMode === "proxy" && (
<FormField
control={form.control}
name="expectedIp"
render={({ field }) => (
<FormItem>
<FormLabel>Expected IP</FormLabel>
<FormDescription>
The IP this domain should resolve to, for example your
reverse proxy or load balancer address.
</FormDescription>
<FormControl>
<Input
placeholder="203.0.113.10"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</div>
</form>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { createColumns } from "./columns";
import { createColumns, type Domain } from "./columns";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
import { HandleForwardAuth } from "./handle-forward-auth";
Expand Down Expand Up @@ -157,7 +157,8 @@ export const ShowDomains = ({ id, type }: Props) => {
}
};

const handleValidateDomain = async (host: string) => {
const handleValidateDomain = async (domain: Domain) => {
const host = domain.host;
setValidationStates((prev) => ({
...prev,
[host]: { isLoading: true },
Expand All @@ -166,8 +167,11 @@ export const ShowDomains = ({ id, type }: Props) => {
try {
const result = await validateDomain({
domain: host,
serverId: application?.serverId ?? undefined,
serverIp:
application?.server?.ipAddress?.toString() || ip?.toString() || "",
validationMode: domain.validationMode ?? undefined,
expectedIp: domain.expectedIp ?? undefined,
});

setValidationStates((prev) => ({
Expand Down Expand Up @@ -595,9 +599,7 @@ export const ShowDomains = ({ id, type }: Props) => {
? "bg-red-500/10 text-red-500 cursor-pointer"
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
}
onClick={() =>
handleValidateDomain(item.host)
}
onClick={() => handleValidateDomain(item)}
>
{validationState?.isLoading ? (
<>
Expand Down
3 changes: 3 additions & 0 deletions apps/dokploy/drizzle/0173_perpetual_tarantula.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
CREATE TYPE "public"."domainValidationMode" AS ENUM('auto', 'proxy', 'skip');--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "validationMode" "domainValidationMode" DEFAULT 'auto' NOT NULL;--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "expectedIp" text;
Loading
Loading