feat(domains): add custom certificate resolver support

- Extend domain configuration to support custom certificate resolvers
- Add new "custom" certificate type option in domain forms
- Update database schema and validation to include custom certificate resolver
- Implement custom certificate resolver handling in Traefik and Docker domain configurations
- Enhance domain management with more flexible SSL/TLS certificate options
This commit is contained in:
Mauricio Siu
2025-03-08 20:46:31 -06:00
parent 4730845a40
commit cc8ffca4d4
12 changed files with 5367 additions and 67 deletions

View File

@@ -85,8 +85,20 @@ export const AddDomain = ({
const form = useForm<Domain>({ const form = useForm<Domain>({
resolver: zodResolver(domain), resolver: zodResolver(domain),
defaultValues: {
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
},
mode: "onChange",
}); });
const certificateType = form.watch("certificateType");
const https = form.watch("https");
useEffect(() => { useEffect(() => {
if (data) { if (data) {
form.reset({ form.reset({
@@ -94,13 +106,29 @@ export const AddDomain = ({
/* Convert null to undefined */ /* Convert null to undefined */
path: data?.path || undefined, path: data?.path || undefined,
port: data?.port || undefined, port: data?.port || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
}); });
} }
if (!domainId) { if (!domainId) {
form.reset({}); form.reset({
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
});
} }
}, [form, form.reset, data, isLoading]); }, [form, data, isLoading, domainId]);
// Separate effect for handling custom cert resolver validation
useEffect(() => {
if (certificateType === "custom") {
form.trigger("customCertResolver");
}
}, [certificateType, form]);
const dictionary = { const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created", success: domainId ? "Domain Updated" : "Domain Created",
@@ -256,34 +284,73 @@ export const AddDomain = ({
)} )}
/> />
{form.getValues().https && ( {https && (
<FormField <>
control={form.control} <FormField
name="certificateType" control={form.control}
render={({ field }) => ( name="certificateType"
<FormItem className="col-span-2"> render={({ field }) => {
<FormLabel>Certificate Provider</FormLabel> return (
<Select <FormItem>
onValueChange={field.onChange} <FormLabel>Certificate Provider</FormLabel>
defaultValue={field.value || ""} <Select
> onValueChange={(value) => {
<FormControl> field.onChange(value);
<SelectTrigger> if (value !== "custom") {
<SelectValue placeholder="Select a certificate provider" /> form.setValue(
</SelectTrigger> "customCertResolver",
</FormControl> undefined,
);
}
}}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>None</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
</SelectItem>
<SelectItem value={"custom"}>Custom</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
<SelectContent> {certificateType === "custom" && (
<SelectItem value="none">None</SelectItem> <FormField
<SelectItem value={"letsencrypt"}> control={form.control}
Let's Encrypt name="customCertResolver"
</SelectItem> render={({ field }) => {
</SelectContent> return (
</Select> <FormItem>
<FormMessage /> <FormLabel>Custom Certificate Resolver</FormLabel>
</FormItem> <FormControl>
<Input
className="w-full"
placeholder="Enter your custom certificate resolver"
{...field}
value={field.value || ""}
onChange={(e) => {
field.onChange(e);
form.trigger("customCertResolver");
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)} )}
/> </>
)} )}
</div> </div>
</div> </div>

View File

@@ -104,6 +104,15 @@ export const AddDomainCompose = ({
const form = useForm<Domain>({ const form = useForm<Domain>({
resolver: zodResolver(domainCompose), resolver: zodResolver(domainCompose),
defaultValues: {
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: "",
},
}); });
const https = form.watch("https"); const https = form.watch("https");
@@ -116,11 +125,21 @@ export const AddDomainCompose = ({
path: data?.path || undefined, path: data?.path || undefined,
port: data?.port || undefined, port: data?.port || undefined,
serviceName: data?.serviceName || undefined, serviceName: data?.serviceName || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
}); });
} }
if (!domainId) { if (!domainId) {
form.reset({}); form.reset({
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: "",
});
} }
}, [form, form.reset, data, isLoading]); }, [form, form.reset, data, isLoading]);
@@ -393,33 +412,55 @@ export const AddDomainCompose = ({
/> />
{https && ( {https && (
<FormField <>
control={form.control} <FormField
name="certificateType" control={form.control}
render={({ field }) => ( name="certificateType"
<FormItem className="col-span-2"> render={({ field }) => (
<FormLabel>Certificate Provider</FormLabel> <FormItem className="col-span-2">
<Select <FormLabel>Certificate Provider</FormLabel>
onValueChange={field.onChange} <Select
defaultValue={field.value || ""} onValueChange={field.onChange}
> defaultValue={field.value || ""}
<FormControl> >
<SelectTrigger> <FormControl>
<SelectValue placeholder="Select a certificate provider" /> <SelectTrigger>
</SelectTrigger> <SelectValue placeholder="Select a certificate provider" />
</FormControl> </SelectTrigger>
</FormControl>
<SelectContent> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}> <SelectItem value={"letsencrypt"}>
Let's Encrypt Let's Encrypt
</SelectItem> </SelectItem>
</SelectContent> <SelectItem value={"custom"}>Custom</SelectItem>
</Select> </SelectContent>
<FormMessage /> </Select>
</FormItem> <FormMessage />
</FormItem>
)}
/>
{form.getValues().certificateType === "custom" && (
<FormField
control={form.control}
name="customCertResolver"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Custom Certificate Resolver</FormLabel>
<FormControl>
<Input
placeholder="Enter your custom certificate resolver"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)} )}
/> </>
)} )}
</div> </div>
</div> </div>

View File

@@ -35,7 +35,7 @@ const addServerDomain = z
.object({ .object({
domain: z.string().min(1, { message: "URL is required" }), domain: z.string().min(1, { message: "URL is required" }),
letsEncryptEmail: z.string(), letsEncryptEmail: z.string(),
certificateType: z.enum(["letsencrypt", "none"]), certificateType: z.enum(["letsencrypt", "none", "custom"]),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) { if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
@@ -193,6 +193,7 @@ export const WebDomain = () => {
); );
}} }}
/> />
<div className="flex w-full justify-end col-span-2"> <div className="flex w-full justify-end col-span-2">
<Button isLoading={isLoading} type="submit"> <Button isLoading={isLoading} type="submit">
{t("settings.common.save")} {t("settings.common.save")}

View File

@@ -0,0 +1,2 @@
ALTER TYPE "public"."certificateType" ADD VALUE 'custom';--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "customCertResolver" text;--> statement-breakpoint

File diff suppressed because it is too large Load Diff

View File

@@ -505,6 +505,13 @@
"when": 1741460060541, "when": 1741460060541,
"tag": "0071_flaky_black_queen", "tag": "0071_flaky_black_queen",
"breakpoints": true "breakpoints": true
},
{
"idx": 72,
"version": "7",
"when": 1741487009559,
"tag": "0072_green_susan_delgado",
"breakpoints": true
} }
] ]
} }

