mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-09 13:35:19 +00:00
feat: add middlewares to domains
This commit is contained in:
@@ -21,6 +21,7 @@ describe("createDomainLabels", () => {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
|
middlewares: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should create basic labels for web entrypoint", async () => {
|
it("should create basic labels for web entrypoint", async () => {
|
||||||
@@ -240,4 +241,93 @@ describe("createDomainLabels", () => {
|
|||||||
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should add single custom middleware to router", async () => {
|
||||||
|
const customMiddlewareDomain = {
|
||||||
|
...baseDomain,
|
||||||
|
middlewares: ["auth@file"],
|
||||||
|
};
|
||||||
|
const labels = await createDomainLabels(appName, customMiddlewareDomain, "web");
|
||||||
|
|
||||||
|
expect(labels).toContain(
|
||||||
|
"traefik.http.routers.test-app-1-web.middlewares=auth@file",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add multiple custom middlewares to router", async () => {
|
||||||
|
const customMiddlewareDomain = {
|
||||||
|
...baseDomain,
|
||||||
|
middlewares: ["auth@file", "rate-limit@file"],
|
||||||
|
};
|
||||||
|
const labels = await createDomainLabels(appName, customMiddlewareDomain, "web");
|
||||||
|
|
||||||
|
expect(labels).toContain(
|
||||||
|
"traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine custom middlewares with HTTPS redirect in correct order", async () => {
|
||||||
|
const combinedDomain = {
|
||||||
|
...baseDomain,
|
||||||
|
https: true,
|
||||||
|
middlewares: ["auth@file"],
|
||||||
|
};
|
||||||
|
const labels = await createDomainLabels(appName, combinedDomain, "web");
|
||||||
|
|
||||||
|
// HTTPS redirect should come before custom middleware
|
||||||
|
expect(labels).toContain(
|
||||||
|
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,auth@file",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine custom middlewares with stripPath middleware", async () => {
|
||||||
|
const combinedDomain = {
|
||||||
|
...baseDomain,
|
||||||
|
path: "/api",
|
||||||
|
stripPath: true,
|
||||||
|
middlewares: ["auth@file"],
|
||||||
|
};
|
||||||
|
const labels = await createDomainLabels(appName, combinedDomain, "web");
|
||||||
|
|
||||||
|
// stripprefix should come before custom middleware
|
||||||
|
expect(labels).toContain(
|
||||||
|
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine all built-in middlewares with custom middlewares in correct order", async () => {
|
||||||
|
const fullDomain = {
|
||||||
|
...baseDomain,
|
||||||
|
https: true,
|
||||||
|
path: "/api",
|
||||||
|
stripPath: true,
|
||||||
|
internalPath: "/hello",
|
||||||
|
middlewares: ["auth@file", "rate-limit@file"],
|
||||||
|
};
|
||||||
|
const webLabels = await createDomainLabels(appName, fullDomain, "web");
|
||||||
|
|
||||||
|
// Order: redirect-to-https, stripprefix, addprefix, then custom middlewares
|
||||||
|
expect(webLabels).toContain(
|
||||||
|
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1,auth@file,rate-limit@file",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include custom middlewares on websecure entrypoint", async () => {
|
||||||
|
const customMiddlewareDomain = {
|
||||||
|
...baseDomain,
|
||||||
|
https: true,
|
||||||
|
middlewares: ["auth@file"],
|
||||||
|
};
|
||||||
|
const websecureLabels = await createDomainLabels(
|
||||||
|
appName,
|
||||||
|
customMiddlewareDomain,
|
||||||
|
"websecure",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Websecure should have custom middleware but not redirect-to-https
|
||||||
|
expect(websecureLabels).toContain(
|
||||||
|
"traefik.http.routers.test-app-1-websecure.middlewares=auth@file",
|
||||||
|
);
|
||||||
|
expect(websecureLabels).not.toContain("redirect-to-https");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ const baseDomain: Domain = {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
|
middlewares: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRedirect: Redirect = {
|
const baseRedirect: Redirect = {
|
||||||
@@ -262,6 +263,80 @@ test("Websecure entrypoint on https domain with redirect", async () => {
|
|||||||
expect(router.middlewares).toContain("redirect-test-1");
|
expect(router.middlewares).toContain("redirect-test-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Custom Middlewares */
|
||||||
|
|
||||||
|
test("Web entrypoint with single custom middleware", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{ ...baseDomain, middlewares: ["auth@file"] },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(router.middlewares).toContain("auth@file");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Web entrypoint with multiple custom middlewares", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{ ...baseDomain, middlewares: ["auth@file", "rate-limit@file"] },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(router.middlewares).toContain("auth@file");
|
||||||
|
expect(router.middlewares).toContain("rate-limit@file");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Web entrypoint on https domain with custom middleware", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should only have HTTPS redirect - custom middleware applies on websecure
|
||||||
|
expect(router.middlewares).toContain("redirect-to-https");
|
||||||
|
expect(router.middlewares).not.toContain("auth@file");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Websecure entrypoint with custom middleware", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
|
||||||
|
"websecure",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should have custom middleware but not HTTPS redirect
|
||||||
|
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||||
|
expect(router.middlewares).toContain("auth@file");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Web entrypoint with redirect and custom middleware", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
{
|
||||||
|
...baseApp,
|
||||||
|
appName: "test",
|
||||||
|
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
|
||||||
|
},
|
||||||
|
{ ...baseDomain, middlewares: ["auth@file"] },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should have both redirect middleware and custom middleware
|
||||||
|
expect(router.middlewares).toContain("redirect-test-1");
|
||||||
|
expect(router.middlewares).toContain("auth@file");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Web entrypoint with empty middlewares array", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{ ...baseDomain, https: false, middlewares: [] },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should behave same as no middlewares - no redirect for http
|
||||||
|
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||||
|
});
|
||||||
|
|
||||||
/** Certificates */
|
/** Certificates */
|
||||||
|
|
||||||
test("CertificateType on websecure entrypoint", async () => {
|
test("CertificateType on websecure entrypoint", async () => {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -66,6 +67,7 @@ export const domain = z
|
|||||||
customCertResolver: z.string().optional(),
|
customCertResolver: z.string().optional(),
|
||||||
serviceName: z.string().optional(),
|
serviceName: z.string().optional(),
|
||||||
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
||||||
|
middlewares: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.superRefine((input, ctx) => {
|
.superRefine((input, ctx) => {
|
||||||
if (input.https && !input.certificateType) {
|
if (input.https && !input.certificateType) {
|
||||||
@@ -201,6 +203,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
serviceName: undefined,
|
serviceName: undefined,
|
||||||
domainType: type,
|
domainType: type,
|
||||||
|
middlewares: [],
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
@@ -224,6 +227,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
customCertResolver: data?.customCertResolver || undefined,
|
customCertResolver: data?.customCertResolver || undefined,
|
||||||
serviceName: data?.serviceName || undefined,
|
serviceName: data?.serviceName || undefined,
|
||||||
domainType: data?.domainType || type,
|
domainType: data?.domainType || type,
|
||||||
|
middlewares: data?.middlewares || [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +242,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
domainType: type,
|
domainType: type,
|
||||||
|
middlewares: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, data, isLoading, domainId]);
|
}, [form, data, isLoading, domainId]);
|
||||||
@@ -725,6 +730,88 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="middlewares"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FormLabel>Middlewares</FormLabel>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-[300px]">
|
||||||
|
<p>
|
||||||
|
Add Traefik middleware references. Middlewares
|
||||||
|
must be defined in your Traefik configuration.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{field.value?.map((name, index) => (
|
||||||
|
<Badge key={index} variant="secondary">
|
||||||
|
{name}
|
||||||
|
<X
|
||||||
|
className="ml-1 size-3 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const newMiddlewares = [...(field.value || [])];
|
||||||
|
newMiddlewares.splice(index, 1);
|
||||||
|
form.setValue("middlewares", newMiddlewares);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., rate-limit@file, auth@file"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (value && !field.value?.includes(value)) {
|
||||||
|
form.setValue("middlewares", [
|
||||||
|
...(field.value || []),
|
||||||
|
value,
|
||||||
|
]);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.querySelector(
|
||||||
|
'input[placeholder="e.g., rate-limit@file, auth@file"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (value && !field.value?.includes(value)) {
|
||||||
|
form.setValue("middlewares", [
|
||||||
|
...(field.value || []),
|
||||||
|
value,
|
||||||
|
]);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -332,6 +332,22 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{item.middlewares?.map((middleware, index) => (
|
||||||
|
<TooltipProvider key={`${middleware}-${index}`}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<InfoIcon className="size-3 mr-1" />
|
||||||
|
Middleware: {middleware}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Traefik middleware reference</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
))}
|
||||||
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "domain" ADD COLUMN "middlewares" text[];
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -939,6 +939,13 @@
|
|||||||
"when": 1766301478005,
|
"when": 1766301478005,
|
||||||
"tag": "0133_striped_the_order",
|
"tag": "0133_striped_the_order",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 134,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1767280635007,
|
||||||
|
"tag": "0134_whole_dazzler",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -53,6 +53,7 @@ export const domains = pgTable("domain", {
|
|||||||
certificateType: certificateType("certificateType").notNull().default("none"),
|
certificateType: certificateType("certificateType").notNull().default("none"),
|
||||||
internalPath: text("internalPath").default("/"),
|
internalPath: text("internalPath").default("/"),
|
||||||
stripPath: boolean("stripPath").notNull().default(false),
|
stripPath: boolean("stripPath").notNull().default(false),
|
||||||
|
middlewares: text("middlewares").array(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const domainsRelations = relations(domains, ({ one }) => ({
|
export const domainsRelations = relations(domains, ({ one }) => ({
|
||||||
@@ -86,6 +87,7 @@ export const apiCreateDomain = createSchema.pick({
|
|||||||
previewDeploymentId: true,
|
previewDeploymentId: true,
|
||||||
internalPath: true,
|
internalPath: true,
|
||||||
stripPath: true,
|
stripPath: true,
|
||||||
|
middlewares: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiFindDomain = createSchema
|
export const apiFindDomain = createSchema
|
||||||
@@ -118,5 +120,6 @@ export const apiUpdateDomain = createSchema
|
|||||||
domainType: true,
|
domainType: true,
|
||||||
internalPath: true,
|
internalPath: true,
|
||||||
stripPath: true,
|
stripPath: true,
|
||||||
|
middlewares: true,
|
||||||
})
|
})
|
||||||
.merge(createSchema.pick({ domainId: true }).required());
|
.merge(createSchema.pick({ domainId: true }).required());
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const domain = z
|
|||||||
https: z.boolean().optional(),
|
https: z.boolean().optional(),
|
||||||
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||||
customCertResolver: z.string(),
|
customCertResolver: z.string(),
|
||||||
|
middlewares: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.superRefine((input, ctx) => {
|
.superRefine((input, ctx) => {
|
||||||
if (input.https && !input.certificateType) {
|
if (input.https && !input.certificateType) {
|
||||||
@@ -83,6 +84,7 @@ export const domainCompose = z
|
|||||||
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||||
customCertResolver: z.string(),
|
customCertResolver: z.string(),
|
||||||
serviceName: z.string().min(1, { message: "Service name is required" }),
|
serviceName: z.string().min(1, { message: "Service name is required" }),
|
||||||
|
middlewares: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.superRefine((input, ctx) => {
|
.superRefine((input, ctx) => {
|
||||||
if (input.https && !input.certificateType) {
|
if (input.https && !input.certificateType) {
|
||||||
|
|||||||
@@ -302,6 +302,11 @@ export const createDomainLabels = (
|
|||||||
middlewares.push(middlewareName);
|
middlewares.push(middlewareName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add custom middlewares
|
||||||
|
if (domain.middlewares?.length) {
|
||||||
|
middlewares.push(...domain.middlewares);
|
||||||
|
}
|
||||||
|
|
||||||
// Apply middlewares to router if any exist
|
// Apply middlewares to router if any exist
|
||||||
if (middlewares.length > 0) {
|
if (middlewares.length > 0) {
|
||||||
labels.push(
|
labels.push(
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export const createRouterConfig = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (entryPoint === "web" && https) {
|
if (entryPoint === "web" && https) {
|
||||||
routerConfig.middlewares = ["redirect-to-https"];
|
routerConfig.middlewares?.unshift("redirect-to-https");
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((entryPoint === "websecure" && https) || !https) {
|
if ((entryPoint === "websecure" && https) || !https) {
|
||||||
@@ -160,6 +160,11 @@ export const createRouterConfig = async (
|
|||||||
}
|
}
|
||||||
routerConfig.middlewares?.push(middlewareName);
|
routerConfig.middlewares?.push(middlewareName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// custom middlewares from domain
|
||||||
|
if (domain.middlewares && domain.middlewares.length > 0) {
|
||||||
|
routerConfig.middlewares?.push(...domain.middlewares);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entryPoint === "websecure") {
|
if (entryPoint === "websecure") {
|
||||||
|
|||||||
Reference in New Issue
Block a user