feat: add middlewares to domains

This commit is contained in:
David Eiber
2026-01-01 16:18:40 +01:00
parent 9498fbeff3
commit ce5ad35981
11 changed files with 7267 additions and 2 deletions
@@ -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
+7
View File
@@ -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
} }
] ]
} }
+3
View File
@@ -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(
+6 -1
View File
@@ -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") {