View File

@@ -10,7 +10,8 @@ export const domain = z
.max(65535, { message: "Port must be 65535 or below" }) .max(65535, { message: "Port must be 65535 or below" })
.optional(), .optional(),
https: z.boolean().optional(), https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none"]).optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string().optional(),
}) })
.superRefine((input, ctx) => { .superRefine((input, ctx) => {
if (input.https && !input.certificateType) { if (input.https && !input.certificateType) {
@@ -20,6 +21,14 @@ export const domain = z
message: "Required", message: "Required",
}); });
} }
if (input.certificateType === "custom" && !input.customCertResolver) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customCertResolver"],
message: "Required",
});
}
}); });
export const domainCompose = z export const domainCompose = z
@@ -32,7 +41,8 @@ export const domainCompose = z
.max(65535, { message: "Port must be 65535 or below" }) .max(65535, { message: "Port must be 65535 or below" })
.optional(), .optional(),
https: z.boolean().optional(), https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none"]).optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string().optional(),
serviceName: z.string().min(1, { message: "Service name is required" }), serviceName: z.string().min(1, { message: "Service name is required" }),
}) })
.superRefine((input, ctx) => { .superRefine((input, ctx) => {
@@ -43,4 +53,12 @@ export const domainCompose = z
message: "Required", message: "Required",
}); });
} }
if (input.certificateType === "custom" && !input.customCertResolver) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customCertResolver"],
message: "Required",
});
}
}); });

View File

