Merge pull request #2863 from KarpachMarko/feature/custom-entrypoint

feat: add support for custom entry point
This commit is contained in:
Mauricio Siu
2026-04-03 16:28:57 -06:00
committed by GitHub
8 changed files with 326 additions and 12 deletions
@@ -32,6 +32,7 @@ describe("Host rule format regression tests", () => {
previewDeploymentId: "", previewDeploymentId: "",
internalPath: "/", internalPath: "/",
stripPath: false, stripPath: false,
customEntrypoint: null,
}; };
describe("Host rule format validation", () => { describe("Host rule format validation", () => {
@@ -7,6 +7,7 @@ describe("createDomainLabels", () => {
const baseDomain: Domain = { const baseDomain: Domain = {
host: "example.com", host: "example.com",
port: 8080, port: 8080,
customEntrypoint: null,
https: false, https: false,
uniqueConfigKey: 1, uniqueConfigKey: 1,
customCertResolver: null, customCertResolver: null,
@@ -240,4 +241,134 @@ 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 create basic labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{ ...baseDomain, customEntrypoint: "custom" },
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
]);
});
it("should create https labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
"traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt",
]);
});
it("should add stripPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1",
);
});
it("should add internalPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1",
);
});
it("should add path prefix in rule for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
},
"custom",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)",
);
});
it("should combine all middlewares for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should not add redirect-to-https for custom entrypoint even with https", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
https: true,
certificateType: "letsencrypt",
},
"custom",
);
const middlewareLabel = labels.find((l) => l.includes(".middlewares="));
// Should not contain redirect-to-https since there's only one router
expect(middlewareLabel).toBeUndefined();
});
}); });
@@ -137,6 +137,7 @@ const baseDomain: Domain = {
https: false, https: false,
path: null, path: null,
port: null, port: null,
customEntrypoint: null,
serviceName: "", serviceName: "",
composeId: "", composeId: "",
customCertResolver: null, customCertResolver: null,
@@ -276,6 +277,110 @@ test("CertificateType on websecure entrypoint", async () => {
expect(router.tls?.certResolver).toBe("letsencrypt"); expect(router.tls?.certResolver).toBe("letsencrypt");
}); });
test("Custom entrypoint on http domain", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: false, customEntrypoint: "custom" },
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.tls).toBeUndefined();
});
test("Custom entrypoint on https domain", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.tls?.certResolver).toBe("letsencrypt");
});
test("Custom entrypoint with path includes PathPrefix in rule", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, customEntrypoint: "custom", path: "/api" },
"custom",
);
expect(router.rule).toContain("PathPrefix(`/api`)");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with stripPath adds stripprefix middleware", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(router.middlewares).toContain("stripprefix--1");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with internalPath adds addprefix middleware", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(router.middlewares).toContain("addprefix--1");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with https and custom cert resolver", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "custom",
customCertResolver: "myresolver",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.tls?.certResolver).toBe("myresolver");
});
test("Custom entrypoint without https should not have tls", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: false,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.tls).toBeUndefined();
});
/** IDN/Punycode */ /** IDN/Punycode */
test("Internationalized domain name is converted to punycode", async () => { test("Internationalized domain name is converted to punycode", async () => {
@@ -61,6 +61,8 @@ export const domain = z
.min(1, { message: "Port must be at least 1" }) .min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" }) .max(65535, { message: "Port must be 65535 or below" })
.optional(), .optional(),
useCustomEntrypoint: z.boolean(),
customEntrypoint: z.string().optional(),
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().optional(), customCertResolver: z.string().optional(),
@@ -114,6 +116,14 @@ export const domain = z
message: "Internal path must start with '/'", message: "Internal path must start with '/'",
}); });
} }
if (input.useCustomEntrypoint && !input.customEntrypoint) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customEntrypoint"],
message: "Custom entry point must be specified",
});
}
}); });
type Domain = z.infer<typeof domain>; type Domain = z.infer<typeof domain>;
@@ -196,6 +206,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: undefined, internalPath: undefined,
stripPath: false, stripPath: false,
port: undefined, port: undefined,
useCustomEntrypoint: false,
customEntrypoint: undefined,
https: false, https: false,
certificateType: undefined, certificateType: undefined,
customCertResolver: undefined, customCertResolver: undefined,
@@ -206,6 +218,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
}); });
const certificateType = form.watch("certificateType"); const certificateType = form.watch("certificateType");
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
const https = form.watch("https"); const https = form.watch("https");
const domainType = form.watch("domainType"); const domainType = form.watch("domainType");
const host = form.watch("host"); const host = form.watch("host");
@@ -220,6 +233,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: data?.internalPath || undefined, internalPath: data?.internalPath || undefined,
stripPath: data?.stripPath || false, stripPath: data?.stripPath || false,
port: data?.port || undefined, port: data?.port || undefined,
useCustomEntrypoint: !!data.customEntrypoint,
customEntrypoint: data.customEntrypoint || undefined,
certificateType: data?.certificateType || undefined, certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined, customCertResolver: data?.customCertResolver || undefined,
serviceName: data?.serviceName || undefined, serviceName: data?.serviceName || undefined,
@@ -234,6 +249,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: undefined, internalPath: undefined,
stripPath: false, stripPath: false,
port: undefined, port: undefined,
useCustomEntrypoint: false,
customEntrypoint: undefined,
https: false, https: false,
certificateType: undefined, certificateType: undefined,
customCertResolver: undefined, customCertResolver: undefined,
@@ -635,6 +652,50 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
}} }}
/> />
<FormField
control={form.control}
name="useCustomEntrypoint"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Custom Entrypoint</FormLabel>
<FormDescription>
Use custom entrypoint for domina
<br />
"web" and/or "websecure" is used by default.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{useCustomEntrypoint && (
<FormField
control={form.control}
name="customEntrypoint"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Entrypoint Name</FormLabel>
<FormControl>
<Input
placeholder="Enter entrypoint name manually"
{...field}
className="w-full"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField <FormField
control={form.control} control={form.control}
name="https" name="https"
@@ -0,0 +1 @@
ALTER TABLE "domain" ADD COLUMN "customEntrypoint" text;
+3
View File
@@ -31,6 +31,7 @@ export const domains = pgTable("domain", {
host: text("host").notNull(), host: text("host").notNull(),
https: boolean("https").notNull().default(false), https: boolean("https").notNull().default(false),
port: integer("port").default(3000), port: integer("port").default(3000),
customEntrypoint: text("customEntrypoint"),
path: text("path").default("/"), path: text("path").default("/"),
serviceName: text("serviceName"), serviceName: text("serviceName"),
domainType: domainType("domainType").default("application"), domainType: domainType("domainType").default("application"),
@@ -80,6 +81,7 @@ export const apiCreateDomain = createSchema.pick({
host: true, host: true,
path: true, path: true,
port: true, port: true,
customEntrypoint: true,
https: true, https: true,
applicationId: true, applicationId: true,
certificateType: true, certificateType: true,
@@ -113,6 +115,7 @@ export const apiUpdateDomain = createSchema
host: true, host: true,
path: true, path: true,
port: true, port: true,
customEntrypoint: true,
https: true, https: true,
certificateType: true, certificateType: true,
customCertResolver: true, customCertResolver: true,
+11 -6
View File
@@ -172,8 +172,12 @@ export const addDomainToCompose = async (
); );
} }
const httpLabels = createDomainLabels(appName, domain, "web"); const httpLabels = createDomainLabels(
if (https) { appName,
domain,
domain.customEntrypoint || "web",
);
if (!domain.customEntrypoint && https) {
const httpsLabels = createDomainLabels(appName, domain, "websecure"); const httpsLabels = createDomainLabels(appName, domain, "websecure");
httpLabels.push(...httpsLabels); httpLabels.push(...httpsLabels);
} }
@@ -251,11 +255,12 @@ export const writeComposeFile = async (
export const createDomainLabels = ( export const createDomainLabels = (
appName: string, appName: string,
domain: Domain, domain: Domain,
entrypoint: "web" | "websecure", entrypoint: string,
) => { ) => {
const { const {
host, host,
port, port,
customEntrypoint,
https, https,
uniqueConfigKey, uniqueConfigKey,
certificateType, certificateType,
@@ -284,7 +289,7 @@ export const createDomainLabels = (
if (stripPath && path && path !== "/") { if (stripPath && path && path !== "/") {
const middlewareName = `stripprefix-${appName}-${uniqueConfigKey}`; const middlewareName = `stripprefix-${appName}-${uniqueConfigKey}`;
// Only define middleware once (on web entrypoint) // Only define middleware once (on web entrypoint)
if (entrypoint === "web") { if (entrypoint === "web" || customEntrypoint) {
labels.push( labels.push(
`traefik.http.middlewares.${middlewareName}.stripprefix.prefixes=${path}`, `traefik.http.middlewares.${middlewareName}.stripprefix.prefixes=${path}`,
); );
@@ -296,7 +301,7 @@ export const createDomainLabels = (
if (internalPath && internalPath !== "/" && internalPath.startsWith("/")) { if (internalPath && internalPath !== "/" && internalPath.startsWith("/")) {
const middlewareName = `addprefix-${appName}-${uniqueConfigKey}`; const middlewareName = `addprefix-${appName}-${uniqueConfigKey}`;
// Only define middleware once (on web entrypoint) // Only define middleware once (on web entrypoint)
if (entrypoint === "web") { if (entrypoint === "web" || customEntrypoint) {
labels.push( labels.push(
`traefik.http.middlewares.${middlewareName}.addprefix.prefix=${internalPath}`, `traefik.http.middlewares.${middlewareName}.addprefix.prefix=${internalPath}`,
); );
@@ -312,7 +317,7 @@ export const createDomainLabels = (
} }
// Add TLS configuration for websecure // Add TLS configuration for websecure
if (entrypoint === "websecure") { if (entrypoint === "websecure" || (customEntrypoint && https)) {
if (certificateType === "letsencrypt") { if (certificateType === "letsencrypt") {
labels.push( labels.push(
`traefik.http.routers.${routerName}.tls.certresolver=letsencrypt`, `traefik.http.routers.${routerName}.tls.certresolver=letsencrypt`,
+13 -6
View File
@@ -32,10 +32,10 @@ export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
config.http.routers[routerName] = await createRouterConfig( config.http.routers[routerName] = await createRouterConfig(
app, app,
domain, domain,
"web", domain.customEntrypoint || "web",
); );
if (domain.https) { if (!domain.customEntrypoint && domain.https) {
config.http.routers[routerNameSecure] = await createRouterConfig( config.http.routers[routerNameSecure] = await createRouterConfig(
app, app,
domain, domain,
@@ -121,13 +121,20 @@ const toPunycode = (host: string): string => {
export const createRouterConfig = async ( export const createRouterConfig = async (
app: ApplicationNested, app: ApplicationNested,
domain: Domain, domain: Domain,
entryPoint: "web" | "websecure", entryPoint: string,
) => { ) => {
const { appName, redirects, security } = app; const { appName, redirects, security } = app;
const { certificateType } = domain; const { certificateType } = domain;
const { host, path, https, uniqueConfigKey, internalPath, stripPath } = const {
domain; host,
path,
https,
uniqueConfigKey,
internalPath,
stripPath,
customEntrypoint,
} = domain;
const punycodeHost = toPunycode(host); const punycodeHost = toPunycode(host);
const routerConfig: HttpRouter = { const routerConfig: HttpRouter = {
rule: `Host(\`${punycodeHost}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`, rule: `Host(\`${punycodeHost}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
@@ -174,7 +181,7 @@ export const createRouterConfig = async (
} }
} }
if (entryPoint === "websecure") { if (entryPoint === "websecure" || (customEntrypoint && https)) {
if (certificateType === "letsencrypt") { if (certificateType === "letsencrypt") {
routerConfig.tls = { certResolver: "letsencrypt" }; routerConfig.tls = { certResolver: "letsencrypt" };
} else if (certificateType === "custom" && domain.customCertResolver) { } else if (certificateType === "custom" && domain.customCertResolver) {