From 545288b1e2e853e085d3320b4939f2dcaefc64a8 Mon Sep 17 00:00:00 2001 From: Adrian Gracia Date: Mon, 2 Dec 2024 20:08:49 -0600 Subject: [PATCH] SQC-352 SQC-353 Create cert upload command for client side mtls certificates and ca chain certificates --- .changeset/hip-cameras-yawn.md | 14 + packages/wrangler/e2e/cert.test.ts | 176 +++++ packages/wrangler/src/__tests__/cert.test.ts | 664 ++++++++++++++++++ packages/wrangler/src/api/index.ts | 1 + packages/wrangler/src/api/mtls-certificate.ts | 38 +- packages/wrangler/src/cert/cert.ts | 199 ++++++ packages/wrangler/src/core/teams.d.ts | 3 +- packages/wrangler/src/index.ts | 19 + 8 files changed, 1109 insertions(+), 5 deletions(-) create mode 100644 .changeset/hip-cameras-yawn.md create mode 100644 packages/wrangler/e2e/cert.test.ts create mode 100644 packages/wrangler/src/__tests__/cert.test.ts create mode 100644 packages/wrangler/src/cert/cert.ts diff --git a/.changeset/hip-cameras-yawn.md b/.changeset/hip-cameras-yawn.md new file mode 100644 index 000000000000..efee6f6f7538 --- /dev/null +++ b/.changeset/hip-cameras-yawn.md @@ -0,0 +1,14 @@ +--- +"wrangler": minor +--- + +feat: implement the `wrangler cert upload` command + +This command allows users to upload a mTLS certificate/private key or certificate-authority certificate chain. + +For uploading mTLS certificate, run: + +- `wrangler cert upload mtls-certificate --cert cert.pem --key key.pem --name MY_CERT` + +For uploading CA certificate chain, run: +- `wrangler cert upload certificate-authority --ca-cert server-ca.pem --name SERVER_CA` \ No newline at end of file diff --git a/packages/wrangler/e2e/cert.test.ts b/packages/wrangler/e2e/cert.test.ts new file mode 100644 index 000000000000..afa5466ad184 --- /dev/null +++ b/packages/wrangler/e2e/cert.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import { WranglerE2ETestHelper } from "./helpers/e2e-wrangler-test"; +import { normalizeOutput } from "./helpers/normalize"; + +import forge from 'node-forge'; + +// Generate X509 self signed root key pair and certificate +function generateRootCertificate() { + const rootKeys = forge.pki.rsa.generateKeyPair(2048); + const rootCert = forge.pki.createCertificate(); + rootCert.publicKey = rootKeys.publicKey; + rootCert.serialNumber = '01'; + rootCert.validity.notBefore = new Date(); + rootCert.validity.notAfter = new Date(); + rootCert.validity.notAfter.setFullYear(rootCert.validity.notBefore.getFullYear() + 10); // 10 years validity + + const rootAttrs = [ + { name: 'commonName', value: 'Root CA' }, + { name: 'countryName', value: 'US' }, + { shortName: 'ST', value: 'California' }, + { name: 'organizationName', value: 'Localhost Root CA' } + ]; + rootCert.setSubject(rootAttrs); + rootCert.setIssuer(rootAttrs); // Self-signed + + rootCert.sign(rootKeys.privateKey, forge.md.sha256.create()); + + return { certificate: rootCert, privateKey: rootKeys.privateKey }; +} + +// Generate X509 leaf certificate signed by the root +function generateLeafCertificate(rootCert: forge.pki.Certificate, rootKey: forge.pki.PrivateKey) { + const leafKeys = forge.pki.rsa.generateKeyPair(2048); + const leafCert = forge.pki.createCertificate(); + leafCert.publicKey = leafKeys.publicKey; + leafCert.serialNumber = '02'; + leafCert.validity.notBefore = new Date(); + leafCert.validity.notAfter = new Date(); + leafCert.validity.notAfter.setFullYear(2034, 10, 18); + + const leafAttrs = [ + { name: 'commonName', value: 'example.org' }, + { name: 'countryName', value: 'US' }, + { shortName: 'ST', value: 'California' }, + { name: 'organizationName', value: 'Example Inc' } + ]; + leafCert.setSubject(leafAttrs); + leafCert.setIssuer(rootCert.subject.attributes); // Signed by root + + leafCert.sign(rootKey, forge.md.sha256.create()); // Signed using root's private key + + const pemLeafCert = forge.pki.certificateToPem(leafCert); + const pemLeafKey = forge.pki.privateKeyToPem(leafKeys.privateKey); + + return { certificate: pemLeafCert, privateKey: pemLeafKey }; +} + +// Generate self signed X509 CA root certificate +function generateRootCaCert() { + // Create a key pair (private and public keys) + const keyPair = forge.pki.rsa.generateKeyPair(2048); + + // Create a new X.509 certificate + const cert = forge.pki.createCertificate(); + + // Set certificate fields + cert.publicKey = keyPair.publicKey; + cert.serialNumber = '01'; + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setFullYear(2034, 10, 18); + + // Add issuer and subject fields (for a root CA, they are the same) + const attrs = [ + { name: 'commonName', value: 'Localhost CA' }, + { name: 'countryName', value: 'US' }, + { shortName: 'ST', value: 'California' }, + { name: 'localityName', value: 'San Francisco' }, + { name: 'organizationName', value: 'Localhost' }, + { shortName: 'OU', value: 'SSL Department' } + ]; + cert.setSubject(attrs); + cert.setIssuer(attrs); + + // Add basic constraints and key usage extensions + cert.setExtensions([ + { + name: 'basicConstraints', + cA: true, + }, + { + name: 'keyUsage', + keyCertSign: true, + digitalSignature: true, + cRLSign: true, + } + ]); + + // Self-sign the certificate with the private key + cert.sign(keyPair.privateKey, forge.md.sha256.create()); + + // Convert the certificate and private key to PEM format + const pemCert = forge.pki.certificateToPem(cert); + const pemPrivateKey = forge.pki.privateKeyToPem(keyPair.privateKey); + + return { certificate: pemCert, privateKey: pemPrivateKey }; +} + +describe("cert", () => { + const normalize = (str: string) => + normalizeOutput(str, { + [process.env.CLOUDFLARE_ACCOUNT_ID as string]: "CLOUDFLARE_ACCOUNT_ID", + }); + const helper = new WranglerE2ETestHelper(); + // Generate root and leaf certificates + const { certificate: rootCert, privateKey: rootKey } = generateRootCertificate(); + const { certificate: leafCert, privateKey: leafKey } = generateLeafCertificate(rootCert, rootKey); + const { certificate: caCert, privateKey: caCertKey } = generateRootCaCert(); + + it("upload mtls-certificate", async () => { + // locally generated certs/key + await helper.seed({"mtls_client_cert_file.pem": leafCert}); + await helper.seed({"mtls_client_private_key_file.pem": leafKey}); + + const output = await helper.run(`wrangler cert upload mtls-certificate --name MTLS_CERTIFICATE_UPLOAD_CERT --cert mtls_client_cert_file.pem --key mtls_client_private_key_file.pem`); + expect(normalize(output.stdout)).toMatchInlineSnapshot(` + "Uploading mTLS Certificate MTLS_CERTIFICATE_UPLOAD_CERT... + Success! Uploaded mTLS Certificate MTLS_CERTIFICATE_UPLOAD_CERT + ID: 00000000-0000-0000-0000-000000000000 + Issuer: CN=Root CA,O=Localhost Root CA,ST=California,C=US + Expires on 11/18/2034" + `); + }); + + it("upload certificate-authority", async () => { + await helper.seed({"ca_chain_cert.pem": caCert}); + + const output = await helper.run(`wrangler cert upload certificate-authority --name CERTIFICATE_AUTHORITY_UPLOAD_CERT --ca-cert ca_chain_cert.pem`); + expect(normalize(output.stdout)).toMatchInlineSnapshot(` + "Uploading CA Certificate CERTIFICATE_AUTHORITY_UPLOAD_CERT... + Success! Uploaded CA Certificate CERTIFICATE_AUTHORITY_UPLOAD_CERT + ID: 00000000-0000-0000-0000-000000000000 + Issuer: CN=Localhost CA,OU=SSL Department,O=Localhost,L=San Francisco,ST=California,C=US + Expires on 11/18/2034" + `); + }); + + it("list cert", async () => { + const output = await helper.run(`wrangler cert list`); + let result = normalize(output.stdout); + expect(result).toContain("Name: MTLS_CERTIFICATE_UPLOAD_CERT"); + expect(result).toContain('Name: CERTIFICATE_AUTHORITY_UPLOAD_CERT'); + }); + + it("delete mtls cert", async () => { + const delete_mtls_cert_output = await helper.run(`wrangler cert delete --name MTLS_CERTIFICATE_UPLOAD_CERT`); + expect(normalize(delete_mtls_cert_output.stdout)).toMatchInlineSnapshot( + ` + "? Are you sure you want to delete certificate 00000000-0000-0000-0000-000000000000 (MTLS_CERTIFICATE_UPLOAD_CERT)? + 🤖 Using fallback value in non-interactive context: yes + Deleted certificate 00000000-0000-0000-0000-000000000000 (MTLS_CERTIFICATE_UPLOAD_CERT) successfully" + ` + ) + }); + + it("delete ca chain cert", async() => { + const delete_ca_cert_output = await helper.run(`wrangler cert delete --name CERTIFICATE_AUTHORITY_UPLOAD_CERT`); + expect(normalize(delete_ca_cert_output.stdout)).toMatchInlineSnapshot( + ` + "? Are you sure you want to delete certificate 00000000-0000-0000-0000-000000000000 (CERTIFICATE_AUTHORITY_UPLOAD_CERT)? + 🤖 Using fallback value in non-interactive context: yes + Deleted certificate 00000000-0000-0000-0000-000000000000 (CERTIFICATE_AUTHORITY_UPLOAD_CERT) successfully" + ` + ) + }) +}); diff --git a/packages/wrangler/src/__tests__/cert.test.ts b/packages/wrangler/src/__tests__/cert.test.ts new file mode 100644 index 000000000000..bcacab4d6140 --- /dev/null +++ b/packages/wrangler/src/__tests__/cert.test.ts @@ -0,0 +1,664 @@ +import { writeFileSync } from "fs"; +import { http, HttpResponse } from "msw"; +import { + deleteMTlsCertificate, + getMTlsCertificate, + getMTlsCertificateByName, + listMTlsCertificates, + uploadMTlsCertificate, + uploadMTlsCertificateFromFs, + uploadCaCertificateFromFs +} from "../api"; +import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; +import { mockConsoleMethods } from "./helpers/mock-console"; +import { mockConfirm } from "./helpers/mock-dialogs"; +import { useMockIsTTY } from "./helpers/mock-istty"; +import { msw } from "./helpers/msw"; +import { runInTempDir } from "./helpers/run-in-tmp"; +import { runWrangler } from "./helpers/run-wrangler"; +import { type MTlsCertificateResponse } from "../api/mtls-certificate"; + +describe("wrangler", () => { + mockAccountId(); + mockApiToken(); + runInTempDir(); + const { setIsTTY } = useMockIsTTY(); + const std = mockConsoleMethods(); + + beforeEach(() => { + setIsTTY(true); + }); + + function mockPostMTlsCertificate( + resp: Partial = {} + ) { + const config = { calls: 0 }; + msw.use( + http.post( + "*/accounts/:accountId/mtls_certificates", + async ({ request }) => { + config.calls++; + + const body = (await request.json()) as Record; + return HttpResponse.json({ + success: true, + errors: [], + messages: [], + result: { + id: "1234", + name: body.name, + certificates: body.certificates, + issuer: "example.com...", + uploaded_on: now.toISOString(), + expires_on: oneYearLater.toISOString(), + ...resp, + }, + }); + }, + { once: true } + ) + ); + return config; + } + + function mockPostCaChainCertificate( + resp: Partial = {} + ) { + const config = { calls: 0 }; + + msw.use( + http.post( + "*/accounts/:accountId/mtls_certificates", + async ({ request }) => { + config.calls++; + + const body = (await request.json()) as Record; + return HttpResponse.json({ + success: true, + errors: [], + messages: [], + result: { + id: "1234", + name: body.name, + certificates: body.certificates, + issuer: "example.com...", + uploaded_on: now.toISOString(), + expires_on: oneYearLater.toISOString(), + ca: true, + ...resp, + }, + }); + }, + { once: true } + ) + ); + return config; + } + + function mockGetMTlsCertificates( + certs: Partial[] | undefined = undefined + ) { + const config = { calls: 0 }; + msw.use( + http.get( + "*/accounts/:accountId/mtls_certificates", + async () => { + config.calls++; + + return HttpResponse.json({ + success: true, + errors: [], + messages: [], + result: + typeof certs === "undefined" + ? [ + { + id: "1234", + name: "cert one", + certificates: "BEGIN CERTIFICATE...", + issuer: "example.com...", + uploaded_on: now.toISOString(), + expires_on: oneYearLater.toISOString(), + }, + { + id: "5678", + name: "cert two", + certificates: "BEGIN CERTIFICATE...", + issuer: "example.com...", + uploaded_on: now.toISOString(), + expires_on: oneYearLater.toISOString(), + }, + ] + : certs, + }); + }, + { once: true } + ) + ); + return config; + } + + function mockGetMTlsCertificate(resp: Partial = {}) { + const config = { calls: 0 }; + msw.use( + http.get( + "*/accounts/:accountId/mtls_certificates/:certId", + async () => { + config.calls++; + + return HttpResponse.json({ + success: true, + errors: [], + messages: [], + result: { + id: "1234", + certificates: "BEGIN CERTIFICATE...", + issuer: "example.com...", + uploaded_on: now.toISOString(), + expires_on: oneYearLater.toISOString(), + ...resp, + }, + }); + }, + { once: true } + ) + ); + return config; + } + + function mockDeleteMTlsCertificate() { + const config = { calls: 0 }; + msw.use( + http.delete( + "*/accounts/:accountId/mtls_certificates/:certId", + async () => { + config.calls++; + + return HttpResponse.json({ + success: true, + errors: [], + messages: [], + result: null, + }); + }, + { once: true } + ) + ); + return config; + } + + const now = new Date(); + const oneYearLater = new Date(now); + oneYearLater.setFullYear(now.getFullYear() + 1); + + describe("cert", () => { + describe("api", () => { + describe("uploadMTlsCertificate", () => { + it("should call mtls_certificates upload endpoint", async () => { + const mock = mockPostMTlsCertificate({ + id: "1234", + issuer: "example.com...", + uploaded_on: now.toISOString(), + expires_on: oneYearLater.toISOString(), + }); + + const cert = await uploadMTlsCertificate("some-account-id", { + certificateChain: "BEGIN CERTIFICATE...", + privateKey: "BEGIN PRIVATE KEY...", + name: "my_cert", + }); + + expect(cert.id).toEqual("1234"); + expect(cert.issuer).toEqual("example.com..."); + expect(cert.expires_on).toEqual(oneYearLater.toISOString()); + + expect(mock.calls).toEqual(1); + }); + }); + + describe("uploadMTlsCertificateFromFs", () => { + it("should fail to read cert and key files when missing", async () => { + await expect( + uploadMTlsCertificateFromFs("some-account-id", { + certificateChainFilename: "cert.pem", + privateKeyFilename: "key.pem", + name: "my_cert", + }) + ).rejects.toMatchInlineSnapshot( + `[ParseError: Could not read file: cert.pem]` + ); + }); + + it("should read cert and key from disk and call mtls_certificates upload endpoint", async () => { + const mock = mockPostMTlsCertificate({ + id: "1234", + issuer: "example.com...", + }); + + writeFileSync("cert.pem", "BEGIN CERTIFICATE..."); + writeFileSync("key.pem", "BEGIN PRIVATE KEY..."); + + const cert = await uploadMTlsCertificateFromFs("some-account-id", { + certificateChainFilename: "cert.pem", + privateKeyFilename: "key.pem", + name: "my_cert", + }); + + expect(cert.id).toEqual("1234"); + expect(cert.issuer).toEqual("example.com..."); + expect(cert.expires_on).toEqual(oneYearLater.toISOString()); + + expect(mock.calls).toEqual(1); + }); + }); + + describe("uploadCaCertificateFromFs", () => { + it("should fail to read ca cert when file is missing", async () => { + await expect( + uploadCaCertificateFromFs("some-account-id", { + certificates: "caCert.pem", + ca: true, + name: "my_cert", + }) + ).rejects.toMatchInlineSnapshot( + `[ParseError: Could not read file: caCert.pem]` + ); + }); + + it("should read ca cert from disk and call mtls_certificates upload endpoint", async () => { + const mock = mockPostCaChainCertificate({ + id: "1234", + issuer: "example.com...", + }); + + writeFileSync("caCert.pem", "BEGIN CERTIFICATE..."); + + const cert = await uploadCaCertificateFromFs("some-account-id", { + certificates: "caCert.pem", + ca: true, + name: "my_cert", + }); + + expect(cert.id).toEqual("1234"); + expect(cert.issuer).toEqual("example.com..."); + expect(cert.expires_on).toEqual(oneYearLater.toISOString()); + expect(cert.ca).toEqual(true); + + expect(mock.calls).toEqual(1); + }); + }); + + describe("listMTlsCertificates", () => { + it("should call mtls_certificates list endpoint", async () => { + const mock = mockGetMTlsCertificates([ + { + id: "1234", + name: "cert one", + certificates: "BEGIN CERTIFICATE...", + issuer: "example.com...", + uploaded_on: now.toISOString(), + expires_on: oneYearLater.toISOString(), + }, + { + id: "5678", + name: "cert two", + certificates: "BEGIN CERTIFICATE...", + issuer: "example.com...", + uploaded_on: now.toISOString(), + expires_on: oneYearLater.toISOString(), + }, + ]); + + const certs = await listMTlsCertificates("some-account-id", {}, "true"); + + expect(certs).toHaveLength(2); + + expect(certs[0].id).toEqual("1234"); + expect(certs[0].name).toEqual("cert one"); + + expect(certs[1].id).toEqual("5678"); + expect(certs[1].name).toEqual("cert two"); + + expect(mock.calls).toEqual(1); + }); + }); + + describe("getMTlsCertificate", () => { + it("calls get mtls_certificates endpoint", async () => { + const mock = mockGetMTlsCertificate({ + id: "1234", + name: "cert one", + certificates: "BEGIN CERTIFICATE...", + issuer: "example.com...", + uploaded_on: now.toISOString(), + expires_on: oneYearLater.toISOString(), + }); + + const cert = await getMTlsCertificate("some-account-id", "1234"); + + expect(cert.id).toEqual("1234"); + expect(cert.issuer).toEqual("example.com..."); + expect(cert.expires_on).toEqual(oneYearLater.toISOString()); + + expect(mock.calls).toEqual(1); + }); + }); + + describe("getMTlsCertificateByName", () => { + it("calls list mtls_certificates endpoint with name", async () => { + const mock = mockGetMTlsCertificates([ + { + id: "1234", + name: "cert one", + certificates: "BEGIN CERTIFICATE...", + issuer: "example.com...", + uploaded_on: now.toISOString(), + expires_on: oneYearLater.toISOString(), + }, + ]); + + const cert = await getMTlsCertificateByName("some-account-id", "cert one", true); + + expect(cert.id).toEqual("1234"); + expect(cert.issuer).toEqual("example.com..."); + expect(cert.expires_on).toEqual(oneYearLater.toISOString()); + + expect(mock.calls).toEqual(1); + }); + + it("errors when a certificate cannot be found", async () => { + const mock = mockGetMTlsCertificates([]); + + await expect( + getMTlsCertificateByName("some-account-id", "cert one", true) + ).rejects.toMatchInlineSnapshot( + `[Error: certificate not found with name "cert one"]` + ); + + expect(mock.calls).toEqual(1); + }); + + it("errors when multiple certificates are found", async () => { + const mock = mockGetMTlsCertificates([ + { + id: "1234", + name: "cert one", + certificates: "BEGIN CERTIFICATE...", + issuer: "example.com...", + uploaded_on: now.toISOString(), + expires_on: oneYearLater.toISOString(), + }, + { + id: "5678", + name: "cert one", + certificates: "BEGIN CERTIFICATE...", + issuer: "example.com...", + uploaded_on: now.toISOString(), + expires_on: oneYearLater.toISOString(), + }, + ]); + + await expect( + getMTlsCertificateByName("some-account-id", "cert one", true) + ).rejects.toMatchInlineSnapshot( + `[Error: multiple certificates found with name "cert one"]` + ); + + expect(mock.calls).toEqual(1); + }); + }); + + describe("deleteMTlsCertificate", () => { + test("calls delete mts_certificates endpoint", async () => { + const mock = mockDeleteMTlsCertificate(); + + await deleteMTlsCertificate("some-account-id", "1234"); + + expect(mock.calls).toEqual(1); + }); + }); + }); + + describe("commands", () => { + describe("help", () => { + it("should show the correct help text", async () => { + await runWrangler("cert --help"); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "wrangler cert + + 🪪 Manage client mTLS certificates and CA certificate chains used for secured connections [open-beta] + + COMMANDS + wrangler cert upload Upload a new cert [open-beta] + wrangler cert list List uploaded mTLS certificates + wrangler cert delete Delete an mTLS certificate + + GLOBAL FLAGS + -c, --config Path to Wrangler configuration file [string] + -e, --env Environment to use for operations and .env files [string] + -h, --help Show help [boolean] + -v, --version Show version number [boolean]" + `); + }); + }); + + describe("upload", () => { + test("uploads certificate and key from file", async () => { + writeFileSync("cert.pem", "BEGIN CERTIFICATE..."); + writeFileSync("key.pem", "BEGIN PRIVATE KEY..."); + + mockPostMTlsCertificate(); + + await runWrangler( + "cert upload mtls-certificate --cert cert.pem --key key.pem" + ); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toEqual( + `Uploading mTLS Certificate... +Success! Uploaded mTLS Certificate +ID: 1234 +Issuer: example.com... +Expires on ${oneYearLater.toLocaleDateString()}` + ); + }); + + test("uploads certificate and key from file with name", async () => { + writeFileSync("cert.pem", "BEGIN CERTIFICATE..."); + writeFileSync("key.pem", "BEGIN PRIVATE KEY..."); + + mockPostMTlsCertificate(); + + await runWrangler( + "cert upload mtls-certificate --cert cert.pem --key key.pem --name my-cert" + ); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toEqual( + `Uploading mTLS Certificate my-cert... +Success! Uploaded mTLS Certificate my-cert +ID: 1234 +Issuer: example.com... +Expires on ${oneYearLater.toLocaleDateString()}` + ); + }); + + test("uploads ca certificate chain from file", async () => { + writeFileSync("caCert.pem", "BEGIN CERTIFICATE..."); + + mockPostCaChainCertificate(); + + await runWrangler( + "cert upload certificate-authority --ca-cert caCert.pem" + ); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toEqual( + `Uploading CA Certificate... +Success! Uploaded CA Certificate +ID: 1234 +Issuer: example.com... +Expires on ${oneYearLater.toLocaleDateString()}` + ); + }); + + test("uploads ca certificate chain from file with name", async () => { + writeFileSync("caCert.pem", "BEGIN CERTIFICATE..."); + + mockPostCaChainCertificate(); + + await runWrangler( + "cert upload certificate-authority --ca-cert caCert.pem --name my-caCert" + ); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toEqual( + `Uploading CA Certificate my-caCert... +Success! Uploaded CA Certificate my-caCert +ID: 1234 +Issuer: example.com... +Expires on ${oneYearLater.toLocaleDateString()}` + ); + }); + + }); + + describe("list", () => { + it("should list certificates", async () => { + mockGetMTlsCertificates(); + + await runWrangler("cert list"); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toEqual( + `ID: 1234 +Name: cert one +Issuer: example.com... +Created on: ${now.toLocaleDateString()} +Expires on: ${oneYearLater.toLocaleDateString()} + + +ID: 5678 +Name: cert two +Issuer: example.com... +Created on: ${now.toLocaleDateString()} +Expires on: ${oneYearLater.toLocaleDateString()} + +` + ); + }); + }); + + describe("delete", () => { + it("should require --id or --name", async () => { + await runWrangler("cert delete"); + + expect(std.err).toMatchInlineSnapshot(` + "X [ERROR] Must provide --id or --name. + + " + `); + expect(std.out).toMatchInlineSnapshot(`""`); + }); + + it("should require not providing --id and --name", async () => { + await runWrangler("cert delete --id 1234 --name mycert"); + + expect(std.err).toMatchInlineSnapshot(` + "X [ERROR] Can't provide both --id and --name. + + " + `); + expect(std.out).toMatchInlineSnapshot(`""`); + }); + + it("should delete certificate by id", async () => { + mockGetMTlsCertificate({ name: "my-cert" }); + mockDeleteMTlsCertificate(); + + mockConfirm({ + text: `Are you sure you want to delete certificate 1234 (my-cert)?`, + result: true, + }); + + await runWrangler("cert delete --id 1234"); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot( + `"Deleted certificate 1234 (my-cert) successfully"` + ); + }); + + it("should delete certificate by name", async () => { + mockGetMTlsCertificates([{ id: "1234", name: "my-cert" }]); + mockDeleteMTlsCertificate(); + + mockConfirm({ + text: `Are you sure you want to delete certificate 1234 (my-cert)?`, + result: true, + }); + + await runWrangler("cert delete --name my-cert"); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot( + `"Deleted certificate 1234 (my-cert) successfully"` + ); + }); + + it("should not delete when certificate cannot be found by name", async () => { + mockGetMTlsCertificates([]); + + await expect( + runWrangler("cert delete --name my-cert") + ).rejects.toMatchInlineSnapshot( + `[Error: certificate not found with name "my-cert"]` + ); + expect(std.out).toMatchInlineSnapshot(`""`); + }); + + it("should not delete when many certificates are found by name", async () => { + mockGetMTlsCertificates([ + { + id: "1234", + name: "cert one", + certificates: "BEGIN CERTIFICATE...", + issuer: "example.com...", + uploaded_on: now.toISOString(), + expires_on: oneYearLater.toISOString(), + }, + { + id: "5678", + name: "cert one", + certificates: "BEGIN CERTIFICATE...", + issuer: "example.com...", + uploaded_on: now.toISOString(), + expires_on: oneYearLater.toISOString(), + }, + ]); + + await expect( + runWrangler("cert delete --name my-cert") + ).rejects.toMatchInlineSnapshot( + `[Error: multiple certificates found with name "my-cert"]` + ); + expect(std.out).toMatchInlineSnapshot(`""`); + }); + + it("should not delete when confirmation fails", async () => { + mockGetMTlsCertificate({ id: "1234" }); + + mockConfirm({ + text: `Are you sure you want to delete certificate 1234?`, + result: false, + }); + + await runWrangler("cert delete --id 1234"); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(`"Not deleting"`); + }); + }); + }); + }); +}); diff --git a/packages/wrangler/src/api/index.ts b/packages/wrangler/src/api/index.ts index 19ae282f1afc..d8a88a6edc7e 100644 --- a/packages/wrangler/src/api/index.ts +++ b/packages/wrangler/src/api/index.ts @@ -8,6 +8,7 @@ export { getMTlsCertificate, getMTlsCertificateByName, deleteMTlsCertificate, + uploadCaCertificateFromFs } from "./mtls-certificate"; export * from "./startDevWorker"; export * from "./integrations"; diff --git a/packages/wrangler/src/api/mtls-certificate.ts b/packages/wrangler/src/api/mtls-certificate.ts index 4c904c535bb8..165fcb36c2ae 100644 --- a/packages/wrangler/src/api/mtls-certificate.ts +++ b/packages/wrangler/src/api/mtls-certificate.ts @@ -35,6 +35,15 @@ export interface MTlsCertificateBody { name?: string; } +/** + * details for uploading an CA certificate or CA chain via the ssl api + */ +export interface CaCertificateBody { + certificates: string; + ca: boolean; + name?: string; +} + /** * supported filters for listing mTLS certificates via the ssl api */ @@ -66,6 +75,23 @@ export async function uploadMTlsCertificateFromFs( }); } +/** + * reads an CA certificate from disk and uploads it to the account mTLS certificate store + */ +export async function uploadCaCertificateFromFs( + accountId: string, + details: CaCertificateBody +): Promise { + return await fetchResult(`/accounts/${accountId}/mtls_certificates`, { + method: "POST", + body: JSON.stringify({ + name: details.name, + certificates: readFileSync(details.certificates), + ca: details.ca, + }), + }); +} + /** * uploads an mTLS certificate and private key pair to the account mTLS certificate store */ @@ -102,10 +128,13 @@ export async function getMTlsCertificate( */ export async function listMTlsCertificates( accountId: string, - filter: MTlsCertificateListFilter + filter: MTlsCertificateListFilter, + ca: boolean = false ): Promise { const params = new URLSearchParams(); - params.append("ca", "false"); + if (!ca) { + params.append("ca", String(false)); + } if (filter.name) { params.append("name", filter.name); } @@ -121,9 +150,10 @@ export async function listMTlsCertificates( */ export async function getMTlsCertificateByName( accountId: string, - name: string + name: string, + ca: boolean = false, ): Promise { - const certificates = await listMTlsCertificates(accountId, { name }); + const certificates = await listMTlsCertificates(accountId, { name }, ca); if (certificates.length === 0) { throw new ErrorMTlsCertificateNameNotFound( `certificate not found with name "${name}"` diff --git a/packages/wrangler/src/cert/cert.ts b/packages/wrangler/src/cert/cert.ts new file mode 100644 index 000000000000..adb927169d45 --- /dev/null +++ b/packages/wrangler/src/cert/cert.ts @@ -0,0 +1,199 @@ +import { createCommand, createNamespace } from "../core/create-command"; +import { requireAuth } from "../user"; +import { logger } from "../logger"; +import { + deleteMTlsCertificate, + getMTlsCertificate, + getMTlsCertificateByName, + listMTlsCertificates, + uploadMTlsCertificateFromFs, + uploadCaCertificateFromFs +} from "../api/mtls-certificate"; +import type { MTlsCertificateResponse } from "../api/mtls-certificate"; +import { confirm } from "../dialogs"; + +// wrangler cert +export const certNamespace = createNamespace({ + metadata: { + description: "🪪 Manage client mTLS certificates and CA certificate chains used for secured connections", + status: "open-beta", + owner: "Product: SSL", + }, +}); + +export const certUploadNamespace = createNamespace({ + metadata: { + description: "Upload a new cert", + status: "open-beta", + owner: "Product: SSL" + } +}) + +// wrangler cert upload mtls-certificate +export const certUploadMtlsCommand = createCommand({ + metadata: { + description: "Upload an mTLS certificate", + status: "stable", + owner: "Product: SSL" + }, + args: { + cert: { + description: "The path to a certificate file (.pem) containing a chain of certificates to upload", + type: "string", + demandOption: true, + }, + key: { + description: "The path to a file containing the private key for your leaf certificate", + type: "string", + demandOption: true, + }, + name: { + description: "The name for the certificate", + type: "string" + } + }, + async handler({ cert, key, name }, { config }) { + const accountId = await requireAuth(config); + logger.log( + name + ? `Uploading mTLS Certificate ${name}...` + : `Uploading mTLS Certificate...` + ); + const certResponse = await uploadMTlsCertificateFromFs(accountId, { + certificateChainFilename: cert, + privateKeyFilename: key, + name, + }); + const expiresOn = new Date(certResponse.expires_on).toLocaleDateString(); + logger.log( + name + ? `Success! Uploaded mTLS Certificate ${name}` + : `Success! Uploaded mTLS Certificate` + ); + logger.log(`ID: ${certResponse.id}`); + logger.log(`Issuer: ${certResponse.issuer}`); + logger.log(`Expires on ${expiresOn}`); + } +}); + +// wrangler cert upload mtls-certificate +export const certUploadCaCertCommand = createCommand({ + metadata: { + description: "Upload a CA certificate chain", + status: "stable", + owner: "Product: SSL" + }, + args: { + name: { + describe: "The name for the certificate", + type: "string" + }, + "ca-cert": { + description: "The path to a certificate file (.pem) containing a chain of CA certificates to upload", + type: "string", + demandOption: true, + }, + }, + async handler({ caCert, name }, { config }) { + const accountId = await requireAuth(config); + logger.log( + name + ? `Uploading CA Certificate ${name}...` + : `Uploading CA Certificate...` + ); + const certResponse = await uploadCaCertificateFromFs(accountId, { + certificates: caCert, + ca: true, + name, + }); + const expiresOn = new Date(certResponse.expires_on).toLocaleDateString(); + logger.log( + name + ? `Success! Uploaded CA Certificate ${name}` + : `Success! Uploaded CA Certificate` + ); + logger.log(`ID: ${certResponse.id}`); + logger.log(`Issuer: ${certResponse.issuer}`); + logger.log(`Expires on ${expiresOn}`); + } +}); + +// wrangler cert list +export const certListCommand = createCommand({ + metadata: { + description: "List uploaded mTLS certificates", + status: "stable", + owner: "Product: SSL" + }, + async handler(_, { config }) { + const accountId = await requireAuth(config); + const certificates = await listMTlsCertificates(accountId, {}, true); + for (const certificate of certificates) { + logger.log(`ID: ${certificate.id}`); + if (certificate.name) { + logger.log(`Name: ${certificate.name}`); + } + logger.log(`Issuer: ${certificate.issuer}`); + logger.log( + `Created on: ${new Date(certificate.uploaded_on).toLocaleDateString()}` + ); + logger.log( + `Expires on: ${new Date(certificate.expires_on).toLocaleDateString()}` + ); + if (certificate.ca) { + logger.log( + `CA: ${certificate.ca}` + ) + } + logger.log("\n"); + } + } +}); + +// wrangler cert delete +export const certDeleteCommand = createCommand({ + metadata: { + description: "Delete an mTLS certificate", + status: "stable", + owner: "Product: SSL" + }, + args: { + id: { + description: "The id of the mTLS certificate to delete", + type: "string" + }, + name: { + description: "The name of the mTLS certificate record to delete", + type: "string" + } + }, + async handler({id, name}, { config }) { + const accountId = await requireAuth(config); + if (id && name) { + return logger.error(`Can't provide both --id and --name.`); + } else if (!id && !name) { + return logger.error(`Must provide --id or --name.`); + } + let certificate: MTlsCertificateResponse; + if (id) { + certificate = await getMTlsCertificate(accountId, id); + } else { + certificate = await getMTlsCertificateByName(accountId, name as string, true); + } + + const response = await confirm( + `Are you sure you want to delete certificate ${certificate.id}${certificate.name ? ` (${certificate.name})` : ""}?` + ); + if (!response) { + logger.log("Not deleting"); + return; + } + + await deleteMTlsCertificate(accountId, certificate.id); + + logger.log( + `Deleted certificate ${certificate.id} ${certificate.name ? `(${certificate.name})` : ""} successfully` + ); + } +}); + diff --git a/packages/wrangler/src/core/teams.d.ts b/packages/wrangler/src/core/teams.d.ts index 204e2312f745..ccdab8697572 100644 --- a/packages/wrangler/src/core/teams.d.ts +++ b/packages/wrangler/src/core/teams.d.ts @@ -15,4 +15,5 @@ export type Teams = | "Product: Hyperdrive" | "Product: Vectorize" | "Product: Workflows" - | "Product: Cloudchamber"; + | "Product: Cloudchamber" + | "Product: SSL"; diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 0a8f6e8d894c..ccc465e1e565 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -82,6 +82,14 @@ import { telemetryStatusCommand, } from "./metrics/commands"; import { mTlsCertificateCommands } from "./mtls-certificate/cli"; +import { + certNamespace, + certUploadNamespace, + certUploadCaCertCommand, + certUploadMtlsCommand, + certListCommand, + certDeleteCommand + } from "./cert/cert"; import { writeOutput } from "./output"; import { pages } from "./pages"; import { APIError, formatMessage, ParseError } from "./parse"; @@ -897,6 +905,17 @@ export function createCLIParser(argv: string[]) { } ); + // cert - includes mtls-certificates and CA cert management + registry.define([ + { command: "wrangler cert", definition: certNamespace }, + { command: "wrangler cert upload", definition: certUploadNamespace }, + { command: "wrangler cert upload mtls-certificate", definition: certUploadMtlsCommand }, + { command: "wrangler cert upload certificate-authority", definition: certUploadCaCertCommand }, + { command: "wrangler cert list", definition: certListCommand }, + { command: "wrangler cert delete", definition: certDeleteCommand}, + ]); + registry.registerNamespace("cert"); + // pages wrangler.command("pages", "⚡️ Configure Cloudflare Pages", (pagesYargs) => { // Pages does not support the `--config`,