@@ -41,6 +41,7 @@ export const domains = pgTable("domain", {
composeId: text("composeId").references(() => compose.composeId, { composeId: text("composeId").references(() => compose.composeId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
customCertResolver: text("customCertResolver"),
applicationId: text("applicationId").references( applicationId: text("applicationId").references(
() => applications.applicationId, () => applications.applicationId,
{ onDelete: "cascade" }, { onDelete: "cascade" },
@@ -76,6 +77,7 @@ export const apiCreateDomain = createSchema.pick({
https: true, https: true,
applicationId: true, applicationId: true,
certificateType: true, certificateType: true,
customCertResolver: true,
composeId: true, composeId: true,
serviceName: true, serviceName: true,
domainType: true, domainType: true,
@@ -107,6 +109,7 @@ export const apiUpdateDomain = createSchema
port: true, port: true,
https: true, https: true,
certificateType: true, certificateType: true,
customCertResolver: true,
serviceName: true, serviceName: true,
domainType: true, domainType: true,
}) })

View File

@@ -10,4 +10,5 @@ export const applicationStatus = pgEnum("applicationStatus", [
export const certificateType = pgEnum("certificateType", [ export const certificateType = pgEnum("certificateType", [
"letsencrypt", "letsencrypt",
"none", "none",
"custom",
]); ]);

View File

@@ -10,7 +10,8 @@ export const domain = z
.max(65535, { message: "Port must be 65535 or below" }) .max(65535, { message: "Port must be 65535 or below" })
.optional(), .optional(),
https: z.boolean().optional(), https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none"]).optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string(),
}) })
.superRefine((input, ctx) => { .superRefine((input, ctx) => {
if (input.https && !input.certificateType) { if (input.https && !input.certificateType) {
@@ -20,6 +21,14 @@ export const domain = z
message: "Required", message: "Required",
}); });
} }
if (input.certificateType === "custom" && !input.customCertResolver) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customCertResolver"],
message: "Required when certificate type is custom",
});
}
}); });
export const domainCompose = z export const domainCompose = z
@@ -32,7 +41,8 @@ export const domainCompose = z
.max(65535, { message: "Port must be 65535 or below" }) .max(65535, { message: "Port must be 65535 or below" })
.optional(), .optional(),
https: z.boolean().optional(), https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none"]).optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string(),
serviceName: z.string().min(1, { message: "Service name is required" }), serviceName: z.string().min(1, { message: "Service name is required" }),
}) })
.superRefine((input, ctx) => { .superRefine((input, ctx) => {
@@ -43,4 +53,12 @@ export const domainCompose = z
message: "Required", message: "Required",
}); });
} }
if (input.certificateType === "custom" && !input.customCertResolver) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customCertResolver"],
message: "Required when certificate type is custom",
});
}
}); });

View File

@@ -211,13 +211,9 @@ export const addDomainToCompose = async (
throw new Error(`The service ${serviceName} not found in the compose`); throw new Error(`The service ${serviceName} not found in the compose`);
} }
const httpLabels = await createDomainLabels(appName, domain, "web"); const httpLabels = createDomainLabels(appName, domain, "web");
if (https) { if (https) {
const httpsLabels = await createDomainLabels( const httpsLabels = createDomainLabels(appName, domain, "websecure");
appName,
domain,
"websecure",
);
httpLabels.push(...httpsLabels); httpLabels.push(...httpsLabels);
} }
@@ -279,12 +275,20 @@ export const writeComposeFile = async (
} }
}; };
export const createDomainLabels = async ( export const createDomainLabels = (
appName: string, appName: string,
domain: Domain, domain: Domain,
entrypoint: "web" | "websecure", entrypoint: "web" | "websecure",
) => { ) => {
const { host, port, https, uniqueConfigKey, certificateType, path } = domain; const {
host,
port,
https,
uniqueConfigKey,
certificateType,
path,
customCertResolver,
} = domain;
const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`; const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`;
const labels = [ const labels = [
`traefik.http.routers.${routerName}.rule=Host(\`${host}\`)${path && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`, `traefik.http.routers.${routerName}.rule=Host(\`${host}\`)${path && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
@@ -304,6 +308,10 @@ export const createDomainLabels = async (
labels.push( labels.push(
`traefik.http.routers.${routerName}.tls.certresolver=letsencrypt`, `traefik.http.routers.${routerName}.tls.certresolver=letsencrypt`,
); );
} else if (certificateType === "custom" && customCertResolver) {
labels.push(
`traefik.http.routers.${routerName}.tls.certresolver=${customCertResolver}`,
);
} }
} }

View File

@@ -148,6 +148,8 @@ export const createRouterConfig = async (
if (entryPoint === "websecure") { if (entryPoint === "websecure") {
if (certificateType === "letsencrypt") { if (certificateType === "letsencrypt") {
routerConfig.tls = { certResolver: "letsencrypt" }; routerConfig.tls = { certResolver: "letsencrypt" };
} else if (certificateType === "custom" && domain.customCertResolver) {
routerConfig.tls = { certResolver: domain.customCertResolver };
} else if (certificateType === "none") { } else if (certificateType === "none") {
routerConfig.tls = undefined; routerConfig.tls = undefined;
} }