feat: add create-only root certificate API (#7033)

* feat: add create-only root certificate API [skip ci]

* fix: harden root certificate API handling
This commit is contained in:
Thomas authored and GitHub committed 2026-10-05 09:48:06 +02:00
1 parent 67cff1fbc0
commit a17845aa1c
8 files changed
+542 -2

No files matched your search

+5
View File
@@ -4,6 +4,7 @@ import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key";
import { appRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { ensureRootCertificateProcedure, getCertificateProcedure } from "./router/certificates/certificate-router";
import { integrationRequestProcedure } from "./router/integration/integration-request";
import { integrationTestStoredConnectionProcedure } from "./router/integration/integration-test-stored-connection";
import { infoRouter } from "./router/info";
@@ -21,6 +22,10 @@ export const openApiRouter = createTRPCRouter({
inviteRouter,
serverSettingsRouter,
userRouter,
certificates: createTRPCRouter({
getCertificate: getCertificateProcedure,
ensureRootCertificate: ensureRootCertificateProcedure,
}),
integration: createTRPCRouter({
request: integrationRequestProcedure,
testConnection: integrationTestStoredConnectionProcedure,
@@ -5,18 +5,83 @@ import { z } from "zod/v4";
import {
addCustomRootCertificateAsync,
ensureCustomRootCertificateAsync,
getCustomRootCertificateAsync,
removeCustomRootCertificateAsync,
RootCertificateError,
} from "@homarr/core/infrastructure/certificates";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, eq } from "@homarr/db";
import { trustedCertificateHostnames } from "@homarr/db/schema";
import { certificateValidFileNameSchema, checkCertificateFile } from "@homarr/validation/certificates";
import {
certificateValidFileNameSchema,
checkCertificateFile,
rootCertificateFileNameSchema,
rootCertificateMetadataSchema,
} from "@homarr/validation/certificates";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
const logger = createLogger({ module: "certificateRouter" });
const withCertificateErrorsAsync = async <T>(operation: () => Promise<T>) => {
try {
return await operation();
} catch (error) {
if (error instanceof RootCertificateError) {
throw new TRPCError({ code: error.code, message: error.message });
}
throw error;
}
};
export const getCertificateProcedure = permissionRequiredProcedure
.requiresPermission("admin")
.meta({
openapi: {
method: "GET",
path: "/api/certificates/{fileName}",
tags: ["certificates"],
protect: true,
summary: "Inspect a named custom root CA certificate",
description:
"Return public metadata or null when absent. Requires admin permission. Does not return PEM contents.",
},
})
.input(z.object({ fileName: rootCertificateFileNameSchema }))
.output(rootCertificateMetadataSchema.nullable())
.query(({ input }) => withCertificateErrorsAsync(() => getCustomRootCertificateAsync(input.fileName)));
export const ensureRootCertificateProcedure = permissionRequiredProcedure
.requiresPermission("admin")
.meta({
openapi: {
method: "POST",
path: "/api/certificates/root",
tags: ["certificates"],
protect: true,
summary: "Ensure a named custom root CA certificate without overwriting",
description:
"Accept one currently valid self-signed PEM root CA (64 KiB maximum). Identical bytes are idempotent; different existing bytes return CONFLICT. Requires admin permission.",
},
})
.input(
z.object({
fileName: rootCertificateFileNameSchema,
certificate: z
.string()
.min(1)
.max(64 * 1024),
}),
)
.output(z.object({ created: z.boolean(), certificate: rootCertificateMetadataSchema }))
.mutation(({ input }) =>
withCertificateErrorsAsync(() => ensureCustomRootCertificateAsync(input.fileName, input.certificate)),
);
export const certificateRouter = createTRPCRouter({
getCertificate: getCertificateProcedure,
ensureRootCertificate: ensureRootCertificateProcedure,
addCertificate: permissionRequiredProcedure
.requiresPermission("admin")
.input(