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

+14
View File
@@ -54,6 +54,20 @@ on every layout, and caps width to the available columns.
`board.updateItemLayout` (`PATCH /api/boards/{boardId}/items/{itemId}/layouts/{layoutId}`) moves or resizes one item in
one layout without replacing the board. It cannot move the item outside its section or overlap another item/container.
## Root CA automation
Administrators can inspect a named CA with `GET /api/certificates/{fileName}` (`certificates.getCertificate`) or
create it with `POST /api/certificates/root` (`certificates.ensureRootCertificate`), using JSON `{ fileName, certificate }`.
Names are limited to 128 characters, start with a letter or digit, contain only letters, digits, dots, underscores and
hyphens, and end in `.crt` or `.pem`. Supply one currently valid self-signed PEM root CA, at most 64 KiB, without a chain
or private key.
Inspection returns public metadata or `null` when absent. Creation returns `{ created, certificate }`; identical PEM
bytes return `created: false`, while different existing bytes or unsafe file paths return `CONFLICT` without replacement.
`sha256` hashes the exact UTF-8 PEM bytes, including whitespace; `fingerprint256` identifies the DER certificate.
Dates are ISO 8601. Certificate contents are never returned. Expired stored roots remain inspectable. Trust applies to
subsequent integration requests without a restart; it does not bypass hostname verification.
## Docker targets
Docker actions require `{ endpointId, id }` targets from `docker.getContainers`; `endpointId` distinguishes hosts.
+267
View File
@@ -5545,6 +5545,273 @@
}
}
},
"/api/certificates/{fileName}": {
"get": {
"operationId": "certificates-getCertificate",
"summary": "Inspect a named custom root CA certificate",
"description": "Return public metadata or null when absent. Requires admin permission. Does not return PEM contents.",
"tags": [
"certificates"
],
"security": [
{
"apikey": []
}
],
"parameters": [
{
"in": "path",
"name": "fileName",
"schema": {
"type": "string",
"maxLength": 128,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$"
},
"required": true
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"fileName": {
"type": "string",
"maxLength": 128,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$"
},
"sha256": {
"type": "string"
},
"fingerprint256": {
"type": "string"
},
"ca": {
"type": "boolean",
"const": true
},
"validFrom": {
"type": "string"
},
"validTo": {
"type": "string"
}
},
"required": [
"fileName",
"sha256",
"fingerprint256",
"ca",
"validFrom",
"validTo"
],
"additionalProperties": false
},
{
"type": "null"
}
]
}
}
}
},
"400": {
"description": "Invalid input data",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.BAD_REQUEST"
}
}
}
},
"401": {
"description": "Authorization not provided",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.UNAUTHORIZED"
}
}
}
},
"403": {
"description": "Insufficient access",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.FORBIDDEN"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.NOT_FOUND"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
}
}
}
}
}
}
},
"/api/certificates/root": {
"post": {
"operationId": "certificates-ensureRootCertificate",
"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.",
"tags": [
"certificates"
],
"security": [
{
"apikey": []
}
],
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"fileName": {
"type": "string",
"maxLength": 128,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$"
},
"certificate": {
"type": "string",
"minLength": 1,
"maxLength": 65536
}
},
"required": [
"fileName",
"certificate"
]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"created": {
"type": "boolean"
},
"certificate": {
"type": "object",
"properties": {
"fileName": {
"type": "string",
"maxLength": 128,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$"
},
"sha256": {
"type": "string"
},
"fingerprint256": {
"type": "string"
},
"ca": {
"type": "boolean",
"const": true
},
"validFrom": {
"type": "string"
},
"validTo": {
"type": "string"
}
},
"required": [
"fileName",
"sha256",
"fingerprint256",
"ca",
"validFrom",
"validTo"
],
"additionalProperties": false
}
},
"required": [
"created",
"certificate"
],
"additionalProperties": false
}
}
}
},
"400": {
"description": "Invalid input data",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.BAD_REQUEST"
}
}
}
},
"401": {
"description": "Authorization not provided",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.UNAUTHORIZED"
}
}
}
},
"403": {
"description": "Insufficient access",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.FORBIDDEN"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
}
}
}
}
}
}
},
"/api/integrations/request": {
"post": {
"operationId": "integration-request",
+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(
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { rootCertificates } from "node:tls";
const getCertificateFolder = () => {
export const getCertificateFolder = () => {
if (process.env.NODE_ENV !== "production") return process.env.LOCAL_CERTIFICATE_PATH;
return process.env.LOCAL_CERTIFICATE_PATH ?? path.join("/appdata", "trusted-certificates");
};
@@ -0,0 +1,170 @@
import { createHash, randomUUID, X509Certificate } from "node:crypto";
import { constants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { createLogger } from "../../logs";
import { getCertificateFolder } from "./index";
const logger = createLogger({ module: "rootCertificate" });
const maxCertificateBytes = 64 * 1024;
const fileNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.(crt|pem)$/;
const pemPattern = /^\s*-----BEGIN CERTIFICATE-----\s+([A-Za-z0-9+/=][A-Za-z0-9+/=\s]*)-----END CERTIFICATE-----\s*$/;
export class RootCertificateError extends Error {
constructor(
public readonly code: "BAD_REQUEST" | "CONFLICT",
message: string,
) {
super(message);
}
}
const validateFileName = (fileName: string) => {
if (fileName.length > 128 || !fileNamePattern.test(fileName)) {
throw new RootCertificateError("BAD_REQUEST", "Invalid certificate file name");
}
};
const describeCertificate = (fileName: string, content: Buffer, requireCurrent: boolean) => {
if (content.length > maxCertificateBytes) {
throw new RootCertificateError("BAD_REQUEST", "Expected one PEM root CA certificate, at most 64 KiB");
}
const pem = pemPattern.exec(content.toString("utf8"));
if (!pem?.[1]) {
throw new RootCertificateError("BAD_REQUEST", "Expected one PEM root CA certificate, at most 64 KiB");
}
let certificate: X509Certificate;
try {
certificate = new X509Certificate(content);
// Reject extra DER data as well as PEM chains, keys, leaf certificates and non-root CAs.
const der = Buffer.from(pem[1].replace(/\s/g, ""), "base64");
if (
!certificate.raw.equals(der) ||
!certificate.ca ||
!certificate.checkIssued(certificate) ||
!certificate.verify(certificate.publicKey)
) {
throw new Error("Expected a self-signed root CA certificate");
}
} catch {
throw new RootCertificateError("BAD_REQUEST", "Expected a self-signed root CA certificate");
}
const validFrom = new Date(certificate.validFrom);
const validTo = new Date(certificate.validTo);
const now = Date.now();
if (requireCurrent && (validFrom.getTime() > now || validTo.getTime() <= now)) {
throw new RootCertificateError("BAD_REQUEST", "Root CA certificate is not currently valid");
}
return {
fileName,
sha256: createHash("sha256").update(content).digest("hex"),
fingerprint256: certificate.fingerprint256,
ca: true as const,
validFrom: validFrom.toISOString(),
validTo: validTo.toISOString(),
};
};
const hasCode = (error: unknown, code: string) => {
return error instanceof Error && "code" in error && error.code === code;
};
const readCertificateAsync = async (fullPath: string) => {
let file;
try {
// NONBLOCK prevents opening a FIFO from hanging before the regular-file check.
file = await fs.open(fullPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
} catch (error) {
if (hasCode(error, "ENOENT")) return null;
if (hasCode(error, "ELOOP") || hasCode(error, "ENXIO")) {
throw new RootCertificateError("CONFLICT", "Certificate path is not a regular file");
}
throw error;
}
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size > maxCertificateBytes) {
throw new RootCertificateError("CONFLICT", "Certificate path is not a bounded regular file");
}
const content = Buffer.alloc(maxCertificateBytes + 1);
let size = 0;
while (size < content.length) {
const { bytesRead } = await file.read(content, size, content.length - size, size);
if (bytesRead === 0) break;
size += bytesRead;
}
if (size > maxCertificateBytes) {
throw new RootCertificateError("CONFLICT", "Certificate file exceeds 64 KiB");
}
return content.subarray(0, size);
} finally {
await file.close();
}
};
export const getCustomRootCertificateAsync = async (fileName: string) => {
validateFileName(fileName);
const folder = getCertificateFolder();
if (!folder) return null;
const content = await readCertificateAsync(path.join(folder, fileName));
if (!content) return null;
return describeCertificate(fileName, content, false);
};
export const ensureCustomRootCertificateAsync = async (fileName: string, pem: string) => {
validateFileName(fileName);
const content = Buffer.from(pem, "utf8");
const certificate = describeCertificate(fileName, content, true);
const folder = getCertificateFolder();
if (!folder) {
throw new Error("Set LOCAL_CERTIFICATE_PATH to an absolute path to manage local certificates");
}
await fs.mkdir(folder, { recursive: true });
const fullPath = path.join(folder, fileName);
const existing = await readCertificateAsync(fullPath);
if (existing) {
if (!existing.equals(content)) {
throw new RootCertificateError("CONFLICT", "A different certificate already exists at this file name");
}
return { created: false, certificate };
}
// Publish a complete file atomically without replacing an existing directory entry.
const temporaryPath = path.join(folder, `.root-certificate-${randomUUID()}.tmp`);
const temporaryFile = await fs.open(
temporaryPath,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o644,
);
try {
try {
await temporaryFile.writeFile(content);
await temporaryFile.sync();
} finally {
await temporaryFile.close();
}
try {
await fs.link(temporaryPath, fullPath);
} catch (error) {
if (!hasCode(error, "EEXIST")) throw error;
const concurrent = await readCertificateAsync(fullPath);
if (!concurrent?.equals(content)) {
throw new RootCertificateError("CONFLICT", "A different certificate already exists at this file name");
}
return { created: false, certificate };
}
return { created: true, certificate };
} finally {
// Cleanup must not turn a published certificate into a failed operation.
try {
await fs.unlink(temporaryPath);
} catch (error) {
logger.warn("Failed to remove temporary root certificate file", { error });
}
}
};
@@ -1,4 +1,9 @@
export { getTrustedCertificateHostnamesAsync } from "./hostnames";
export {
ensureCustomRootCertificateAsync,
getCustomRootCertificateAsync,
RootCertificateError,
} from "./files/root-certificate";
export {
addCustomRootCertificateAsync,
removeCustomRootCertificateAsync,
+14
View File
@@ -4,6 +4,20 @@ import { createCustomErrorParams } from "./form/i18n";
export const certificateValidFileNameSchema = z.string().regex(/^[\w\-. ]+$/);
export const rootCertificateFileNameSchema = z
.string()
.max(128)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*\.(crt|pem)$/);
export const rootCertificateMetadataSchema = z.object({
fileName: rootCertificateFileNameSchema,
sha256: z.string(),
fingerprint256: z.string(),
ca: z.literal(true),
validFrom: z.string(),
validTo: z.string(),
});
export const checkCertificateFile: z.core.CheckFn<File> = (context) => {
const result = certificateValidFileNameSchema.safeParse(context.value.name);
if (!result.success) {