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
@@ -1,5 +1,5 @@
import type { Domain } from "@dokploy/server";
import { createDomainLabels } from "@dokploy/server";
import type { Domain } from "@dokploy/server/services/domain";
import { createDomainLabels } from "@dokploy/server/utils/docker/domain";
import { describe, expect, it } from "vitest";
import { parse, stringify } from "yaml";

Expand Down Expand Up @@ -35,6 +35,7 @@ describe("Host rule format regression tests", () => {
customEntrypoint: null,
middlewares: null,
forwardAuthEnabled: false,
externalUpstreamId: null,
};

describe("Host rule format validation", () => {
Expand Down
5 changes: 3 additions & 2 deletions apps/dokploy/__test__/compose/domain/labels.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Domain } from "@dokploy/server";
import { createDomainLabels } from "@dokploy/server";
import type { Domain } from "@dokploy/server/services/domain";
import { createDomainLabels } from "@dokploy/server/utils/docker/domain";
import { describe, expect, it } from "vitest";

describe("createDomainLabels", () => {
Expand All @@ -24,6 +24,7 @@ describe("createDomainLabels", () => {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
externalUpstreamId: null,
};

it("should create basic labels for web entrypoint", async () => {
Expand Down
72 changes: 72 additions & 0 deletions apps/dokploy/__test__/external-upstream/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {
DEFAULT_EXTERNAL_UPSTREAM_BLOCKED_CIDRS,
normalizeBlockedCidrs,
validateBlockedCidrs,
validateExternalUpstreamTargetUrl,
} from "@dokploy/server/utils/network/external-upstream";
import { describe, expect, test } from "vitest";

describe("external upstream target validation", () => {
test("normalizes blocked CIDR input", () => {
expect(
normalizeBlockedCidrs([
"",
" 127.0.0.0/8 ",
"127.0.0.0/8",
" ",
"10.0.0.0/8",
]),
).toEqual(["127.0.0.0/8", "10.0.0.0/8"]);
});

test("rejects invalid blocked CIDR values", () => {
expect(() => validateBlockedCidrs(["nope"])).toThrow(
"Invalid blocked address or CIDR: nope",
);
});

test("rejects non-http protocols", async () => {
await expect(
validateExternalUpstreamTargetUrl({
targetUrl: "tcp://1.1.1.1:8080",
blockedCidrs: DEFAULT_EXTERNAL_UPSTREAM_BLOCKED_CIDRS,
}),
).rejects.toThrow("Target URL must use http:// or https://");
});

test("rejects credentials in target URLs", async () => {
await expect(
validateExternalUpstreamTargetUrl({
targetUrl: "https://user:pass@example.com",
blockedCidrs: DEFAULT_EXTERNAL_UPSTREAM_BLOCKED_CIDRS,
}),
).rejects.toThrow("Target URL must not include credentials");
});

test("rejects localhost hostnames", async () => {
await expect(
validateExternalUpstreamTargetUrl({
targetUrl: "http://localhost:3000",
blockedCidrs: DEFAULT_EXTERNAL_UPSTREAM_BLOCKED_CIDRS,
}),
).rejects.toThrow("Target URL points to a blocked network range");
});

test("rejects blocked private IPs", async () => {
await expect(
validateExternalUpstreamTargetUrl({
targetUrl: "http://127.0.0.1:3000",
blockedCidrs: DEFAULT_EXTERNAL_UPSTREAM_BLOCKED_CIDRS,
}),
).rejects.toThrow("Target URL points to a blocked network range");
});

test("accepts a public HTTP target", async () => {
await expect(
validateExternalUpstreamTargetUrl({
targetUrl: "https://1.1.1.1:8443",
blockedCidrs: DEFAULT_EXTERNAL_UPSTREAM_BLOCKED_CIDRS,
}),
).resolves.toBe("https://1.1.1.1:8443/");
});
});
18 changes: 10 additions & 8 deletions apps/dokploy/__test__/traefik/forward-auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { ApplicationNested, Domain } from "@dokploy/server";
import {
buildForwardAuthEnv,
createRouterConfig,
deriveBaseDomain,
deriveCookieSecret,
forwardAuthCallbackUrl,
forwardAuthMiddlewareName,
} from "@dokploy/server";
} from "@dokploy/server/setup/forward-auth-setup";
import type { ApplicationNested } from "@dokploy/server/utils/builders";
import type { Domain } from "@dokploy/server/services/domain";
import { createRouterConfig as createTraefikRouterConfig } from "@dokploy/server/utils/traefik/domain";
import { forwardAuthMiddlewareName } from "@dokploy/server/utils/traefik/forward-auth";
import { beforeAll, describe, expect, test } from "vitest";

const app = {
Expand Down Expand Up @@ -35,6 +36,7 @@ const baseDomain: Domain = {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
externalUpstreamId: null,
};

describe("forwardAuthMiddlewareName", () => {
Expand All @@ -53,7 +55,7 @@ describe("forwardAuthMiddlewareName", () => {

describe("createRouterConfig forward-auth wiring", () => {
test("does NOT add forward-auth middleware when no provider is linked", async () => {
const config = await createRouterConfig(app, baseDomain, "websecure");
const config = await createTraefikRouterConfig(app, baseDomain, "websecure");
expect(config.middlewares).not.toContain(
forwardAuthMiddlewareName("my-app", 7),
);
Expand All @@ -64,7 +66,7 @@ describe("createRouterConfig forward-auth wiring", () => {
...baseDomain,
forwardAuthEnabled: true,
};
const config = await createRouterConfig(app, domain, "websecure");
const config = await createTraefikRouterConfig(app, domain, "websecure");
expect(config.middlewares).toContain(
forwardAuthMiddlewareName("my-app", 7),
);
Expand All @@ -76,7 +78,7 @@ describe("createRouterConfig forward-auth wiring", () => {
forwardAuthEnabled: true,
middlewares: ["rate-limit@file"],
};
const config = await createRouterConfig(app, domain, "websecure");
const config = await createTraefikRouterConfig(app, domain, "websecure");
const forwardAuthIdx = config.middlewares?.indexOf(
forwardAuthMiddlewareName("my-app", 7),
);
Expand All @@ -91,7 +93,7 @@ describe("createRouterConfig forward-auth wiring", () => {
https: true,
forwardAuthEnabled: true,
};
const config = await createRouterConfig(app, domain, "web");
const config = await createTraefikRouterConfig(app, domain, "web");
expect(config.middlewares).toContain("redirect-to-https");
expect(config.middlewares).not.toContain(
forwardAuthMiddlewareName("my-app", 7),
Expand Down
32 changes: 17 additions & 15 deletions apps/dokploy/__test__/traefik/server/update-server-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ vi.mock("node:fs", () => ({
default: fs,
}));

import type { FileConfig } from "@dokploy/server";
import {
createDefaultServerTraefikConfig,
loadOrCreateConfig,
updateServerTraefik,
} from "@dokploy/server";
} from "@dokploy/server/setup/traefik-setup";
import type { webServerSettings } from "@dokploy/server/db/schema";
import type { FileConfig } from "@dokploy/server/utils/traefik/file-types";
import { loadOrCreateConfig as loadTraefikConfig } from "@dokploy/server/utils/traefik/application";
import { updateServerTraefik as updateWebServerTraefik } from "@dokploy/server/utils/traefik/web-server";
import { beforeEach, expect, test, vi } from "vitest";

type WebServerSettings = typeof webServerSettings.$inferSelect;
Expand Down Expand Up @@ -66,6 +66,8 @@ const baseSettings: WebServerSettings = {
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
externalUpstreamsEnabled: false,
externalUpstreamBlockedCidrs: [],
remoteServersOnly: false,
enforceSSO: false,
createdAt: null,
Expand All @@ -78,14 +80,14 @@ beforeEach(() => {
});

test("Should read the configuration file", () => {
const config: FileConfig = loadOrCreateConfig("dokploy");
const config: FileConfig = loadTraefikConfig("dokploy");
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
"dokploy-service-app",
);
});

test("Should apply redirect-to-https", () => {
updateServerTraefik(
updateWebServerTraefik(
{
...baseSettings,
https: true,
Expand All @@ -94,43 +96,43 @@ test("Should apply redirect-to-https", () => {
"example.com",
);

const config: FileConfig = loadOrCreateConfig("dokploy");
const config: FileConfig = loadTraefikConfig("dokploy");

expect(config.http?.routers?.["dokploy-router-app"]?.middlewares).toContain(
"redirect-to-https",
);
});

test("Should change only host when no certificate", () => {
updateServerTraefik(baseSettings, "example.com");
updateWebServerTraefik(baseSettings, "example.com");

const config: FileConfig = loadOrCreateConfig("dokploy");
const config: FileConfig = loadTraefikConfig("dokploy");

expect(config.http?.routers?.["dokploy-router-app-secure"]).toBeUndefined();
});

test("Should not touch config without host", () => {
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
const originalConfig: FileConfig = loadTraefikConfig("dokploy");

updateServerTraefik(baseSettings, null);
updateWebServerTraefik(baseSettings, null);

const config: FileConfig = loadOrCreateConfig("dokploy");
const config: FileConfig = loadTraefikConfig("dokploy");

expect(originalConfig).toEqual(config);
});

test("Should remove websecure if https rollback to http", () => {
updateServerTraefik(
updateWebServerTraefik(
{ ...baseSettings, certificateType: "letsencrypt" },
"example.com",
);

updateServerTraefik(
updateWebServerTraefik(
{ ...baseSettings, certificateType: "none" },
"example.com",
);

const config: FileConfig = loadOrCreateConfig("dokploy");
const config: FileConfig = loadTraefikConfig("dokploy");

expect(config.http?.routers?.["dokploy-router-app-secure"]).toBeUndefined();
expect(
Expand Down
7 changes: 5 additions & 2 deletions apps/dokploy/__test__/traefik/traefik.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ApplicationNested, Domain, Redirect } from "@dokploy/server";
import { createRouterConfig } from "@dokploy/server";
import type { ApplicationNested } from "@dokploy/server/utils/builders";
import type { Domain } from "@dokploy/server/services/domain";
import type { Redirect } from "@dokploy/server/services/redirect";
import { createRouterConfig } from "@dokploy/server/utils/traefik/domain";
import { expect, test } from "vitest";

const baseApp: ApplicationNested = {
Expand Down Expand Up @@ -149,6 +151,7 @@ const baseDomain: Domain = {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
externalUpstreamId: null,
};

const baseRedirect: Redirect = {
Expand Down
72 changes: 51 additions & 21 deletions apps/dokploy/components/dashboard/application/domains/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ import type { ValidationStates } from "./show-domains";

export type Domain =
| RouterOutputs["domain"]["byApplicationId"][0]
| RouterOutputs["domain"]["byComposeId"][0];
| RouterOutputs["domain"]["byComposeId"][0]
| RouterOutputs["domain"]["byExternalUpstreamId"][0];

interface ColumnsProps {
id: string;
type: "application" | "compose";
type: "application" | "compose" | "externalUpstream";
validationStates: ValidationStates;
handleValidateDomain: (host: string) => Promise<void>;
handleDeleteDomain: (domainId: string) => Promise<void>;
Expand All @@ -52,6 +53,53 @@ export const createColumns = ({
canCreateDomain,
canDeleteDomain,
}: ColumnsProps): ColumnDef<Domain>[] => [
...(type === "externalUpstream"
? [
{
accessorKey: "internalPath",
header: "Upstream Prefix",
cell: ({ row }) => {
const internalPath = row.getValue("internalPath") as string | null
if (!internalPath) {
return <span className="text-muted-foreground">-</span>
}
return <div className="font-mono text-sm">{internalPath}</div>
},
} satisfies ColumnDef<Domain>,
{
accessorKey: "stripPath",
header: "Strip Public Path",
cell: ({ row }) => {
const stripPath = row.getValue("stripPath") as boolean | null
if (!stripPath) {
return <span className="text-muted-foreground">No</span>
}
return <Badge variant="secondary">Yes</Badge>
},
} satisfies ColumnDef<Domain>,
]
: [
{
accessorKey: "port",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Port
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const port = row.getValue("port") as number
return <Badge variant="secondary">{port}</Badge>
},
} satisfies ColumnDef<Domain>,
]),
...(type === "compose"
? [
{
Expand Down Expand Up @@ -105,7 +153,7 @@ export const createColumns = ({
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Path
{type === "externalUpstream" ? "Public Path" : "Path"}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
Expand All @@ -115,24 +163,6 @@ export const createColumns = ({
return <div className="font-mono text-sm">{path || "/"}</div>;
},
},
{
accessorKey: "port",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Port
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const port = row.getValue("port") as number;
return <Badge variant="secondary">{port}</Badge>;
},
},
{
accessorKey: "customEntrypoint",
header: "Entrypoint",
Expand Down
Loading