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)
|
||||
|
||||
@@ -124,7 +124,25 @@ 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';
|
||||
|
||||
/** Kennungen der Vorlagen (Zielsysteme); die API prueft sie gegen dieselbe Liste. */
|
||||
export type TemplateId =
|
||||
| 'nginx'
|
||||
| 'apache'
|
||||
| 'apache-legacy'
|
||||
| 'iis'
|
||||
| 'npm'
|
||||
| 'haproxy'
|
||||
| 'tomcat';
|
||||
|
||||
/** Anfrage fuer POST build (D-19); die API baut die Reihenfolge immer selbst aus den gesendeten Zertifikaten. */
|
||||
export interface BuildInput {
|
||||
@@ -138,7 +156,7 @@ export interface BuildInput {
|
||||
includeChain?: boolean;
|
||||
password?: string;
|
||||
pfxEncryption?: 'compat' | 'modern';
|
||||
template?: string;
|
||||
template?: TemplateId;
|
||||
baseName?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ beforeEach(() => {
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('CertManagerPage', () => {
|
||||
it('zeigt Titel und die Reiter in der Reihenfolge Dateien, Analysieren, Aufteilen, Zusammenführen, Konvertieren', () => {
|
||||
it('zeigt Titel und die Reiter in der Reihenfolge Dateien, Analysieren, Aufteilen, Zusammenführen, Konvertieren, Vorlagen', () => {
|
||||
render(<CertManagerPage />);
|
||||
expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument();
|
||||
const nav = screen.getByRole('navigation');
|
||||
@@ -54,6 +54,7 @@ describe('CertManagerPage', () => {
|
||||
'Aufteilen',
|
||||
'Zusammenführen',
|
||||
'Konvertieren',
|
||||
'Vorlagen',
|
||||
]);
|
||||
expect(tabs[0]).toHaveAttribute('aria-current', 'page');
|
||||
for (const tab of tabs.slice(1)) expect(tab).not.toHaveAttribute('aria-current');
|
||||
@@ -94,6 +95,7 @@ describe('CertManagerPage', () => {
|
||||
'Analysieren',
|
||||
'Aufteilen',
|
||||
'Konvertieren',
|
||||
'Vorlagen',
|
||||
])('%s mit leerem Arbeitsbereich: Hinweis auf den Reiter „Dateien“', (tab) => {
|
||||
render(<CertManagerPage />);
|
||||
fireEvent.click(screen.getByRole('button', { name: tab }));
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
|
||||
import { strFromU8, unzipSync } from 'fflate';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import de from '@/messages/de.json';
|
||||
import type { AnalysisResult, CertItem, ChainInfo, KeyItem } from '../actions';
|
||||
import { CertManagerRequestError } from '../actions';
|
||||
import type { CertWorkspace } from '../use-cert-workspace';
|
||||
import { TemplatesTab } from './TemplatesTab';
|
||||
|
||||
const mockBuild = vi.fn();
|
||||
const mockDownload = vi.fn();
|
||||
|
||||
vi.mock('../actions', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../actions')>();
|
||||
return {
|
||||
...actual,
|
||||
buildOutput: (...args: unknown[]) => mockBuild(...args),
|
||||
downloadBase64: (...args: unknown[]) => mockDownload(...args),
|
||||
};
|
||||
});
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function cert(id: string, cn: string, role: CertItem['role'], issuerCn: string): CertItem {
|
||||
return {
|
||||
id,
|
||||
kind: 'certificate',
|
||||
role,
|
||||
sources: [{ file: 0, path: `${id}.pem` }],
|
||||
pem: `PEM-${id}`,
|
||||
baseName: cn.replace(/\s/g, '_'),
|
||||
cn,
|
||||
organization: '',
|
||||
issuerCn,
|
||||
issuerOrganization: '',
|
||||
notBefore: '2026-01-01T00:00:00.000Z',
|
||||
notAfter: '2126-01-01T00:00:00.000Z',
|
||||
isExpired: false,
|
||||
daysLeft: 36000,
|
||||
san: [],
|
||||
keyType: 'RSA',
|
||||
keyBits: 2048,
|
||||
curve: null,
|
||||
serialNumber: '01',
|
||||
sha256: '',
|
||||
sha1: '',
|
||||
isCa: role !== 'end-entity',
|
||||
selfSigned: role === 'root',
|
||||
aiaIssuerUrls: [],
|
||||
keyId: null,
|
||||
csrIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const leaf = cert('c-leaf', 'www.example.test', 'end-entity', 'Test Inter');
|
||||
const keyedLeaf = { ...leaf, keyId: 'k-leaf' };
|
||||
const inter = cert('c-inter', 'Test Inter', 'intermediate', 'Test Root');
|
||||
const root = cert('c-root', 'Test Root', 'root', 'Test Root');
|
||||
const key: KeyItem = {
|
||||
id: 'k-leaf',
|
||||
kind: 'privateKey',
|
||||
sources: [{ file: 1, path: 'key.pem' }],
|
||||
pem: 'PEM-KEY',
|
||||
baseName: 'schluessel',
|
||||
keyType: 'RSA',
|
||||
keyBits: 2048,
|
||||
curve: null,
|
||||
wasEncrypted: false,
|
||||
certIds: ['c-leaf'],
|
||||
};
|
||||
|
||||
const completeChain: ChainInfo = {
|
||||
headId: 'c-leaf',
|
||||
path: ['c-leaf', 'c-inter', 'c-root'],
|
||||
rootId: 'c-root',
|
||||
complete: true,
|
||||
gap: null,
|
||||
alternatives: 0,
|
||||
};
|
||||
|
||||
function workspace(items: (CertItem | KeyItem)[], chains: ChainInfo[]): CertWorkspace {
|
||||
const analysis: AnalysisResult = { items, chains, locked: [], ignored: [] };
|
||||
return {
|
||||
entries: [],
|
||||
analysis,
|
||||
analysisIds: [],
|
||||
status: 'idle',
|
||||
errorKey: null,
|
||||
addFiles: () => [],
|
||||
addText: () => null,
|
||||
setPassword: () => {},
|
||||
remove: () => {},
|
||||
clear: () => {},
|
||||
retry: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const withKey = () => workspace([keyedLeaf, inter, root, key], [completeChain]);
|
||||
const b64 = (text: string) => btoa(text);
|
||||
|
||||
function nginxResult() {
|
||||
return {
|
||||
files: [
|
||||
{ filename: 'fullchain.pem', content: b64('FULLCHAIN'), mimeType: 'application/x-pem-file' },
|
||||
{ filename: 'privkey.pem', content: b64('PRIVKEY'), mimeType: 'application/x-pem-file' },
|
||||
],
|
||||
chainComplete: true,
|
||||
missingIssuerCn: null,
|
||||
snippet: 'ssl_certificate /etc/nginx/ssl/www.example.test/fullchain.pem;',
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockBuild.mockReset();
|
||||
mockDownload.mockReset();
|
||||
mockBuild.mockResolvedValue(nginxResult());
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('TemplatesTab', () => {
|
||||
it('zeigt sieben Karten in fester Reihenfolge', () => {
|
||||
render(<TemplatesTab workspace={withKey()} />);
|
||||
const titles = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent);
|
||||
expect(titles).toEqual([
|
||||
'Nginx',
|
||||
'Apache 2.4.8 und neuer',
|
||||
'Apache älter als 2.4.8',
|
||||
'Windows / IIS',
|
||||
'Nginx Proxy Manager',
|
||||
'HAProxy',
|
||||
'Tomcat / Java',
|
||||
]);
|
||||
expect(screen.getAllByText(/Das erhalten Sie/)).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('der Haken „Root-Zertifikat mitnehmen“ ist zunaechst aus', () => {
|
||||
render(<TemplatesTab workspace={withKey()} />);
|
||||
expect(screen.getByRole('checkbox', { name: 'Root-Zertifikat mitnehmen' })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('ohne passenden Schluessel sind alle Knoepfe gesperrt und der Grund steht da', () => {
|
||||
render(<TemplatesTab workspace={workspace([leaf, inter, root], [completeChain])} />);
|
||||
expect(screen.getByText(/noch kein passender privater Schlüssel/)).toBeInTheDocument();
|
||||
for (const button of screen.getAllByRole('button', { name: /^Vorlage herunterladen/ })) {
|
||||
expect(button).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it('ohne erkannte Zertifikate steht ein Hinweis statt der Karten', () => {
|
||||
render(<TemplatesTab workspace={workspace([], [])} />);
|
||||
expect(screen.getByText(/noch kein Zertifikat erkannt/)).toBeInTheDocument();
|
||||
expect(screen.queryAllByRole('heading', { level: 3 })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Nginx: eine Anfrage mit Vorlage und Schluessel, Ergebnis als ZIP mit Anleitung', async () => {
|
||||
render(<TemplatesTab workspace={withKey()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Vorlage herunterladen: Nginx' }));
|
||||
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
|
||||
expect(mockBuild).toHaveBeenCalledWith({
|
||||
content: 'template',
|
||||
template: 'nginx',
|
||||
certPem: 'PEM-c-leaf',
|
||||
poolPems: ['PEM-c-inter', 'PEM-c-root'],
|
||||
keyPem: 'PEM-KEY',
|
||||
includeRoot: false,
|
||||
baseName: 'www.example.test',
|
||||
});
|
||||
const [name, content, mime] = mockDownload.mock.calls[0];
|
||||
expect(name).toBe('www.example.test-nginx.zip');
|
||||
expect(mime).toBe('application/zip');
|
||||
const bytes = Uint8Array.from(atob(content as string), (c) => c.charCodeAt(0));
|
||||
const files = unzipSync(bytes);
|
||||
expect(Object.keys(files).sort()).toEqual(['ANLEITUNG.txt', 'fullchain.pem', 'privkey.pem']);
|
||||
expect(strFromU8(files['fullchain.pem'])).toBe('FULLCHAIN');
|
||||
const guide = strFromU8(files['ANLEITUNG.txt']);
|
||||
expect(guide).toContain('Anleitung für Nginx');
|
||||
expect(guide).toContain('1. Kopieren Sie beide Dateien');
|
||||
expect(guide).toContain('ssl_certificate /etc/nginx/ssl/www.example.test/fullchain.pem;');
|
||||
});
|
||||
|
||||
it('mit angehaktem Root wird includeRoot gesendet', async () => {
|
||||
render(<TemplatesTab workspace={withKey()} />);
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Root-Zertifikat mitnehmen' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Vorlage herunterladen: Nginx' }));
|
||||
await waitFor(() => expect(mockBuild).toHaveBeenCalled());
|
||||
expect(mockBuild.mock.calls[0][0]).toMatchObject({ includeRoot: true });
|
||||
});
|
||||
|
||||
it('danach steht der Schnipsel da, und Kopieren schreibt ihn in die Zwischenablage', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } });
|
||||
render(<TemplatesTab workspace={withKey()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Vorlage herunterladen: Nginx' }));
|
||||
expect(await screen.findByText(/^ssl_certificate \/etc\/nginx/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Kopieren' }));
|
||||
await waitFor(() =>
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
'ssl_certificate /etc/nginx/ssl/www.example.test/fullchain.pem;',
|
||||
),
|
||||
);
|
||||
expect(await screen.findByRole('button', { name: 'Kopiert' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('HAProxy: eine Datei wird direkt geladen, ohne ZIP', async () => {
|
||||
mockBuild.mockResolvedValueOnce({
|
||||
files: [{ filename: 'www.example.test.pem', content: b64('X'), mimeType: 'text/plain' }],
|
||||
chainComplete: true,
|
||||
missingIssuerCn: null,
|
||||
snippet: 'bind :443 ssl crt /etc/haproxy/certs/www.example.test.pem',
|
||||
});
|
||||
render(<TemplatesTab workspace={withKey()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Vorlage herunterladen: HAProxy' }));
|
||||
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
|
||||
expect(mockDownload).toHaveBeenCalledWith('www.example.test.pem', b64('X'), 'text/plain');
|
||||
expect(await screen.findByText(/^bind :443 ssl crt/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Nginx Proxy Manager: ZIP ohne Schnipsel, die Schritte nennen Custom und die Felder', async () => {
|
||||
mockBuild.mockResolvedValueOnce({
|
||||
files: [
|
||||
{ filename: 'certificate.pem', content: b64('C'), mimeType: 'text/plain' },
|
||||
{ filename: 'privkey.pem', content: b64('K'), mimeType: 'text/plain' },
|
||||
],
|
||||
chainComplete: true,
|
||||
missingIssuerCn: null,
|
||||
snippet: null,
|
||||
});
|
||||
render(<TemplatesTab workspace={withKey()} />);
|
||||
expect(screen.getByText(/Wählen Sie „Custom“/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/bei „Certificate Key“ die Datei privkey\.pem/)).toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Vorlage herunterladen: Nginx Proxy Manager' }),
|
||||
);
|
||||
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
|
||||
expect(mockDownload.mock.calls[0][0]).toBe('www.example.test-npm.zip');
|
||||
expect(screen.queryByText('Zeilen für die Einrichtung')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('IIS: Kompatibel ist vorgewaehlt, ohne Passwort gesperrt, dann Anfrage mit Passwort', async () => {
|
||||
mockBuild.mockResolvedValueOnce({
|
||||
files: [
|
||||
{ filename: 'www.example.test.pfx', content: b64('P'), mimeType: 'application/x-pkcs12' },
|
||||
],
|
||||
chainComplete: true,
|
||||
missingIssuerCn: null,
|
||||
snippet: 'Import-PfxCertificate -FilePath .\\www.example.test.pfx',
|
||||
});
|
||||
render(<TemplatesTab workspace={withKey()} />);
|
||||
const encryptions = screen.getAllByRole('combobox', { name: 'Verschlüsselung' });
|
||||
expect(encryptions).toHaveLength(2); // IIS und Tomcat
|
||||
expect((encryptions[0] as HTMLSelectElement).value).toBe('compat');
|
||||
const button = screen.getByRole('button', { name: 'Vorlage herunterladen: Windows / IIS' });
|
||||
expect(button).toBeDisabled();
|
||||
const passwords = screen.getAllByLabelText('PFX-Passwort');
|
||||
const repeats = screen.getAllByLabelText('Passwort wiederholen');
|
||||
fireEvent.change(passwords[0], { target: { value: 'Neu-Pass-2026' } });
|
||||
fireEvent.change(repeats[0], { target: { value: 'Neu-Pass-2026' } });
|
||||
expect(button).toBeEnabled();
|
||||
// Tomcat bleibt gesperrt: eigenes Passwort je Karte
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Vorlage herunterladen: Tomcat / Java' }),
|
||||
).toBeDisabled();
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(mockBuild).toHaveBeenCalled());
|
||||
expect(mockBuild.mock.calls[0][0]).toMatchObject({
|
||||
content: 'template',
|
||||
template: 'iis',
|
||||
password: 'Neu-Pass-2026',
|
||||
pfxEncryption: 'compat',
|
||||
});
|
||||
expect(mockDownload).toHaveBeenCalledWith(
|
||||
'www.example.test.pfx',
|
||||
b64('P'),
|
||||
'application/x-pkcs12',
|
||||
);
|
||||
// das Passwort steht nirgends im dargestellten Text
|
||||
expect(document.body.textContent).not.toContain('Neu-Pass-2026');
|
||||
});
|
||||
|
||||
it('ein Fehlercode der API zeigt den deutschen Text', async () => {
|
||||
mockBuild.mockRejectedValueOnce(new CertManagerRequestError(400, 'keyMismatch'));
|
||||
render(<TemplatesTab workspace={withKey()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Vorlage herunterladen: Nginx' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Der Schlüssel gehört nicht');
|
||||
expect(mockDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
'use client';
|
||||
|
||||
import { strFromU8, strToU8, zipSync } from 'fflate';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
type BuildFile,
|
||||
buildOutput,
|
||||
type CertItem,
|
||||
certErrorKey,
|
||||
downloadBase64,
|
||||
type KeyItem,
|
||||
type TemplateId,
|
||||
} from '../actions';
|
||||
import type { CertWorkspace } from '../use-cert-workspace';
|
||||
import { sanitizeZipFilename } from '../zip-filename';
|
||||
import { ChainView } from './ChainView';
|
||||
import { type PfxEncryption, PfxOptions, pfxPasswordReady } from './PfxOptions';
|
||||
|
||||
interface TemplatesTabProps {
|
||||
workspace: CertWorkspace;
|
||||
}
|
||||
|
||||
/** Reihenfolge der Karten; `messageKey` ist der Zweig unter certManager.templates, `steps` die Zahl der Schritte. */
|
||||
const TEMPLATES: { id: TemplateId; messageKey: string; steps: number; pfx: boolean }[] = [
|
||||
{ id: 'nginx', messageKey: 'nginx', steps: 3, pfx: false },
|
||||
{ id: 'apache', messageKey: 'apache', steps: 3, pfx: false },
|
||||
{ id: 'apache-legacy', messageKey: 'apacheLegacy', steps: 3, pfx: false },
|
||||
{ id: 'iis', messageKey: 'iis', steps: 3, pfx: true },
|
||||
{ id: 'npm', messageKey: 'npm', steps: 3, pfx: false },
|
||||
{ id: 'haproxy', messageKey: 'haproxy', steps: 3, pfx: false },
|
||||
{ id: 'tomcat', messageKey: 'tomcat', steps: 3, pfx: true },
|
||||
];
|
||||
|
||||
interface PfxChoice {
|
||||
password: string;
|
||||
encryption: PfxEncryption;
|
||||
}
|
||||
|
||||
function bytesOf(file: BuildFile): Uint8Array {
|
||||
const raw = atob(file.content);
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Mehrere Dateien: ZIP aus den Dateien der API plus der Anleitung als Textdatei. */
|
||||
function zipBase64(files: BuildFile[], instructionsName: string, instructions: string): string {
|
||||
const entries: Record<string, Uint8Array> = {};
|
||||
for (const file of files) entries[file.filename] = bytesOf(file);
|
||||
entries[instructionsName] = strToU8(instructions);
|
||||
return btoa(strFromU8(zipSync(entries), true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiter „Vorlagen“: ein Klick liefert die Dateien fuer ein Zielsystem (Nginx, Apache, Windows/IIS,
|
||||
* Nginx Proxy Manager, HAProxy, Tomcat) und zeigt die Zeilen fuer die Einrichtung. Alle Vorlagen
|
||||
* brauchen das Serverzertifikat und den passenden privaten Schluessel. Die API baut die Dateien
|
||||
* (und ordnet die Kette selbst); mehrere Dateien packt der Browser mit einer Anleitung in eine ZIP-Datei.
|
||||
* PFX-Passwoerter bleiben im Zustand der Karte und gehen nur in die Anfrage.
|
||||
*/
|
||||
export function TemplatesTab({ workspace }: TemplatesTabProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const [selectedHead, setSelectedHead] = useState<string | null>(null);
|
||||
const [includeRoot, setIncludeRoot] = useState(false);
|
||||
const [busy, setBusy] = useState<TemplateId | null>(null);
|
||||
const [errorKey, setErrorKey] = useState<string | null>(null);
|
||||
const [shown, setShown] = useState<{ id: TemplateId; snippet: string } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const certs = (workspace.analysis?.items ?? []).filter(
|
||||
(i): i is CertItem => i.kind === 'certificate',
|
||||
);
|
||||
const keys = (workspace.analysis?.items ?? []).filter(
|
||||
(i): i is KeyItem => i.kind === 'privateKey',
|
||||
);
|
||||
const chains = workspace.analysis?.chains ?? [];
|
||||
|
||||
if (workspace.status === 'analyzing' && !workspace.analysis) {
|
||||
return (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{t('files.analyzing')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (chains.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{t('merge.noCertificates')}</p>;
|
||||
}
|
||||
|
||||
const chain = chains.find((c) => c.headId === selectedHead) ?? chains[0];
|
||||
const head = certs.find((c) => c.id === chain.headId);
|
||||
const members = chain.path
|
||||
.map((id) => certs.find((c) => c.id === id))
|
||||
.filter((c): c is CertItem => c !== undefined);
|
||||
const hasRoot = chain.rootId !== null;
|
||||
const key = head?.keyId ? keys.find((k) => k.id === head.keyId) : undefined;
|
||||
|
||||
const instructionsText = (
|
||||
template: (typeof TEMPLATES)[number],
|
||||
files: BuildFile[],
|
||||
snippet: string,
|
||||
) => {
|
||||
const lines = [
|
||||
t('templates.instructionsHeading', { name: t(`templates.${template.messageKey}.title`) }),
|
||||
'',
|
||||
];
|
||||
lines.push(`${t('templates.instructionsFiles')}:`);
|
||||
for (const file of files) lines.push(`- ${file.filename}`);
|
||||
lines.push('', `${t('templates.stepsTitle')}:`);
|
||||
for (let n = 1; n <= template.steps; n++) {
|
||||
lines.push(`${n}. ${t(`templates.${template.messageKey}.steps.${n}`)}`);
|
||||
}
|
||||
if (snippet) lines.push('', `${t('templates.instructionsSnippet')}:`, snippet);
|
||||
return `${lines.join('\n')}\n`;
|
||||
};
|
||||
|
||||
const download = async (template: (typeof TEMPLATES)[number], pfx: PfxChoice | null) => {
|
||||
if (!head || !key) return;
|
||||
setBusy(template.id);
|
||||
setErrorKey(null);
|
||||
setShown(null);
|
||||
setCopied(false);
|
||||
try {
|
||||
const result = await buildOutput({
|
||||
content: 'template',
|
||||
template: template.id,
|
||||
certPem: head.pem,
|
||||
poolPems: members.filter((c) => c.id !== head.id).map((c) => c.pem),
|
||||
keyPem: key.pem,
|
||||
includeRoot,
|
||||
baseName: head.baseName,
|
||||
...(pfx ? { password: pfx.password, pfxEncryption: pfx.encryption } : {}),
|
||||
});
|
||||
const snippet = result.snippet ?? '';
|
||||
if (result.files.length > 1) {
|
||||
const zipName = sanitizeZipFilename(`${head.baseName}-${template.id}.zip`);
|
||||
downloadBase64(
|
||||
zipName,
|
||||
zipBase64(
|
||||
result.files,
|
||||
t('templates.instructionsFile'),
|
||||
instructionsText(template, result.files, snippet),
|
||||
),
|
||||
'application/zip',
|
||||
);
|
||||
} else {
|
||||
for (const file of result.files) downloadBase64(file.filename, file.content, file.mimeType);
|
||||
}
|
||||
if (snippet) setShown({ id: template.id, snippet });
|
||||
} catch (error) {
|
||||
setErrorKey(certErrorKey(error));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async (snippet: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(snippet);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-muted-foreground">{t('templates.intro')}</p>
|
||||
|
||||
{chains.length > 1 && (
|
||||
<fieldset className="space-y-1">
|
||||
<legend className="text-sm font-semibold text-foreground">
|
||||
{t('templates.chooseHead')}
|
||||
</legend>
|
||||
{chains.map((c) => {
|
||||
const cert = certs.find((x) => x.id === c.headId);
|
||||
return (
|
||||
<label key={c.headId} className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="cert-template-head"
|
||||
checked={c.headId === chain.headId}
|
||||
onChange={() => setSelectedHead(c.headId)}
|
||||
/>
|
||||
<span className="break-all">{cert?.cn || cert?.baseName}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<ChainView chain={chain} certs={certs} />
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeRoot && hasRoot}
|
||||
disabled={!hasRoot}
|
||||
onChange={(e) => setIncludeRoot(e.target.checked)}
|
||||
/>
|
||||
{t('merge.includeRoot')}
|
||||
</label>
|
||||
{!hasRoot && (
|
||||
<p className="pl-6 text-xs text-muted-foreground">{t('merge.noRootAvailable')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!key && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-lg border border-border bg-muted p-3 text-sm text-foreground"
|
||||
>
|
||||
{t('templates.needsKey')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="space-y-3">
|
||||
{TEMPLATES.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
disabled={!key || busy !== null}
|
||||
busy={busy === template.id}
|
||||
onDownload={(pfx) => download(template, pfx)}
|
||||
snippet={shown?.id === template.id ? shown.snippet : null}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{errorKey && (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-lg border border-border bg-muted p-3 text-sm text-foreground"
|
||||
>
|
||||
{t(`errors.${errorKey}`)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: (typeof TEMPLATES)[number];
|
||||
disabled: boolean;
|
||||
busy: boolean;
|
||||
onDownload: (pfx: PfxChoice | null) => void;
|
||||
snippet: string | null;
|
||||
copied: boolean;
|
||||
onCopy: (snippet: string) => void;
|
||||
}
|
||||
|
||||
/** Eine Vorlage: Titel, was geliefert wird, die Schritte, bei PFX-Vorlagen Passwort und Verschluesselung. */
|
||||
function TemplateCard({
|
||||
template,
|
||||
disabled,
|
||||
busy,
|
||||
onDownload,
|
||||
snippet,
|
||||
copied,
|
||||
onCopy,
|
||||
}: TemplateCardProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const [password, setPassword] = useState('');
|
||||
const [repeat, setRepeat] = useState('');
|
||||
const [encryption, setEncryption] = useState<PfxEncryption>('compat');
|
||||
const base = `templates.${template.messageKey}`;
|
||||
const ready = !template.pfx || pfxPasswordReady(password, repeat);
|
||||
|
||||
return (
|
||||
<li className="space-y-3 rounded-lg border border-border p-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">{t(`${base}.title`)}</h3>
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">{t('templates.delivers')}: </span>
|
||||
{t(`${base}.delivers`)}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold text-foreground">{t('templates.stepsTitle')}</p>
|
||||
<ol className="list-decimal space-y-0.5 pl-5 text-xs text-muted-foreground">
|
||||
{Array.from({ length: template.steps }, (_, i) => i + 1).map((n) => (
|
||||
<li key={n}>{t(`${base}.steps.${n}`)}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
{template.pfx && (
|
||||
<PfxOptions
|
||||
password={password}
|
||||
repeat={repeat}
|
||||
encryption={encryption}
|
||||
onPassword={setPassword}
|
||||
onRepeat={setRepeat}
|
||||
onEncryption={setEncryption}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || !ready}
|
||||
onClick={() => onDownload(template.pfx ? { password, encryption } : null)}
|
||||
aria-label={`${t('templates.download')}: ${t(`${base}.title`)}`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{busy ? t('templates.busy') : t('templates.download')}
|
||||
</button>
|
||||
{snippet && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-foreground">{t('templates.snippetTitle')}</p>
|
||||
<pre className="overflow-x-auto rounded border border-border bg-muted p-3 text-xs text-foreground">
|
||||
{snippet}
|
||||
</pre>
|
||||
<p className="text-xs text-muted-foreground">{t('templates.snippetHint')}</p>
|
||||
<button type="button" onClick={() => onCopy(snippet)} className="btn btn-secondary">
|
||||
{copied ? t('templates.copied') : t('templates.copy')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,10 @@ import { EmptyWorkspace } from './components/EmptyWorkspace';
|
||||
import { FilesTab } from './components/FilesTab';
|
||||
import { MergeTab } from './components/MergeTab';
|
||||
import { SplitTab } from './components/SplitTab';
|
||||
import { TemplatesTab } from './components/TemplatesTab';
|
||||
import { useCertWorkspace } from './use-cert-workspace';
|
||||
|
||||
type TabId = 'files' | 'analyze' | 'split' | 'merge' | 'convert';
|
||||
type TabId = 'files' | 'analyze' | 'split' | 'merge' | 'convert' | 'templates';
|
||||
|
||||
/**
|
||||
* CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt).
|
||||
@@ -34,6 +35,7 @@ export default function CertManagerPage() {
|
||||
{ id: 'split', label: t('tabs.split') },
|
||||
{ id: 'merge', label: t('tabs.merge') },
|
||||
{ id: 'convert', label: t('tabs.convert') },
|
||||
{ id: 'templates', label: t('tabs.templates') },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -52,6 +54,7 @@ export default function CertManagerPage() {
|
||||
{activeTab === 'split' && <SplitTab workspace={workspace} />}
|
||||
{activeTab === 'merge' && <MergeTab workspace={workspace} />}
|
||||
{activeTab === 'convert' && <ConvertTab workspace={workspace} />}
|
||||
{activeTab === 'templates' && <TemplatesTab workspace={workspace} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"analyze": "Analysieren",
|
||||
"split": "Aufteilen",
|
||||
"merge": "Zusammenführen",
|
||||
"convert": "Konvertieren"
|
||||
"convert": "Konvertieren",
|
||||
"templates": "Vorlagen"
|
||||
},
|
||||
"roles": {
|
||||
"end-entity": "Serverzertifikat",
|
||||
@@ -1460,6 +1461,87 @@
|
||||
"keyPassword": "Passwort für den Schlüssel",
|
||||
"download": "Herunterladen",
|
||||
"busy": "Wird erstellt …"
|
||||
},
|
||||
"templates": {
|
||||
"intro": "Wählen Sie das System, auf dem das Zertifikat laufen soll. Tessera erstellt mit einem Klick die passenden Dateien und zeigt die Zeilen für die Einrichtung. Für jede Vorlage brauchen Sie das Serverzertifikat und den passenden privaten Schlüssel.",
|
||||
"chooseHead": "Für welches Zertifikat sollen die Dateien erstellt werden?",
|
||||
"needsKey": "Für dieses Zertifikat liegt noch kein passender privater Schlüssel vor. Fügen Sie ihn im Reiter „Dateien“ hinzu, dann sind die Vorlagen verfügbar.",
|
||||
"delivers": "Das erhalten Sie",
|
||||
"stepsTitle": "So gehen Sie vor",
|
||||
"download": "Vorlage herunterladen",
|
||||
"busy": "Wird erstellt …",
|
||||
"zipHint": "Mehrere Dateien kommen zusammen in einer ZIP-Datei, die auch eine Anleitung enthält.",
|
||||
"snippetTitle": "Zeilen für die Einrichtung",
|
||||
"snippetHint": "Passen Sie die Pfade an Ihren Server an.",
|
||||
"copy": "Kopieren",
|
||||
"copied": "Kopiert",
|
||||
"instructionsFile": "ANLEITUNG.txt",
|
||||
"instructionsHeading": "Anleitung für {name}",
|
||||
"instructionsFiles": "Enthaltene Dateien",
|
||||
"instructionsSnippet": "Zeilen für die Einrichtung",
|
||||
"nginx": {
|
||||
"title": "Nginx",
|
||||
"delivers": "fullchain.pem (Serverzertifikat und Zwischenzertifikate) und privkey.pem (privater Schlüssel).",
|
||||
"steps": {
|
||||
"1": "Kopieren Sie beide Dateien auf Ihren Server, zum Beispiel in den Ordner /etc/nginx/ssl.",
|
||||
"2": "Tragen Sie die beiden angezeigten Zeilen im server-Block Ihrer Nginx-Konfiguration ein.",
|
||||
"3": "Prüfen Sie die Einstellungen mit „nginx -t“ und laden Sie Nginx mit „nginx -s reload“ neu."
|
||||
}
|
||||
},
|
||||
"apache": {
|
||||
"title": "Apache 2.4.8 und neuer",
|
||||
"delivers": "fullchain.pem (Serverzertifikat und Zwischenzertifikate) und privkey.pem (privater Schlüssel).",
|
||||
"steps": {
|
||||
"1": "Kopieren Sie beide Dateien auf Ihren Server, zum Beispiel in den Ordner /etc/ssl.",
|
||||
"2": "Tragen Sie die beiden angezeigten Zeilen im VirtualHost für Port 443 ein.",
|
||||
"3": "Prüfen Sie die Einstellungen mit „apachectl configtest“ und laden Sie Apache neu."
|
||||
}
|
||||
},
|
||||
"apacheLegacy": {
|
||||
"title": "Apache älter als 2.4.8",
|
||||
"delivers": "cert.pem (Serverzertifikat), chain.pem (Zwischenzertifikate) und privkey.pem (privater Schlüssel).",
|
||||
"steps": {
|
||||
"1": "Kopieren Sie die Dateien auf Ihren Server, zum Beispiel in den Ordner /etc/ssl.",
|
||||
"2": "Tragen Sie die angezeigten Zeilen im VirtualHost für Port 443 ein. Ältere Apache-Versionen brauchen die Kette in einer eigenen Datei.",
|
||||
"3": "Prüfen Sie die Einstellungen mit „apachectl configtest“ und laden Sie Apache neu."
|
||||
}
|
||||
},
|
||||
"iis": {
|
||||
"title": "Windows / IIS",
|
||||
"delivers": "Eine PFX-Datei mit Zertifikaten und Schlüssel. Vorgewählt ist die kompatible Verschlüsselung, die auch ältere Windows-Server öffnen können.",
|
||||
"steps": {
|
||||
"1": "Kopieren Sie die PFX-Datei auf den Windows-Server.",
|
||||
"2": "Importieren Sie sie per Doppelklick in den Speicher „Lokaler Computer“, Ablage „Eigene Zertifikate“, oder mit der angezeigten PowerShell-Zeile. Das Passwort geben Sie dabei ein.",
|
||||
"3": "Wählen Sie das Zertifikat im IIS-Manager unter „Bindungen“ der Website aus."
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"title": "Nginx Proxy Manager",
|
||||
"delivers": "certificate.pem (Serverzertifikat), intermediate.pem (Zwischenzertifikate, falls vorhanden) und privkey.pem (privater Schlüssel im klassischen Format).",
|
||||
"steps": {
|
||||
"1": "Öffnen Sie in Nginx Proxy Manager den Bereich „SSL Certificates“ und klicken Sie auf „Add SSL Certificate“. Wählen Sie „Custom“.",
|
||||
"2": "Vergeben Sie einen Namen. Wählen Sie bei „Certificate Key“ die Datei privkey.pem, bei „Certificate“ die Datei certificate.pem und bei „Intermediate Certificate“ die Datei intermediate.pem.",
|
||||
"3": "Speichern Sie und wählen Sie das Zertifikat anschließend beim Proxy-Host im Reiter „SSL“ aus."
|
||||
}
|
||||
},
|
||||
"haproxy": {
|
||||
"title": "HAProxy",
|
||||
"delivers": "Eine einzige PEM-Datei mit Serverzertifikat, Zwischenzertifikaten und privatem Schlüssel.",
|
||||
"steps": {
|
||||
"1": "Kopieren Sie die Datei auf Ihren Server, zum Beispiel in den Ordner /etc/haproxy/certs. Schützen Sie sie, denn sie enthält den Schlüssel.",
|
||||
"2": "Tragen Sie die angezeigte Zeile im frontend-Block Ihrer HAProxy-Konfiguration ein.",
|
||||
"3": "Laden Sie HAProxy neu, zum Beispiel mit „systemctl reload haproxy“."
|
||||
}
|
||||
},
|
||||
"tomcat": {
|
||||
"title": "Tomcat / Java",
|
||||
"delivers": "Eine Datei im Format PKCS#12 (.p12) mit Zertifikaten und Schlüssel. Vorgewählt ist die kompatible Verschlüsselung.",
|
||||
"steps": {
|
||||
"1": "Kopieren Sie die Datei auf Ihren Server, zum Beispiel in den Ordner conf von Tomcat.",
|
||||
"2": "Tragen Sie die angezeigte Zeile in den SSLHostConfig-Block der server.xml ein und ersetzen Sie IHR-PASSWORT durch das Passwort, das Sie hier vergeben haben.",
|
||||
"3": "Starten Sie Tomcat neu."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tenderRadar": {
|
||||
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"analyze": "Analyze",
|
||||
"split": "Split",
|
||||
"merge": "Merge",
|
||||
"convert": "Convert"
|
||||
"convert": "Convert",
|
||||
"templates": "Templates"
|
||||
},
|
||||
"roles": {
|
||||
"end-entity": "Server certificate",
|
||||
@@ -1460,6 +1461,87 @@
|
||||
"keyPassword": "Password for the key",
|
||||
"download": "Download",
|
||||
"busy": "Creating …"
|
||||
},
|
||||
"templates": {
|
||||
"intro": "Choose the system the certificate is going to run on. Tessera creates the matching files with one click and shows the lines for the setup. Every template needs the server certificate and the matching private key.",
|
||||
"chooseHead": "For which certificate should the files be created?",
|
||||
"needsKey": "There is no matching private key for this certificate yet. Add it in the “Files” tab, then the templates become available.",
|
||||
"delivers": "What you get",
|
||||
"stepsTitle": "How to proceed",
|
||||
"download": "Download template",
|
||||
"busy": "Creating …",
|
||||
"zipHint": "Several files come together in one ZIP file that also contains instructions.",
|
||||
"snippetTitle": "Lines for the setup",
|
||||
"snippetHint": "Adjust the paths to your server.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"instructionsFile": "INSTRUCTIONS.txt",
|
||||
"instructionsHeading": "Instructions for {name}",
|
||||
"instructionsFiles": "Included files",
|
||||
"instructionsSnippet": "Lines for the setup",
|
||||
"nginx": {
|
||||
"title": "Nginx",
|
||||
"delivers": "fullchain.pem (server certificate and intermediate certificates) and privkey.pem (private key).",
|
||||
"steps": {
|
||||
"1": "Copy both files to your server, for example to the folder /etc/nginx/ssl.",
|
||||
"2": "Add the two lines shown to the server block of your Nginx configuration.",
|
||||
"3": "Check the settings with “nginx -t” and reload Nginx with “nginx -s reload”."
|
||||
}
|
||||
},
|
||||
"apache": {
|
||||
"title": "Apache 2.4.8 and newer",
|
||||
"delivers": "fullchain.pem (server certificate and intermediate certificates) and privkey.pem (private key).",
|
||||
"steps": {
|
||||
"1": "Copy both files to your server, for example to the folder /etc/ssl.",
|
||||
"2": "Add the two lines shown to the VirtualHost for port 443.",
|
||||
"3": "Check the settings with “apachectl configtest” and reload Apache."
|
||||
}
|
||||
},
|
||||
"apacheLegacy": {
|
||||
"title": "Apache older than 2.4.8",
|
||||
"delivers": "cert.pem (server certificate), chain.pem (intermediate certificates) and privkey.pem (private key).",
|
||||
"steps": {
|
||||
"1": "Copy the files to your server, for example to the folder /etc/ssl.",
|
||||
"2": "Add the lines shown to the VirtualHost for port 443. Older Apache versions need the chain in a file of its own.",
|
||||
"3": "Check the settings with “apachectl configtest” and reload Apache."
|
||||
}
|
||||
},
|
||||
"iis": {
|
||||
"title": "Windows / IIS",
|
||||
"delivers": "A PFX file with certificates and key. The compatible encryption is preselected; older Windows servers can open it as well.",
|
||||
"steps": {
|
||||
"1": "Copy the PFX file to the Windows server.",
|
||||
"2": "Import it by double-click into the “Local Computer” store, “Personal”, or with the PowerShell line shown. You enter the password there.",
|
||||
"3": "Select the certificate in IIS Manager under “Bindings” of the website."
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"title": "Nginx Proxy Manager",
|
||||
"delivers": "certificate.pem (server certificate), intermediate.pem (intermediate certificates, if any) and privkey.pem (private key in the traditional format).",
|
||||
"steps": {
|
||||
"1": "In Nginx Proxy Manager open “SSL Certificates” and click “Add SSL Certificate”. Choose “Custom”.",
|
||||
"2": "Enter a name. For “Certificate Key” choose the file privkey.pem, for “Certificate” the file certificate.pem and for “Intermediate Certificate” the file intermediate.pem.",
|
||||
"3": "Save, then select the certificate on the proxy host in the “SSL” tab."
|
||||
}
|
||||
},
|
||||
"haproxy": {
|
||||
"title": "HAProxy",
|
||||
"delivers": "A single PEM file with server certificate, intermediate certificates and private key.",
|
||||
"steps": {
|
||||
"1": "Copy the file to your server, for example to the folder /etc/haproxy/certs. Protect it, because it contains the key.",
|
||||
"2": "Add the line shown to the frontend block of your HAProxy configuration.",
|
||||
"3": "Reload HAProxy, for example with “systemctl reload haproxy”."
|
||||
}
|
||||
},
|
||||
"tomcat": {
|
||||
"title": "Tomcat / Java",
|
||||
"delivers": "A file in PKCS#12 format (.p12) with certificates and key. The compatible encryption is preselected.",
|
||||
"steps": {
|
||||
"1": "Copy the file to your server, for example to the conf folder of Tomcat.",
|
||||
"2": "Add the line shown to the SSLHostConfig block of server.xml and replace IHR-PASSWORT with the password you set here.",
|
||||
"3": "Restart Tomcat."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tenderRadar": {
|
||||
|
||||
@@ -257,4 +257,10 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
// quick-261009-ikt Task 5: PFX und Konvertieren
|
||||
'passwortgeschützte',
|
||||
'AES',
|
||||
// quick-261009-ikt Task 6: Vorlagen
|
||||
'ssl',
|
||||
'neuer',
|
||||
'klassischen',
|
||||
'SSLHostConfig',
|
||||
'PASSWORT',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user