feat(cert-manager): Vorlagen für Zielsysteme, Modulversion 1.2.0 und Anleitungen
- Sieben Vorlagen (Nginx, Apache ab/vor 2.4.8, Windows/IIS, Nginx Proxy Manager, HAProxy, Tomcat) mit Dateien und Einrichtungszeilen - Reiter „Vorlagen“ mit ZIP samt Anleitung, Schnipsel und Kopieren - Modulversion 1.2.0, Modul-Changelog, CHANGELOG und drei Anleitungen Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,37 @@ import type { ModuleChangelog } from '../module-registry/module-changelog';
|
||||
* docs/anleitung-entwicklung.md, Abschnitt „Modulversion und Modul-Changelog pflegen“.
|
||||
*/
|
||||
export const CERT_MANAGER_CHANGELOG: ModuleChangelog = [
|
||||
{
|
||||
version: '1.2.0',
|
||||
date: '2026-10-09',
|
||||
changes: [
|
||||
{
|
||||
kind: 'new',
|
||||
de: 'Ein gemeinsamer Reiter „Dateien“: Laden Sie mehrere Dateien und ZIP-Dateien auf einmal hoch oder fügen Sie PEM-Text ein. Alle anderen Reiter arbeiten mit dieser Liste.',
|
||||
en: 'One shared “Files” tab: upload several files and ZIP files at once or paste PEM text. All other tabs work with this list.',
|
||||
},
|
||||
{
|
||||
kind: 'new',
|
||||
de: '„Zusammenführen“ ordnet die Kette selbst und liefert Fullchain, nur die Kette, Zertifikat mit Schlüssel oder eine PFX-Datei; das Root-Zertifikat nehmen Sie nur auf Wunsch mit.',
|
||||
en: '“Merge” orders the chain by itself and delivers a full chain, the chain only, certificate with key or a PFX file; the root certificate is only included on request.',
|
||||
},
|
||||
{
|
||||
kind: 'new',
|
||||
de: 'Alle gängigen Formate rein und raus, auch Zertifikate und Schlüssel mit elliptischen Kurven (EC) und verschlüsselte Schlüssel. PFX-Dateien wahlweise kompatibel oder modern verschlüsselt.',
|
||||
en: 'All common formats in and out, including elliptic-curve (EC) certificates and keys and encrypted keys. PFX files with compatible or modern encryption.',
|
||||
},
|
||||
{
|
||||
kind: 'new',
|
||||
de: 'Vorlagen für Nginx, Apache, Windows/IIS, Nginx Proxy Manager, HAProxy und Tomcat liefern die passenden Dateien mit einem Klick.',
|
||||
en: 'Templates for Nginx, Apache, Windows/IIS, Nginx Proxy Manager, HAProxy and Tomcat deliver the right files with one click.',
|
||||
},
|
||||
{
|
||||
kind: 'fixed',
|
||||
de: 'Beim Zusammenführen ersetzt eine zweite Datei nicht mehr die erste.',
|
||||
en: 'When merging, a second file no longer replaces the first one.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '1.1.0',
|
||||
date: '2026-10-02',
|
||||
|
||||
@@ -530,3 +530,34 @@ describe('buildOutput: ungueltige Paare', () => {
|
||||
).toEqual({ status: 400, code: 'invalidInput' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOutput: Vorlagen im Ergebnis', () => {
|
||||
it('liefert bei Vorlagen einen Schnipsel, bei anderen Inhalten keinen', () => {
|
||||
const tpl = buildOutput({
|
||||
content: 'template',
|
||||
template: 'haproxy',
|
||||
certPem: fxText('rsa-leaf.pem'),
|
||||
poolPems: [fxText('rsa-inter.pem')],
|
||||
keyPem: fxText('rsa-leaf-key.pem'),
|
||||
});
|
||||
expect(tpl.snippet).toBe('bind :443 ssl crt /etc/haproxy/certs/www.example.test.pem');
|
||||
expect(tpl.chainComplete).toBe(false);
|
||||
expect(tpl.missingIssuerCn).toBe('Tessera Test Root RSA');
|
||||
const plain = buildOutput({ content: 'leaf', certPem: fxText('rsa-leaf.pem') });
|
||||
expect('snippet' in plain).toBe(false);
|
||||
});
|
||||
|
||||
it('Vorlage mit Format: 400 invalidInput', () => {
|
||||
expect(
|
||||
codeOf(() =>
|
||||
buildOutput({
|
||||
content: 'template',
|
||||
template: 'nginx',
|
||||
format: 'pem',
|
||||
certPem: fxText('rsa-leaf.pem'),
|
||||
keyPem: fxText('rsa-leaf-key.pem'),
|
||||
}),
|
||||
),
|
||||
).toEqual({ status: 400, code: 'invalidInput' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { exportKey, type KeyExportFormat } from './cert-keys';
|
||||
import { certItemFromDer } from './cert-model';
|
||||
import { safeBaseName } from './cert-names';
|
||||
import { writePkcs12 } from './cert-pkcs12';
|
||||
import { buildTemplate, isTemplateId } from './cert-templates';
|
||||
import {
|
||||
type BuildContent,
|
||||
type BuildFile,
|
||||
@@ -44,6 +45,7 @@ const FORMATS: Record<BuildContent, readonly string[]> = {
|
||||
pfx: ['pfx'],
|
||||
key: ['pkcs8', 'traditional', 'pkcs8-der'],
|
||||
csr: ['pem', 'der'],
|
||||
template: ['template'],
|
||||
};
|
||||
|
||||
function parseCertificate(pem: unknown): CertItem {
|
||||
@@ -172,6 +174,7 @@ export function buildOutput(input: BuildInput): BuildResult {
|
||||
const base = safeBaseName(input.baseName ?? '', head.baseName);
|
||||
|
||||
let files: BuildFile[];
|
||||
let snippet: string | null = null;
|
||||
switch (input.content) {
|
||||
case 'leaf':
|
||||
if (format === 'der') files = [file(`${base}.cer`, derOf(head), MIME.der)];
|
||||
@@ -208,6 +211,25 @@ export function buildOutput(input: BuildInput): BuildResult {
|
||||
files = [file(`${base}.pfx`, der, MIME.pfx)];
|
||||
break;
|
||||
}
|
||||
case 'template': {
|
||||
if (!isTemplateId(input.template)) certError('invalidInput', 400, 'Unknown template');
|
||||
// Eine Vorlage ohne passenden Schluessel gibt es nicht (templateNeedsKey); ein falscher Schluessel ist keyMismatch.
|
||||
if (!input.keyPem || input.keyPem.trim() === '') {
|
||||
certError('templateNeedsKey', 400, 'The template needs the matching private key');
|
||||
}
|
||||
const key = matchingKey(head, input.keyPem);
|
||||
const built = buildTemplate(input.template, {
|
||||
head,
|
||||
shown,
|
||||
key,
|
||||
base,
|
||||
password: input.password,
|
||||
pfxProfile: input.pfxEncryption === 'modern' ? 'modern' : 'compat',
|
||||
});
|
||||
files = built.files;
|
||||
snippet = built.snippet;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return certError('invalidInput', 400, 'Unknown content');
|
||||
}
|
||||
@@ -216,6 +238,7 @@ export function buildOutput(input: BuildInput): BuildResult {
|
||||
files,
|
||||
chainComplete: chain.complete,
|
||||
missingIssuerCn: chain.gap?.missingIssuerCn ?? null,
|
||||
...(input.content === 'template' ? { snippet } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { createPrivateKey, X509Certificate } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import * as forge from 'node-forge';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildOutput } from './cert-output';
|
||||
import { readPkcs12 } from './cert-pkcs12';
|
||||
import { TEMPLATE_IDS } from './cert-templates';
|
||||
import type { BuildFile, BuildInput } from './cert-types';
|
||||
|
||||
const fxText = (name: string) => readFileSync(join(__dirname, '__fixtures__', name), 'utf8');
|
||||
const bytes = (file: BuildFile) => Buffer.from(file.content, 'base64');
|
||||
const text = (file: BuildFile) => bytes(file).toString('utf8');
|
||||
const PASSWORD = 'Neu-Pass-2026';
|
||||
|
||||
const SETS = {
|
||||
rsa: {
|
||||
cn: 'www.example.test',
|
||||
certPem: fxText('rsa-leaf.pem'),
|
||||
poolPems: [fxText('rsa-root.pem'), fxText('rsa-inter.pem')],
|
||||
keyPem: fxText('rsa-leaf-key.pem'),
|
||||
traditionalHeader: '-----BEGIN RSA PRIVATE KEY-----',
|
||||
},
|
||||
ec: {
|
||||
cn: 'ec.example.test',
|
||||
certPem: fxText('ec-leaf.pem'),
|
||||
poolPems: [fxText('ec-root.pem'), fxText('ec-inter.pem')],
|
||||
keyPem: fxText('ec-leaf-key.pem'),
|
||||
traditionalHeader: '-----BEGIN EC PRIVATE KEY-----',
|
||||
},
|
||||
} as const;
|
||||
|
||||
function codeOf(fn: () => unknown): { status: number; code: string } {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
const e = error as { getStatus(): number; getResponse(): { code: string } };
|
||||
return { status: e.getStatus(), code: e.getResponse().code };
|
||||
}
|
||||
throw new Error('expected a throw');
|
||||
}
|
||||
|
||||
function blocks(pem: string): string[] {
|
||||
return pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) ?? [];
|
||||
}
|
||||
|
||||
function cnsOf(pem: string): string[] {
|
||||
return blocks(pem).map((b) => String(new X509Certificate(b).toLegacyObject().subject.CN));
|
||||
}
|
||||
|
||||
function names(files: BuildFile[]): string[] {
|
||||
return files.map((f) => f.filename);
|
||||
}
|
||||
|
||||
function template(id: string, set: keyof typeof SETS, extra: Partial<BuildInput> = {}) {
|
||||
const s = SETS[set];
|
||||
return buildOutput({
|
||||
content: 'template',
|
||||
template: id,
|
||||
certPem: s.certPem,
|
||||
poolPems: [...s.poolPems],
|
||||
keyPem: s.keyPem,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
describe('Vorlagen: Kennungen', () => {
|
||||
it('kennt die sieben Zielsysteme', () => {
|
||||
expect([...TEMPLATE_IDS]).toEqual([
|
||||
'nginx',
|
||||
'apache',
|
||||
'apache-legacy',
|
||||
'iis',
|
||||
'npm',
|
||||
'haproxy',
|
||||
'tomcat',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(['rsa', 'ec'] as const)('Vorlagen (%s)', (set) => {
|
||||
const s = SETS[set];
|
||||
|
||||
it('nginx: fullchain.pem (Server, Zwischen) und privkey.pem (PKCS#8) plus Schnipsel mit Basisnamen', () => {
|
||||
const r = template('nginx', set);
|
||||
expect(names(r.files)).toEqual(['fullchain.pem', 'privkey.pem']);
|
||||
expect(cnsOf(text(r.files[0]))[0]).toBe(s.cn);
|
||||
expect(cnsOf(text(r.files[0]))).toHaveLength(2);
|
||||
expect(text(r.files[1])).toContain('-----BEGIN PRIVATE KEY-----');
|
||||
expect(new X509Certificate(s.certPem).checkPrivateKey(createPrivateKey(text(r.files[1])))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(r.snippet).toBe(
|
||||
`ssl_certificate /etc/nginx/ssl/${s.cn}/fullchain.pem;\nssl_certificate_key /etc/nginx/ssl/${s.cn}/privkey.pem;`,
|
||||
);
|
||||
expect(r.chainComplete).toBe(true);
|
||||
});
|
||||
|
||||
it('nginx mit Wurzel: drei Zertifikate, Wurzel zuletzt', () => {
|
||||
const r = template('nginx', set, { includeRoot: true });
|
||||
expect(cnsOf(text(r.files[0]))).toHaveLength(3);
|
||||
expect(cnsOf(text(r.files[0]))[2]).toContain('Root');
|
||||
});
|
||||
|
||||
it('apache (ab 2.4.8): fullchain.pem und privkey.pem, SSLCertificateFile und SSLCertificateKeyFile', () => {
|
||||
const r = template('apache', set);
|
||||
expect(names(r.files)).toEqual(['fullchain.pem', 'privkey.pem']);
|
||||
expect(r.snippet).toContain(`SSLCertificateFile /etc/ssl/${s.cn}/fullchain.pem`);
|
||||
expect(r.snippet).toContain(`SSLCertificateKeyFile /etc/ssl/${s.cn}/privkey.pem`);
|
||||
expect(r.snippet).not.toContain('SSLCertificateChainFile');
|
||||
});
|
||||
|
||||
it('apache-legacy: cert.pem (nur Server), chain.pem (nur Aussteller), privkey.pem', () => {
|
||||
const r = template('apache-legacy', set);
|
||||
expect(names(r.files)).toEqual(['cert.pem', 'chain.pem', 'privkey.pem']);
|
||||
expect(cnsOf(text(r.files[0]))).toEqual([s.cn]);
|
||||
expect(cnsOf(text(r.files[1]))).toHaveLength(1);
|
||||
expect(cnsOf(text(r.files[1]))[0]).toContain('Inter');
|
||||
expect(r.snippet).toContain('SSLCertificateChainFile');
|
||||
});
|
||||
|
||||
it('iis: <base>.pfx mit kompatibler Verschluesselung, mit dem Passwort lesbar, Schluessel passt', () => {
|
||||
const r = template('iis', set, { password: PASSWORD });
|
||||
expect(names(r.files)).toEqual([`${s.cn}.pfx`]);
|
||||
const der = bytes(r.files[0]);
|
||||
// 3DES-Schluesselbeutel (pbeWithSHA1And3-KeyTripleDES-CBC = 1.2.840.113549.1.12.1.3)
|
||||
expect(der.includes(Buffer.from('2a864886f70d010c0103', 'hex'))).toBe(true);
|
||||
const read = readPkcs12(der, [], PASSWORD);
|
||||
expect(read.ok).toBe(true);
|
||||
if (!read.ok) return;
|
||||
expect(new X509Certificate(s.certPem).checkPrivateKey(read.contents.keys[0].key)).toBe(true);
|
||||
expect(r.snippet).toBe(
|
||||
`Import-PfxCertificate -FilePath .\\${s.cn}.pfx -CertStoreLocation Cert:\\LocalMachine\\My -Password (Read-Host -AsSecureString)`,
|
||||
);
|
||||
expect(r.snippet).not.toContain(PASSWORD);
|
||||
});
|
||||
|
||||
it('npm: certificate.pem nur Server, intermediate.pem nur Zwischen, privkey.pem klassisch, kein Schnipsel', () => {
|
||||
const r = template('npm', set);
|
||||
expect(names(r.files)).toEqual(['certificate.pem', 'intermediate.pem', 'privkey.pem']);
|
||||
expect(cnsOf(text(r.files[0]))).toEqual([s.cn]);
|
||||
expect(cnsOf(text(r.files[1]))).toHaveLength(1);
|
||||
expect(cnsOf(text(r.files[1]))[0]).toContain('Inter');
|
||||
expect(text(r.files[2]).startsWith(s.traditionalHeader)).toBe(true);
|
||||
expect(r.snippet).toBeNull();
|
||||
const withRoot = template('npm', set, { includeRoot: true });
|
||||
expect(cnsOf(text(withRoot.files[1]))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('haproxy: eine Datei mit Server, Zwischen und Schluessel in dieser Reihenfolge', () => {
|
||||
const r = template('haproxy', set);
|
||||
expect(names(r.files)).toEqual([`${s.cn}.pem`]);
|
||||
const pem = text(r.files[0]);
|
||||
expect(cnsOf(pem)).toHaveLength(2);
|
||||
expect(cnsOf(pem)[0]).toBe(s.cn);
|
||||
expect(pem.indexOf('-----BEGIN PRIVATE KEY-----')).toBeGreaterThan(
|
||||
pem.lastIndexOf('-----END CERTIFICATE-----'),
|
||||
);
|
||||
expect(r.snippet).toBe(`bind :443 ssl crt /etc/haproxy/certs/${s.cn}.pem`);
|
||||
});
|
||||
|
||||
it('tomcat: <base>.p12, Anzeigename gleich Basisname, Schnipsel mit IHR-PASSWORT ohne das echte Passwort', () => {
|
||||
const r = template('tomcat', set, { password: PASSWORD });
|
||||
expect(names(r.files)).toEqual([`${s.cn}.p12`]);
|
||||
const read = readPkcs12(bytes(r.files[0]), [], PASSWORD);
|
||||
expect(read.ok).toBe(true);
|
||||
// friendlyName steckt verschluesselt im Container: mit forge und dem Passwort auslesen
|
||||
const p12 = forge.pkcs12.pkcs12FromAsn1(
|
||||
forge.asn1.fromDer(forge.util.createBuffer(bytes(r.files[0]).toString('binary'))),
|
||||
PASSWORD,
|
||||
);
|
||||
const aliases = p12.safeContents.flatMap((c) =>
|
||||
c.safeBags.flatMap((b) => (b.attributes?.friendlyName ?? []) as string[]),
|
||||
);
|
||||
// forge liefert den BMPString roh (UTF-16, big endian)
|
||||
const decoded = aliases.map((a) => Buffer.from(a, 'binary').swap16().toString('utf16le'));
|
||||
expect(decoded).toContain(s.cn);
|
||||
expect(r.snippet).toContain('IHR-PASSWORT');
|
||||
expect(r.snippet).toContain(`conf/${s.cn}.p12`);
|
||||
expect(r.snippet).toContain(`certificateKeyAlias="${s.cn}"`);
|
||||
expect(r.snippet).not.toContain(PASSWORD);
|
||||
});
|
||||
|
||||
it('iis mit Modern: AES-256 im Schluesselbeutel (gewaehlt, nicht Vorgabe)', () => {
|
||||
const r = template('iis', set, { password: PASSWORD, pfxEncryption: 'modern' });
|
||||
const read = readPkcs12(bytes(r.files[0]), [], PASSWORD);
|
||||
expect(read.ok).toBe(true);
|
||||
// PBES2 = 1.2.840.113549.1.5.13
|
||||
expect(bytes(r.files[0]).includes(Buffer.from('2a864886f70d01050d', 'hex'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vorlagen: Fehler', () => {
|
||||
it('ohne Schluessel: 400 templateNeedsKey fuer jede Vorlage', () => {
|
||||
for (const id of TEMPLATE_IDS) {
|
||||
expect(
|
||||
codeOf(() =>
|
||||
buildOutput({
|
||||
content: 'template',
|
||||
template: id,
|
||||
certPem: SETS.rsa.certPem,
|
||||
password: PASSWORD,
|
||||
}),
|
||||
),
|
||||
).toEqual({ status: 400, code: 'templateNeedsKey' });
|
||||
}
|
||||
});
|
||||
|
||||
it('falscher Schluessel: 400 keyMismatch', () => {
|
||||
expect(codeOf(() => template('nginx', 'rsa', { keyPem: SETS.ec.keyPem }))).toEqual({
|
||||
status: 400,
|
||||
code: 'keyMismatch',
|
||||
});
|
||||
});
|
||||
|
||||
it('iis und tomcat ohne Passwort: 400 passwordRequired', () => {
|
||||
for (const id of ['iis', 'tomcat']) {
|
||||
expect(codeOf(() => template(id, 'rsa'))).toEqual({ status: 400, code: 'passwordRequired' });
|
||||
expect(codeOf(() => template(id, 'rsa', { password: '' }))).toEqual({
|
||||
status: 400,
|
||||
code: 'passwordRequired',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('unbekannte oder fehlende Kennung: 400 invalidInput', () => {
|
||||
expect(codeOf(() => template('weblogic', 'rsa'))).toEqual({
|
||||
status: 400,
|
||||
code: 'invalidInput',
|
||||
});
|
||||
expect(
|
||||
codeOf(() =>
|
||||
buildOutput({
|
||||
content: 'template',
|
||||
certPem: SETS.rsa.certPem,
|
||||
keyPem: SETS.rsa.keyPem,
|
||||
}),
|
||||
),
|
||||
).toEqual({ status: 400, code: 'invalidInput' });
|
||||
});
|
||||
|
||||
it('Kennung ohne Zertifikat: 400 invalidInput', () => {
|
||||
expect(codeOf(() => buildOutput({ content: 'template', template: 'nginx' }))).toEqual({
|
||||
status: 400,
|
||||
code: 'invalidInput',
|
||||
});
|
||||
});
|
||||
|
||||
it('Antwort und Fehler enthalten weder Passwort noch Schluesseltext', () => {
|
||||
const r = template('tomcat', 'rsa', { password: PASSWORD });
|
||||
expect(JSON.stringify(r.snippet)).not.toContain(PASSWORD);
|
||||
let message = '';
|
||||
try {
|
||||
template('iis', 'rsa', { keyPem: 'kein Schluessel', password: PASSWORD });
|
||||
} catch (error) {
|
||||
message = JSON.stringify((error as { getResponse(): unknown }).getResponse());
|
||||
}
|
||||
expect(message).not.toContain(PASSWORD);
|
||||
expect(message).not.toContain('kein Schluessel');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { KeyObject } from 'node:crypto';
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import { exportKey } from './cert-keys';
|
||||
import { type Pkcs12Profile, writePkcs12 } from './cert-pkcs12';
|
||||
import { type BuildFile, type CertItem, certError } from './cert-types';
|
||||
|
||||
/**
|
||||
* Vorlagen fuer Zielsysteme (quick-261009-ikt, D-04, D-21). Eine Vorlage liefert die fertigen Dateien
|
||||
* und einen Konfigurationsschnipsel; mehrere Dateien packt der Browser zu einer ZIP-Datei (fflate).
|
||||
* Alle Vorlagen brauchen das Serverzertifikat und den passenden privaten Schluessel (der Aufrufer
|
||||
* hat beides schon geprueft). Der Schluessel liegt in den Dateien unverschluesselt, ausser in PFX-
|
||||
* Dateien (Passwort). Die Wurzel kommt nur mit, wenn `shown` sie enthaelt (Haken „Root-Zertifikat
|
||||
* mitnehmen“). Das Passwort erscheint nie in einem Schnipsel.
|
||||
*
|
||||
* Diese Datei kennt cert-output.ts nicht (sonst entstuende eine Importschleife); die paar Hilfen
|
||||
* fuer Dateien und PEM-Bloecke stehen deshalb hier noch einmal in Kurzform.
|
||||
*/
|
||||
|
||||
export const TEMPLATE_IDS = [
|
||||
'nginx',
|
||||
'apache',
|
||||
'apache-legacy',
|
||||
'iis',
|
||||
'npm',
|
||||
'haproxy',
|
||||
'tomcat',
|
||||
] as const;
|
||||
export type TemplateId = (typeof TEMPLATE_IDS)[number];
|
||||
|
||||
export interface TemplateContext {
|
||||
/** Serverzertifikat */
|
||||
head: CertItem;
|
||||
/** Kette in Reihenfolge: Serverzertifikat zuerst, dann die Aussteller (Wurzel nur auf Wunsch) */
|
||||
shown: CertItem[];
|
||||
/** der zum Serverzertifikat gehoerende private Schluessel */
|
||||
key: KeyObject;
|
||||
/** sicherer Basisname fuer Dateinamen und Schnipsel */
|
||||
base: string;
|
||||
password?: string;
|
||||
pfxProfile: Pkcs12Profile;
|
||||
}
|
||||
|
||||
export interface TemplateResult {
|
||||
files: BuildFile[];
|
||||
snippet: string | null;
|
||||
}
|
||||
|
||||
const MIME_PEM = 'application/x-pem-file';
|
||||
const MIME_PFX = 'application/x-pkcs12';
|
||||
|
||||
export function isTemplateId(value: unknown): value is TemplateId {
|
||||
return typeof value === 'string' && (TEMPLATE_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function file(filename: string, data: Buffer | string, mimeType: string): BuildFile {
|
||||
const bytes = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
|
||||
return { filename, content: bytes.toString('base64'), mimeType };
|
||||
}
|
||||
|
||||
function joinPem(certs: CertItem[]): string {
|
||||
return certs.map((c) => `${c.pem.trim()}\n`).join('');
|
||||
}
|
||||
|
||||
function derOf(cert: CertItem): Buffer {
|
||||
return new X509Certificate(cert.pem).raw;
|
||||
}
|
||||
|
||||
function pkcs8Pem(key: KeyObject): string {
|
||||
return exportKey(key, 'pkcs8').toString('utf8');
|
||||
}
|
||||
|
||||
function requirePassword(ctx: TemplateContext): string {
|
||||
if (!ctx.password) certError('passwordRequired', 400, 'A password is required');
|
||||
return ctx.password;
|
||||
}
|
||||
|
||||
function pfxFile(ctx: TemplateContext, extension: string): BuildFile {
|
||||
const der = writePkcs12({
|
||||
keyObject: ctx.key,
|
||||
certDers: ctx.shown.map(derOf),
|
||||
password: requirePassword(ctx),
|
||||
profile: ctx.pfxProfile,
|
||||
friendlyName: ctx.base,
|
||||
});
|
||||
return file(`${ctx.base}.${extension}`, der, MIME_PFX);
|
||||
}
|
||||
|
||||
/** Bauen je Vorlage. Wirft Nest-Ausnahmen mit Code; unbekannte Kennung ergibt invalidInput. */
|
||||
export function buildTemplate(id: unknown, ctx: TemplateContext): TemplateResult {
|
||||
if (!isTemplateId(id)) certError('invalidInput', 400, 'Unknown template');
|
||||
const { base } = ctx;
|
||||
const issuers = ctx.shown.filter((c) => c.id !== ctx.head.id);
|
||||
|
||||
switch (id) {
|
||||
case 'nginx':
|
||||
return {
|
||||
files: [
|
||||
file('fullchain.pem', joinPem(ctx.shown), MIME_PEM),
|
||||
file('privkey.pem', pkcs8Pem(ctx.key), MIME_PEM),
|
||||
],
|
||||
snippet: [
|
||||
`ssl_certificate /etc/nginx/ssl/${base}/fullchain.pem;`,
|
||||
`ssl_certificate_key /etc/nginx/ssl/${base}/privkey.pem;`,
|
||||
].join('\n'),
|
||||
};
|
||||
case 'apache':
|
||||
return {
|
||||
files: [
|
||||
file('fullchain.pem', joinPem(ctx.shown), MIME_PEM),
|
||||
file('privkey.pem', pkcs8Pem(ctx.key), MIME_PEM),
|
||||
],
|
||||
snippet: [
|
||||
`SSLCertificateFile /etc/ssl/${base}/fullchain.pem`,
|
||||
`SSLCertificateKeyFile /etc/ssl/${base}/privkey.pem`,
|
||||
].join('\n'),
|
||||
};
|
||||
case 'apache-legacy': {
|
||||
// Ohne Aussteller gibt es keine Kettendatei; dann entfallen Datei und Zeile.
|
||||
const files = [file('cert.pem', joinPem([ctx.head]), MIME_PEM)];
|
||||
const lines = [
|
||||
`SSLCertificateFile /etc/ssl/${base}/cert.pem`,
|
||||
`SSLCertificateKeyFile /etc/ssl/${base}/privkey.pem`,
|
||||
];
|
||||
if (issuers.length > 0) {
|
||||
files.push(file('chain.pem', joinPem(issuers), MIME_PEM));
|
||||
lines.push(`SSLCertificateChainFile /etc/ssl/${base}/chain.pem`);
|
||||
}
|
||||
files.push(file('privkey.pem', pkcs8Pem(ctx.key), MIME_PEM));
|
||||
return { files, snippet: lines.join('\n') };
|
||||
}
|
||||
case 'iis':
|
||||
return {
|
||||
files: [pfxFile(ctx, 'pfx')],
|
||||
snippet: `Import-PfxCertificate -FilePath .\\${base}.pfx -CertStoreLocation Cert:\\LocalMachine\\My -Password (Read-Host -AsSecureString)`,
|
||||
};
|
||||
case 'npm': {
|
||||
// Nginx Proxy Manager verlangt bei RSA oft „RSA PRIVATE KEY“ (PKCS#1), bei EC „EC PRIVATE KEY“ (SEC1).
|
||||
let keyPem: string;
|
||||
try {
|
||||
keyPem = exportKey(ctx.key, 'traditional').toString('utf8');
|
||||
} catch {
|
||||
keyPem = pkcs8Pem(ctx.key);
|
||||
}
|
||||
const files = [file('certificate.pem', joinPem([ctx.head]), MIME_PEM)];
|
||||
if (issuers.length > 0) files.push(file('intermediate.pem', joinPem(issuers), MIME_PEM));
|
||||
files.push(file('privkey.pem', keyPem, MIME_PEM));
|
||||
return { files, snippet: null };
|
||||
}
|
||||
case 'haproxy':
|
||||
return {
|
||||
files: [file(`${base}.pem`, joinPem(ctx.shown) + pkcs8Pem(ctx.key), MIME_PEM)],
|
||||
snippet: `bind :443 ssl crt /etc/haproxy/certs/${base}.pem`,
|
||||
};
|
||||
case 'tomcat':
|
||||
return {
|
||||
files: [pfxFile(ctx, 'p12')],
|
||||
snippet: `<Certificate certificateKeystoreFile="conf/${base}.p12" certificateKeystorePassword="IHR-PASSWORT" certificateKeystoreType="PKCS12" certificateKeyAlias="${base}" />`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,15 @@ export interface AnalysisResult {
|
||||
ignored: IgnoredEntry[];
|
||||
}
|
||||
|
||||
export type BuildContent = 'leaf' | 'fullchain' | 'chain' | 'leafKey' | 'pfx' | 'key' | 'csr';
|
||||
export type BuildContent =
|
||||
| 'leaf'
|
||||
| 'fullchain'
|
||||
| 'chain'
|
||||
| 'leafKey'
|
||||
| 'pfx'
|
||||
| 'key'
|
||||
| 'csr'
|
||||
| 'template';
|
||||
|
||||
export interface BuildInput {
|
||||
content: BuildContent;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { TEMPLATE_IDS } from '../cert-templates';
|
||||
|
||||
/**
|
||||
* Anfrage fuer POST build (quick-261009-ikt, D-19). Die Obergrenzen stehen hier als Konstanten,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
* + password 256 + baseName 120 + JSON-Maskierung der Zeilenumbrueche (etwa +1,6 %)
|
||||
* = rund 384 kB, also deutlich unter dem Grenzwert von 512 KiB.
|
||||
*
|
||||
* Stand Task 5: alle Inhalte und Formate aus D-19; die Vorlage (template) folgt in Task 6.
|
||||
* Stand Task 6: alle Inhalte und Formate aus D-19 sowie die Vorlage (template, nur eine Kennung).
|
||||
*/
|
||||
export const CERT_PEM_MAX = 16_384;
|
||||
export const CERT_POOL_MAX = 20;
|
||||
@@ -31,6 +32,7 @@ export const BUILD_CONTENTS = [
|
||||
'pfx',
|
||||
'key',
|
||||
'csr',
|
||||
'template',
|
||||
] as const;
|
||||
export const BUILD_FORMATS = [
|
||||
'pem',
|
||||
@@ -91,6 +93,10 @@ export class BuildOutputDto {
|
||||
@IsIn(BUILD_PFX_ENCRYPTIONS)
|
||||
pfxEncryption?: (typeof BUILD_PFX_ENCRYPTIONS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(TEMPLATE_IDS)
|
||||
template?: (typeof TEMPLATE_IDS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(CERT_BASENAME_MAX)
|
||||
|
||||
Reference in New Issue
Block a user