From 7c2094c6314e4b60eca98145181cfc04ad9dd168 Mon Sep 17 00:00:00 2001 From: "wille.io" Date: Sun, 17 Mar 2024 22:42:03 +0100 Subject: [PATCH] added dns-01 challenge over Cloudflare; better command line parsing; cleanups --- .gitignore | 2 +- README.md | 34 ++- acme.ts | 660 ++++++++++++++++++++++++++++++++++++++--------------- cli.ts | 138 +++++++---- 4 files changed, 600 insertions(+), 234 deletions(-) diff --git a/.gitignore b/.gitignore index 728a723..9f95971 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -*.pem *.crt +*.pem deno.lock \ No newline at end of file diff --git a/README.md b/README.md index 3d00ac1..8787b9e 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,41 @@ # deno-acme [![Latest version](https://deno.land/badge/acme/version)](https://deno.land/x/acme) -Get certificates for your domains and subdomains via http challenges from an acme server. -Use the CLI as a standalone acme client, ... -or use the acme.ts library to use it in your own application. +Get certificates for your domains and or your domains their subdomains from an acme server. +Supports http-01 challenges and dns-01 challenges with domains hosted with Cloudflare's DNS server. +Use the CLI as a standalone acme client, or use the acme.ts library to use it in your own application. -## Prerequisites -- Port 80 needs to be available on the maschine running the acme cli -- The requested domain name(s) need to point the IP address of the maschine running the acme cli +## Prerequisites for HTTP challenge +- Port 80 needs to be available on the maschine running the acme cli or ... - (optional) Port 80 needs to be forwarded to the maschine running the acme cli +- The requested domain name(s) need to point the IP address of the maschine running the acme cli + +## Prerequisites for Cloudflare DNS challenge +- Domain and / or subdomain(s) with nameservers pointing to Cloudflare +- Cloudflare API token with edit privileges for the given domain(s) / subdomain(s) DNS zone ## CLI How to get & use the CLI: ``` -sudo deno install -A --allow-read=. --allow-write=. --allow-net --name acme --root /usr/local/ https://deno.land/x/acme/cli.ts -sudo acme example.com +sudo deno install -A --allow-read=. --allow-write=. --allow-net --name acme --root /usr/local/ https://deno.land/x/acme@v0.3.0/cli.ts +# http challenge: +sudo acme http example.com,subdomain.example.com +# cloudflare dns challenge: +sudo acme cloudflare example.com,subdomain.example.com ``` ## Library To use acme as a library in your application, add the following: ``` -import * as ACME from "https://deno.land/x/acme/acme.ts" -const { domainCertificates } = await ACME.getCertificateForDomain("example.com", "https://acme-staging-v02.api.letsencrypt.org/directory"); +import * as ACME from "https://deno.land/x/acme@v0.3.0/acme.ts" + +// http challenge: +const { domainCertificates } = await ACME.getCertificatesWithHttp("example.com", "https://acme-staging-v02.api.letsencrypt.org/directory"); +console.log(domainCertificates); + +// cloudflare dns challenge: +const cloudflareToken = Deno.env.get("CLOUDFLARE_TOKEN"); +const { domainCertificates } = await ACME.getCertificatesWithCloudflare(cloudflareToken, "example.com", "https://acme-staging-v02.api.letsencrypt.org/directory"); console.log(domainCertificates); ``` diff --git a/acme.ts b/acme.ts index 835ae49..2ea07de 100644 --- a/acme.ts +++ b/acme.ts @@ -5,73 +5,110 @@ import { encode as encodeBase64Url } from "https://deno.land/std@0.193.0/encodin import { decode as decodeHex } from "https://deno.land/std@0.193.0/encoding/hex.ts"; -export type Domain = { domainName: string, subdomains?: string[] }; +type JoseAccountKeys = { publicKey: jose.KeyLike, privateKey: jose.KeyLike, exists: boolean }; + + +// exports + + +export interface Domain { domainName: string, subdomains?: string[] }; export type DomainCertificate = { domainName: string, subdomains?: string[], pemCertificate: string, pemPublicKey: string, pemPrivateKey: string }; export type AccountKeys = { pemPublicKey: string, pemPrivateKey: string }; -export async function getCertificateForDomain(domainName: string, acmeDirectoryUrl: string, yourEmail?: string, - pemAccountKeys?: AccountKeys) +async function getAccountKeys(pemAccountKeys?: AccountKeys) +{ + if (pemAccountKeys) + { + return { + publicKey: await jose.importSPKI(pemAccountKeys.pemPublicKey, "ES256", { extractable: true }), + privateKey: await jose.importPKCS8(pemAccountKeys.pemPrivateKey, "ES256", { extractable: true }), + exists: true, + } satisfies JoseAccountKeys; + } + + return { ...(await jose.generateKeyPair('ES256', { extractable: true })), exists: false } satisfies JoseAccountKeys; +} + + +export async function getCertificateWithHttp(domainName: string, acmeDirectoryUrl: string, options?: { yourEmail?: string, + pemAccountKeys?: AccountKeys, csrInfo?: CSRInfo }) : Promise<{domainCertificate: DomainCertificate, pemAccountKeys: AccountKeys}> { - const ret = await getCertificateForDomainsWithSubdomains([{ domainName }], acmeDirectoryUrl, yourEmail, pemAccountKeys); + const ret = await getCertificatesWithHttp([{ domainName }], acmeDirectoryUrl, options); return { domainCertificate: ret.domainCertificates[0], pemAccountKeys: ret.pemAccountKeys }; } -export async function getCertificateForDomainsWithSubdomains(domains: Domain[], acmeDirectoryUrl: string, yourEmail?: string, - pemAccountKeys?: AccountKeys) +export async function getCertificatesWithHttp(domains: Domain[], acmeDirectoryUrl: string, options?: { yourEmail?: string, + pemAccountKeys?: AccountKeys, csrInfo?: CSRInfo }) : Promise<{domainCertificates: DomainCertificate[], pemAccountKeys: AccountKeys}> { - let accountKeys: { publicKey: jose.KeyLike, privateKey: jose.KeyLike }; + return new ACMEHttp(await getAccountKeys(options?.pemAccountKeys), domains, acmeDirectoryUrl, options?.yourEmail, options?.csrInfo).getCertificates(); +} - if (pemAccountKeys) - { - //console.log("pemAccountKeys", pemAccountKeys); - accountKeys = - { - publicKey: await jose.importSPKI(pemAccountKeys.pemPublicKey, "ES256", { extractable: true }), - privateKey: await jose.importPKCS8(pemAccountKeys.pemPrivateKey, "ES256", { extractable: true }), - }; - } - else - { - accountKeys = await jose.generateKeyPair('ES256', { extractable: true }); - } - return new ACME(accountKeys, domains, acmeDirectoryUrl, yourEmail).getCertificateForDomainsWithSubdomains(); +export async function getCertificateWithCloudflare(bearer: string, domainName: string, acmeDirectoryUrl: string, options: { yourEmail?: string, + pemAccountKeys?: AccountKeys, csrInfo?: CSRInfo }) + : Promise<{domainCertificate: DomainCertificate, pemAccountKeys: AccountKeys}> +{ + const ret = await getCertificatesWithCloudflare(bearer, [{ domainName }], acmeDirectoryUrl, options); + return { domainCertificate: ret.domainCertificates[0], pemAccountKeys: ret.pemAccountKeys }; +} + + +export async function getCertificatesWithCloudflare(bearer: string, domains: Domain[], acmeDirectoryUrl: string, options?: { yourEmail?: string, + pemAccountKeys?: AccountKeys, csrInfo?: CSRInfo }) + : Promise<{domainCertificates: DomainCertificate[], pemAccountKeys: AccountKeys}> +{ + return new ACMECloudflare(bearer, await getAccountKeys(options?.pemAccountKeys), domains, acmeDirectoryUrl, options?.yourEmail, options?.csrInfo).getCertificates(); +} + + +export interface CSRInfo +{ + countryCode: string; + organization: string; } // end of exports -type Nonce = string; +type Nonce = string; type AcmeDirectoryUrls = { newAccount: string, newNonce: string, newOrder: string }; +type Auth = { challengeUrl: string, keyAuth: string, token: string, authUrl: string }; + + +function orError(message: string): never +{ + throw new Error(message); +} -class ACME +abstract class ACMEBase { - public constructor(accountKeys: { publicKey: jose.KeyLike, privateKey: jose.KeyLike }, - domains: Domain[], acmeDirectoryUrl: string, email?: string) + public constructor(challengeType: string, accountKeys: JoseAccountKeys, + domains: Domain[], acmeDirectoryUrl: string, email?: string, csrInfo?: CSRInfo) { this.nonce = null; this.accountKeys = accountKeys; this.email = email; this.kid = null; this.domains = domains; - this.jwk = null; this.acmeDirectoryUrl = acmeDirectoryUrl; + this.csr = csrInfo;// || { countryCode: "US", organization: "deno-acme" }; + this.challengeType = challengeType; } - public async getCertificateForDomainsWithSubdomains() + public async getCertificates() : Promise<{domainCertificates: DomainCertificate[], pemAccountKeys: AccountKeys}> { - await this.processAcmeDirectory(); + const acmeDirectoryUrls: AcmeDirectoryUrls = await this.processAcmeDirectory(); - await this.getNonce(); - await this.createAccount(); + await this.getNonce(acmeDirectoryUrls.newNonce); + const jwk = await this.createAccount(acmeDirectoryUrls.newAccount); const domainCertificates: DomainCertificate[] = []; @@ -79,29 +116,25 @@ class ACME { try { - const { finalizeUrl, authUrls } = await this.newOrder(domain); - - const auths = []; + const { finalizeUrl, authUrls, orderUrl } = await this.newOrder(domain, acmeDirectoryUrls.newOrder); + + const auths: Auth[] = []; for (const authUrl of authUrls) { - auths.push(await this.newAuth(authUrl)); - } - - for (const auth of auths) - { - // TODO: use one webserver; and fire all challenges at once - await this.newChallenge(auth.token, auth.keyAuth, auth.challengeUrl); + auths.push(await this.newAuth(authUrl, jwk)); } - - const { orderUrl, domainPublicKey, domainPrivateKey } = await this.newFinalize(domain, finalizeUrl); + + await this.newChallenges(domain, auths); + + const { domainPublicKey, domainPrivateKey } = await this.newFinalize(domain, finalizeUrl); const { certificateUrl } = await this.newReorder(orderUrl); const cert = await this.getCert(certificateUrl); domainCertificates.push( - { + { domainName: domain.domainName, subdomains: domain.subdomains, - pemCertificate: cert, + pemCertificate: cert, pemPublicKey: domainPublicKey, pemPrivateKey: domainPrivateKey, }); @@ -115,8 +148,8 @@ class ACME } - const pemAccountKeys: AccountKeys = - { + const pemAccountKeys: AccountKeys = + { pemPublicKey: await jose.exportSPKI(this.accountKeys.publicKey), pemPrivateKey: await jose.exportPKCS8(this.accountKeys.privateKey), }; @@ -127,25 +160,25 @@ class ACME // private private nonce: Nonce | null; - private accountKeys: { publicKey: jose.KeyLike, privateKey: jose.KeyLike }; + private accountKeys: JoseAccountKeys; private email: string | undefined; private kid: string | null; private domains: Domain[]; - private jwk: jose.JWK | null; private acmeDirectoryUrl: string; - private acmeDirectoryUrls: AcmeDirectoryUrls; - + private csr: CSRInfo | undefined; + private challengeType: string; + private async processAcmeDirectory() { const res = await fetch(this.acmeDirectoryUrl); await checkResponseStatus(res, 200); - this.acmeDirectoryUrls = await res.json(); //console.log("this.acmeDirectoryUrls", this.acmeDirectoryUrls); + return await res.json() as AcmeDirectoryUrls; } - private async post(url: string, payload: string | Record = "", + protected async post(url: string, payload: string | Record = "", expectedStatus: number | number[] = 200, additionalProtectedHeaderValues?: Record) : Promise { @@ -154,16 +187,16 @@ class ACME const jws = await new jose.FlattenedSign( new TextEncoder().encode(payloadString)) .setProtectedHeader( - { - alg: 'ES256', - b64: true, - nonce: this.nonce, + { + alg: 'ES256', + b64: true, + nonce: this.nonce, url: url, ...(this.kid ? { kid: this.kid } : {}), ...additionalProtectedHeaderValues, }) .sign(this.accountKeys.privateKey); - + const res = await fetch(url, { method: "POST", @@ -173,20 +206,22 @@ class ACME }, body: JSON.stringify(jws), }); - + await checkResponseStatus(res, ...(Array.isArray(expectedStatus) ? expectedStatus : [expectedStatus])); //Array.isArray(expectedStatus) ? expectedStatus as number[] : [expectedStatus as number]); this.nonce = getNonceFromResponse(res); - + + //console.log("post", url, res.headers, res.statusText); + return res; } - private async getNonce() + private async getNonce(url: string) { - console.debug("nonce.."); - const res = await fetch(this.acmeDirectoryUrls.newNonce, - { + //console.debug("> nonce"); + const res = await fetch(url, + { method: "HEAD", }); @@ -196,90 +231,124 @@ class ACME } - private async newAuth(authUrl: string) + private async newAuth(authUrl: string, jwk: jose.JWK): Promise { - console.debug("authz.."); + //console.debug("> newAuth"); + //console.log("post auth", authUrl); const res = await this.post(authUrl); const json = await res.json(); + //console.log("auth json", json); + + const status = getStringFromJson("status", json); + + if (!["pending", "valid"].includes(status)) + { + throw new Error(`order status not 'valid' or 'pending', but '${status}' - response: ${JSON.stringify(json)}`); // TODO: error message instead of full json + } type Challenge = { type: string, url: string, token: string }; const challenges: Challenge[] = getValueFromJson("challenges", json) as Challenge[]; + //console.log("challenges", challenges); - const httpChallenge = challenges.find(obj => obj.type === "http-01"); + const challenge = challenges.find(obj => obj.type === this.challengeType); + //console.log("chosen challenge", challenge); - if (!httpChallenge) - throw new Error("newAuth: no suitable challenge (http-01) received from acme server!"); + if (!challenge) + { + throw new Error(`newAuth: no suitable challenge (${this.challengeType}) received from acme server!`); + } + + // TODO: check if challenge 'status' already 'valid' - then directly finalize - checkUrl(httpChallenge.url); + checkUrl(challenge.url); - if (httpChallenge.token.length < 1) - throw new Error("newAuth: no suitable token in http-01 challenge received from acme server!"); + if (challenge.token.length < 1) + { + throw new Error(`newAuth: no suitable token in ${this.challengeType} challenge received from acme server!`); + } - const keyAuth = httpChallenge.token + '.' + await jose.calculateJwkThumbprint(this.jwk); + const keyAuth = challenge.token + '.' + await jose.calculateJwkThumbprint(jwk); - return { challengeUrl: httpChallenge.url, keyAuth: keyAuth, token: httpChallenge.token }; + return { challengeUrl: challenge.url, keyAuth: keyAuth, token: challenge.token, authUrl }; } - private async newOrder(domain: Domain) + private async newOrder(domain: Domain, url: string) { - console.debug("new order.."); + //console.debug("> newOrder"); - const url = this.acmeDirectoryUrls.newOrder; const domainName = domain.domainName; - const identifiersArray = + const identifiersArray = [ { "type": "dns", "value": domainName }, ...(domain.subdomains?.map(subdomain => ({ type: "dns", value: subdomain + "." + domainName })) || []), // <= subdomains ]; - const res = await this.post(url, + //console.log("post new order", url); + const res = await this.post(url, { - "identifiers": // - identifiersArray, + "identifiers": identifiersArray, }, 201); + const orderUrl = checkUrl(res.headers.get("Location") || orError("Location header missing")); + //console.log("orderUrl (from Location header)", orderUrl); + const json = await res.json(); + //console.log("order json", json); - const finalizeUrl = getValueFromJson("finalize", json) as string; - checkUrl(finalizeUrl); + // TODO: check if 'status' already 'ready' - then directly finalize + + const finalizeUrl = checkUrl(getStringFromJson("finalize", json) as string); + //console.log("finalizeUrl", finalizeUrl); const authUrls = (getValueFromJson("authorizations", json) as string[]); authUrls.forEach(authUrl => { checkUrl(authUrl) }); + //console.log("authUrls", authUrls); - return { finalizeUrl: finalizeUrl, authUrls: authUrls }; + return { finalizeUrl, authUrls, orderUrl }; } - - private async createAccount()//: Promise + + private async createAccount(url: string)//: Promise { - console.debug("create account.."); - this.jwk = await jose.exportJWK(this.accountKeys.publicKey); - - const url = this.acmeDirectoryUrls.newAccount; + //console.debug("create account.. exists?", this.accountKeys.exists); + + const jwk = await jose.exportJWK(this.accountKeys.publicKey); - const res = await this.post(url, + // TODO: 7.3.3. ? + + const res = await this.post(url, { - "termsOfServiceAgreed": true, - "contact": (this.email ? [ `mailto:${this.email}`, ] : null), - }, - [200, 201], { jwk: this.jwk }); + ...(this.accountKeys.exists ? + { + onlyReturnExisting: true + } + : + { + termsOfServiceAgreed: true, + contact: (this.email ? [ `mailto:${this.email}`, ] : null), + } + ) + }, + (this.accountKeys.exists ? 200 : 201), { jwk }); this.kid = getHeaderFromResponse(res, "location"); - console.debug("kid", this.kid); + //console.debug("kid", this.kid); + + return jwk; } private async newReorder(orderUrl: string) { - console.debug("reorder.."); + //console.debug("> reorder"); const res = await this.post(orderUrl); const json = await res.json(); - const certificateUrl = getValueFromJson("certificate", json) as string; + const certificateUrl = getStringFromJson("certificate", json) as string; checkUrl(certificateUrl); @@ -289,7 +358,7 @@ class ACME private async newFinalize(domain: Domain, finalizeUrl: string) { - console.debug("finalize.."); + //console.debug("> finalize"); const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { extractable: true }); // keys for the csr and the to-be requested certificate(s) const spkiPemPubCSR = await jose.exportSPKI(publicKey); @@ -298,33 +367,38 @@ class ACME const publicKeyCSR = KEYUTIL.getKey(spkiPemPubCSR); const privateKeyCSR = KEYUTIL.getKey(pkcs8PemPrvCSR); - const subjectAltNameArray = + const subjectAltNameArray = [ - { dns: domain.domainName }, + { dns: domain.domainName }, ...(domain.subdomains?.map(subdomain => ({ dns: subdomain+"."+domain.domainName }) ) || []), // <= subdomains ]; const csr = new KJUR.asn1.csr.CertificationRequest( { - subject: { str: `/C=DE/O=wille.io/CN=${domain.domainName}` }, + // TODO: add available csr info(s) to subject + + //subject: { str: `/C=${this.csr.countryCode}/O=${this.csr.organization.replace("/","//")}/CN=${domain.domainName}` }, + subject: { str: `/CN=${domain.domainName}` }, sbjpubkey: publicKeyCSR, extreq: [{ extname: "subjectAltName", array: subjectAltNameArray }], sigalg: "SHA256withECDSA", sbjprvkey: privateKeyCSR, }); - + const csrDerHexString = csr.tohex(); const csrDerHexRaw = decodeHex(new TextEncoder().encode(csrDerHexString)); const csrDer = encodeBase64Url(csrDerHexRaw); - + const res = await this.post(finalizeUrl, { csr: csrDer }); const json = await res.json(); - const status = getValueFromJson("status", json) as string; + const status = getStringFromJson("status", json) as string; if (["invalid", "pending"].includes(status)) + { throw new Error(`newFinalize: acme server answered with status 'invalid' or 'pending': '${json}' - headers: '${res.headers}'`); - + } + // pending means: 'The server does not believe that the client has fulfilled the requirements.' see https://datatracker.ietf.org/doc/html/rfc8555#section-7.4 // TODO: be able to re-do challenges on pending? @@ -340,7 +414,7 @@ class ACME { const retryAfter: number = (parseInt(res.headers.get("retry-after") || "10") || 10) + 2; - console.log(`waiting ${retryAfter} seconds for the acme server to process our certificate...`); + //console.log(`waiting ${retryAfter} seconds for the acme server to process our certificate...`); await waitSeconds(retryAfter); } @@ -351,117 +425,320 @@ class ACME private async getCert(certificateUrl: string): Promise { - console.debug("cert.."); + //console.debug("> cert"); const res = await this.post(certificateUrl); const text = await res.text(); const certList = text.split("-----END CERTIFICATE-----").map(cert => cert + "-----END CERTIFICATE-----"); if (certList.length < 1) + { throw new Error("getCert: no valid certificate received from acme server - response text was: " + text); + } const cert = certList[0]; - // const encoder = new TextEncoder(); - // const decoder = new TextDecoder(); - ////for (const cert of certList) - // { - // const p = new Deno.Command("openssl", - // { - // args: "x509 -noout -text".split(" "), - // stdout: "piped", - // stdin: "piped" , - // stderr: "piped", - // }).spawn(); - - // const stderrReader = p.stderr.getReader(); - // stderrReader.read().then(x => { if (!x.done) console.log("CERT ERROR:", decoder.decode(x.value)); }); - - // const writer = p.stdin.getWriter(); - // await writer.write(encoder.encode(cert)); - // await writer.close(); - - // const reader = p.stdout.getReader(); - // const r = await reader.read(); - - // if (!r.done) - // console.log("CERT!", decoder.decode((r).value)); - // } - return cert; } - private async newChallenge(token: string, keyAuth: string, challengeUrl: string) + abstract newChallenges(domain: Domain, auths: Auth[]): Promise; + + +} // class ACME + + +class ACMECloudflare extends ACMEBase +{ + private bearer: string; + + + public constructor(bearer: string, accountKeys: JoseAccountKeys, + domains: Domain[], acmeDirectoryUrl: string, email?: string, csrInfo?: CSRInfo) { - console.debug("challe.."); + super("dns-01", accountKeys, domains, acmeDirectoryUrl, email, csrInfo); - const controller = new AbortController(); - const signal = controller.signal; + this.bearer = bearer + } - let _resolve: () => void; - const promise: Promise = new Promise((resolve) => _resolve = resolve); + async newChallenges(domain: Domain, auths: Auth[]) + { + //console.log("> newChallenges: " + auths.length); - // TODO: don't use serve - serve((request: Request): Response => + // find zone id of given zone + const cloudflareZoneId = await (async (cloudflareZone) => { - console.log("!!!"); - if (!request.url.endsWith("/.well-known/acme-challenge/" + token)) - return new Response(null, { status: 400 }); - - _resolve(); - - return new Response(keyAuth, { status: 200, headers: { "content-type": "application/octet-stream" } }); - }, - { - hostname: "0.0.0.0", - port: 80, - signal: signal, - onListen: () => {} - }); // NOTE: event loop now active! - - - console.log("webserver started, starting challenge..."); - - //console.log( - await this.post(challengeUrl, {}) - // ); - ; - - console.log("waiting for acme server to make a request... (timeout: 10 seconds)"); + const rep = await fetch(`https://api.cloudflare.com/client/v4/zones`, + { + method: "GET", + headers: + { + "authorization": "Bearer " + this.bearer, + }, + }); + + if (rep.status !== 200) + { + throw new Error(`Unable to find Zone id from zone (http status '${rep.status}'): ${await rep.json()}`); + } + + const json = await rep.json(); + + const result = getValueFromJson("result", json) as { id: string, name: string }[]; + + for (const entry of result) + { + const id = getStringFromJson("id", entry); + const name = getStringFromJson("name", entry); + + // maybe the name is a subdomain inside that zone... going back one full-stop at a time + const fullStops = cloudflareZone.split("."); + while (fullStops.length >= 2) + { + //console.log("fullStops", fullStops); + + if (name === fullStops.join(".")) + { + return id; + } + + fullStops.shift(); + } + } + + // if code came here, the zone was not found + throw new Error(`Unable to find zone id for zone '${cloudflareZone}' with the given bearer. Does the zone with that name exist and does the bearer have access to that zone?`); + })(domain.domainName); + + + const dnsRecordIds: string[] = []; try { - await promiseWithTimeout(promise, 10 * 1000); // waiting for http request from letsencrypt .. + for (const auth of auths) + { + //console.log("auth... with challenge url", auth.challengeUrl); + + const dnsNames: string[] = [ domain.domainName, ...(domain.subdomains || []) ]; + + const keyAuthData = new TextEncoder().encode(auth.keyAuth); + const keyAuthHash = await crypto.subtle.digest('SHA-256', keyAuthData); + const txtRecordContent = encodeBase64Url(keyAuthHash); + + // create txt records on cloudflare + for (const dnsName of dnsNames) + { + const rep = await fetch(`https://api.cloudflare.com/client/v4/zones/${cloudflareZoneId}/dns_records`, + { + method: "POST", + body: JSON.stringify( + { + content: txtRecordContent, + name: "_acme-challenge." + dnsName, + proxied: false, + type: "TXT", + comment: "temporary acme challenge, created by deno-acme at " + new Date().toISOString(), + ttl: 60, + }), + headers: + { + "authorization": "Bearer " + this.bearer, + "content-type": "application/json", + }, + }); + + if (rep.status !== 200) + { + throw new Error(`Failed to create dns record at cloudflare (http status '${rep.status}'): ${JSON.stringify(await rep.json())}`); + } + + const json = await rep.json(); + + const id = getStringFromJson("id", getValueFromJson("result", json) as Record); + + dnsRecordIds.push(id); + + //console.log("cloudflare create dns record success", json.result.id);//, json); + } + + //console.log("all cloudflare dns records created"); + } + + + // all auths done, now waiting for the order to be processed + //console.log("giving the acme server time (10s) to catch up..."); + //await waitSeconds(10); + + + // fire all challenges + for (const auth of auths) + { + // tell acme server that the challenge has been solved + //console.log("post challenge", auth.challengeUrl); + + //const challengeResult = + await this.post(auth.challengeUrl, {}); + + //const challengeJson = await challengeResult.json(); + //console.log("challenge json", challengeJson, "http status", challengeResult.status); - console.log("first request received by acme server, waiting 4 seconds..."); - await waitSeconds(4); + //const challengeStatus = getStringFromJson("status", challengeJson); + + //console.log("challengeStatus", challengeStatus);//, "token:", json.token, "given token:", auth.token); + //} + + // TODO: if 'status' already 'valid' - directly finalize + + + // all challenged done, now waiting for the order to be processed + //console.log("waiting for acme server do all its dns checks..."); + + // console.log("waiting 20 seconds..."); + // await waitSeconds(20); // TODO: respect 'Retry-After' header + + //console.log("post AUTH", auth.authUrl); + const authStatus = await this.post(auth.authUrl); + + // if (!authStatus.ok) + // { + // throw new Error("Order status check failed: " + JSON.stringify(await authStatus.json())); + // } + + const authJson = await authStatus.json(); + //console.log("authJson", authJson); + + const status = getStringFromJson("status", authJson); + + //console.log("status", status); + + if (!["pending", "valid"].includes(status)) + { + throw new Error(`response auth status not 'pending' or 'valid', but '${status}': response: ${JSON.stringify(authJson)}`); // TODO: error message instead of whole json + } + } } - catch(e) + catch (err) { - throw new Error("letsencrypt didn't answer in 7 seconds: " + e); + console.error("one auth failed - giving up", err); + throw err; } finally { - controller.abort(); // aka. close webserver - console.log("waiting 2 seconds for the server to stop listening..."); - await waitSeconds(4); + for (const dnsRecordId of dnsRecordIds) + { + try + { + const rep = await fetch(`https://api.cloudflare.com/client/v4/zones/${cloudflareZoneId}/dns_records/${dnsRecordId}`, + { + method: "DELETE", + headers: + { + "authorization": "Bearer " + this.bearer, + }, + }); + + if (rep.status !== 200) + { + console.error(`cloudflare failed to delete temporary acme challege (http status '${rep.status}'): ${await rep.json()}`); + } + } + catch (err) + { + console.error("failed to delete temporary acme challenge from cloudflare - you have to delete it manually", err); + } + } } + + //console.log("all auths done"); } -} // class ACME +} + + +class ACMEHttp extends ACMEBase +{ + public constructor(accountKeys: JoseAccountKeys, + domains: Domain[], acmeDirectoryUrl: string, email?: string, csrInfo?: CSRInfo) + { + super("http-01", accountKeys, domains, acmeDirectoryUrl, email, csrInfo); + } + + + async newChallenges(_domain: Domain, auths: Auth[]) + { + // TODO: check order status.... + + // TODO: one webserver for all auths + for (const auth of auths) + { + const { token, keyAuth, challengeUrl } = auth; + + const controller = new AbortController(); + const signal = controller.signal; + + let _resolve: () => void; + const promise: Promise = new Promise((resolve) => _resolve = resolve); + + + // TODO: don't use serve + serve((request: Request): Response => + { + //console.log("!!!"); + if (!request.url.endsWith("/.well-known/acme-challenge/" + token)) + return new Response(null, { status: 400 }); + + _resolve(); + + return new Response(keyAuth, { status: 200, headers: { "content-type": "application/octet-stream" } }); + }, + { + hostname: "0.0.0.0", + port: 80, + signal: signal, + onListen: () => {} + }); // NOTE: event loop now active! + + + //console.log("webserver started, starting challenge..."); + + //console.log( + await this.post(challengeUrl, {}) + // ); + ; + + //console.log("waiting for acme server to make a request... (timeout: 10 seconds)"); + try + { + await promiseWithTimeout(promise, 10 * 1000); // waiting for http request from letsencrypt .. + + //console.log("first request received by acme server, waiting 4 seconds..."); + await waitSeconds(4); + } + catch(e) + { + throw new Error("letsencrypt didn't answer in 7 seconds: " + e); + } + finally + { + controller.abort(); // aka. close webserver + //console.log("waiting 2 seconds for the server to stop listening..."); + await waitSeconds(4); + } + } + } +} async function promiseWithTimeout(promise: Promise, timeoutMs: number): Promise { let timeoutTimer: ReturnType; - const timeoutPromise = new Promise((_, reject) => + const timeoutPromise = new Promise((_, reject) => { timeoutTimer = setTimeout(() => { reject(new Error("Timeout exceeded!")); }, timeoutMs); }); - return await Promise.race([promise, timeoutPromise]) - .finally(() => + return await Promise.race([promise, timeoutPromise]) + .finally(() => { clearTimeout(timeoutTimer); }); @@ -481,6 +758,32 @@ async function checkResponseStatus(res: Response, ...expectedStatus: number[]) } +function getStringFromJson(key: string, json: Record): string +{ + const val = getValueFromJson(key, json); + + if (typeof(val) !== "string") + { + throw new Error(`getStringFromJson: value for key '${key}' is not of type 'string'! Type is '${typeof(val)}'`); + } + + return val as string; +} + + +// function getNumberFromJson(key: string, json: Record): number +// { +// const val = getValueFromJson(key, json); + +// if (typeof(val) !== "number") +// { +// throw new Error(`getNumberFromJson: value for key '${key}' is not of type 'number'! Type is '${typeof(val)}'`); +// } + +// return val as number; +// } + + function getValueFromJson(key: string, json: Record): unknown { if (!(key in json)) @@ -489,10 +792,11 @@ function getValueFromJson(key: string, json: Record): unknown } -function checkUrl(url: string): void +function checkUrl(url: string): string { if (!url.startsWith("https://")) throw new Error(`checkUrl: not a https link: '${url}'`); + return url; } diff --git a/cli.ts b/cli.ts index 82260d9..cbb7f36 100644 --- a/cli.ts +++ b/cli.ts @@ -1,46 +1,25 @@ import * as ACME from "./acme.ts" -import { Command } from "https://deno.land/x/cliffy@v1.0.0-rc.2/command/command.ts"; +import { Command } from "https://deno.land/x/cliffy@v1.0.0-rc.3/command/command.ts"; +import { ensureDir } from "https://deno.land/std@0.219.0/fs/mod.ts"; -// console.dir(parse(Deno.args, -// { -// string: ["d"], -// default: { d: "https://acme-v02.api.letsencrypt.org/directory" } -// })); -// Deno.exit(); - - -// function printUsageAndExit() -// { -// const appName = new URL(import.meta.url).pathname; -// console.error(`usage: ${appName} <...(domain<...,subdomain>)> \r\n` -// + `minimal example: ${appName} ` -// + `minimal example: ${appName} admin@example.com example.com,sub1.example.com,sub2.example.com another-example.com` -// ); -// Deno.exit(); -// } - - -async function main() +async function shared1(accountDirectory: string, domainsWithSubdomains: string[]) { - const command = new Command() - .name("acme-cli") - //.version() - .description("Get certificates for your domains and subdomains via http challenges from an acme server. \r\nSubdomains are added to the domain names with commas. Example: example.com,subdomain.example.com,another-subdomain.example.com") - .option("-d, --directory ", "https url to the acme server's acme directory.", { default: "https://acme-v02.api.letsencrypt.org/directory" as const }) - .option("-e, --email ", "Your email address for the acme server to notify you on notifications of your certificates.") - .arguments("[domains...:string]"); - - const x = await command.parse(Deno.args); - const email = x.options.email; - const directory = x.options.directory; - const domainsWithSubdomains = x.args; + try + { + await ensureDir(accountDirectory); + } + catch (e) + { + console.error("Could not create account directory", e); + Deno.exit(1); + } if (domainsWithSubdomains.length < 1) { command.showHelp(); console.error("Provide at least one domain as argument."); - Deno.exit(-1); + Deno.exit(1); } const domains: ACME.Domain[] = []; @@ -57,12 +36,19 @@ async function main() { command.showHelp(); console.error("Subdomains need to end with the main domain's name."); - Deno.exit(-1); + Deno.exit(1); } } - - split[1]; - domains.push({ domainName: domainName, ...((subdomains.length > 0) ? ({ subdomains: subdomains }) : {}) }); + + //split[1]; + + const domain = + { + domainName: domainName, + ...((subdomains.length > 0) ? ({ subdomains: subdomains }) : {}), + } satisfies ACME.Domain; + + domains.push(domain); } @@ -71,8 +57,8 @@ async function main() try { // get existing account keys - const prv = await Deno.readTextFile("./accountPrivateKey.pem"); - const pub = await Deno.readTextFile("./accountPublicKey.pem"); + const prv = await Deno.readTextFile(accountDirectory + "/accountPrivateKey.pem"); + const pub = await Deno.readTextFile(accountDirectory + "/accountPublicKey.pem"); if (!pub || !prv) throw "no pub and / or prv"; @@ -95,13 +81,17 @@ async function main() //console.log("domain:", domain.domainName, "subdomains:", subdomains); } - const { domainCertificates, pemAccountKeys } = - await ACME.getCertificateForDomainsWithSubdomains(domains, directory, email, accountKeys); + return { domains, accountKeys }; +} + + +async function shared2(accountDirectory: string, accountKeys: ACME.AccountKeys | undefined, pemAccountKeys: ACME.AccountKeys, domainCertificates: ACME.DomainCertificate[]) +{ if (!accountKeys) { - await Deno.writeTextFile("./accountPrivateKey.pem", pemAccountKeys.pemPrivateKey); - await Deno.writeTextFile("./accountPublicKey.pem", pemAccountKeys.pemPublicKey); + await Deno.writeTextFile(accountDirectory + "/accountPrivateKey.pem", pemAccountKeys.pemPrivateKey); + await Deno.writeTextFile(accountDirectory + "/accountPublicKey.pem", pemAccountKeys.pemPublicKey); console.log("saved new account keys"); } @@ -122,4 +112,62 @@ async function main() } -main(); \ No newline at end of file + +const command = new Command() +.name("acme-cli") +.version("v0.3.0") +.description("Get certificates for your domains and or your domains their subdomains with the specified challenge type from an acme server. \r\n"+ + "One certificate is created per challenge argument. \r\n"+ + "You can either get a certificate for a domain *and* its subdomains or for a domain only (without subdomains). It is not possible to get a certificate with only subdomains (without its parent domain).\r\n"+ + "Subdomains are added to the domain name with commas. Example: example.com,subdomain.example.com,another-subdomain.example.com") +.globalOption("-d, --directory ", "Https url to the acme server's acme directory.", { default: "https://acme-v02.api.letsencrypt.org/directory" as const }) +.globalOption("-e, --email ", "Your email address for the acme server to notify you on notifications of your certificates") +.globalOption("-a, --accountDirectory ", "The directory where the account keys of your acme server will be read from / written to", { default: `${Deno.env.get("HOME") || Deno.cwd()}/.deno-acme` as const }) + +// .globalOption("-c.c, --csr-country ", "Two character, uppercase country code (ISO 3166-1 alpha-2) for the certificate's location (e.g. 'US')", { default: "US" as const }) +// .globalOption("-c.o, --csr-organization ", "The human readable name of the organization this certificate belongs to", { default: "deno-acme" as const }) + + +.command("http", "Get certificates with http challenges.") +.arguments("") +.example("Get two certificates", "example.com,subdomain.example.com subdomain.example2.com,subdomain2.example2.com") +.action(async (options, ...args) => +{ + const { email, directory, accountDirectory } = options; + //const csrInfo: ACME.CSRInfo = { countryCode: options.csrCountry, organization: options.csrOrganization }; + const domainsWithSubdomains = args; + + const { domains, accountKeys } = await shared1(accountDirectory, domainsWithSubdomains); + + const { domainCertificates, pemAccountKeys } = + await ACME.getCertificatesWithHttp(domains, directory, { yourEmail: email, pemAccountKeys: accountKeys, /*csrInfo*/ }); + + await shared2(accountDirectory, accountKeys, pemAccountKeys, domainCertificates); + Deno.exit(0); +}) + + +.command("cloudflare", "Get certificates with dns challenges for (sub-)domains hosted by Cloudflare DNS. \r\nNOTE: Your Cloudflare API token bearer must have access to all given zones.") +.env("CLOUDFLARE_BEARER=", "Your Cloudflare API token bearer to list, create and delte temporary acme TXT records with.", { required: true }) +.meta("Cloudflare API version", "4") +.arguments("") +.example("Get two certificates", "example.com:example.com,subdomain.example.com example2.com:subdomain.example2.com,subdomain2.example2.com") +.action(async (options, ...args) => +{ + const { email, directory, accountDirectory, cloudflareBearer } = options; + //const csrInfo: ACME.CSRInfo = { countryCode: options.csrCountry, organization: options.csrOrganization }; + const domainsWithSubdomains = args; + + const { domains, accountKeys } = await shared1(accountDirectory, domainsWithSubdomains); + + const { domainCertificates, pemAccountKeys } = + await ACME.getCertificatesWithCloudflare(cloudflareBearer, domains, directory, { yourEmail: email, pemAccountKeys: accountKeys, /*csrInfo*/ }); + + await shared2(accountDirectory, accountKeys, pemAccountKeys, domainCertificates); + Deno.exit(0); +}); + +await command.parse(Deno.args); + +// came here? no command action was triggered (that all call Deno.exit) +command.showHelp(); \ No newline at end of file