acme: certificate auto renew feature #353

damienzonly committed Sep 22, 2023 at 15:00 UTC bb566529c0bacf0ed8d82f722e094ab9140e6fa4
3 files changed +76 -25
src/api.net.ts
+69 -22
@@ -13,31 +13,32 @@ import { cert, getCertObject, getIps, getServerStatus, privateKey } from './list
13 import { getProjectInfo } from './github'
14 import { httpString } from './util-http'
15 import { exec } from 'child_process'
16 -import { apiAssertTypes, debounceAsync, haveTimeout, HOUR, MINUTE, objSameKeys, onlyTruthy, repeat } from './misc'
16 +import { apiAssertTypes, DAY, debounceAsync, haveTimeout, HOUR, MINUTE, objSameKeys, onlyTruthy, repeat, Dict} from './misc'
17 import acme from 'acme-client'
18 import fs from 'fs/promises'
19 -import { Dict } from './misc'
19 import { createServer, RequestListener } from 'http'
20 import { Middleware } from 'koa'
21 import { lookup, Resolver } from 'dns/promises'
22 +import { defineConfig } from './config'
23 +import events from './events'
24
24 -const client = new Client({ timeout: 4_000 })
25 -const originalMethod = client.getGateway
25 +const upnpClient = new Client({ timeout: 4_000 })
26 +const originalMethod = upnpClient.getGateway
27 // other client methods call getGateway too, so this will ensure they reuse this same result
27 -client.getGateway = debounceAsync(() => originalMethod.apply(client), 0, { retain: HOUR, retainFailure: 30_000 })
28 -client.getGateway().catch(() => {})
28 +upnpClient.getGateway = debounceAsync(() => originalMethod.apply(upnpClient), 0, { retain: HOUR, retainFailure: 30_000 })
29 +upnpClient.getGateway().catch(() => {})
30
31 export let externalIp = Promise.resolve('') // poll external ip
32 repeat(10 * MINUTE, () => {
33 const was = externalIp
33 - externalIp = client.getPublicIp().catch(() => was) //fallback to previous value
34 + externalIp = upnpClient.getPublicIp().catch(() => was) //fallback to previous value
35 })
36
37 const getNatInfo = debounceAsync(async () => {
38 const gettingIp = getPublicIp() // don't wait, do it in parallel
38 - const res = await client.getGateway().catch(() => null)
39 + const res = await upnpClient.getGateway().catch(() => null)
40 const status = await getServerStatus()
40 - const mappings = res && await haveTimeout(5_000, client.getMappings()).catch(() => null)
41 + const mappings = res && await haveTimeout(5_000, upnpClient.getMappings()).catch(() => null)
42 console.debug('mappings found', mappings)
43 const gatewayIp = res ? new URL(res.gateway.description).hostname : await findGateway().catch(() => null)
44 const localIp = res?.address || (await getIps())[0]
@@ -130,19 +131,19 @@ async function generateSSLCert(domain: string, email?: string) {
131 let check = await checkPort(domain, 80) // some check services may not consider the domain, but we already verified that
132 if (check && !check.success && upnp && externalPort !== 80) { // consider a short-lived mapping
133 // @ts-ignore
133 - await client.createMapping({ private: 80, public: { host: '', port: 80 }, description: 'hfs temporary', ttl: 30 }).catch(() => {})
134 + await upnpClient.createMapping({ private: 80, public: { host: '', port: 80 }, description: 'hfs temporary', ttl: 30 }).catch(() => {})
135 check = await checkPort(domain, 80) // repeat test
136 }
137 if (!check)
138 throw new ApiError(HTTP_FAILED_DEPENDENCY, "couldn't test port 80")
139 if (!check.success)
140 throw new ApiError(HTTP_FAILED_DEPENDENCY, "port 80 is not working on the specified domain")
140 - const client = new acme.Client({
141 + const acmeClient = new acme.Client({
142 accountKey: await acme.crypto.createPrivateKey(),
143 directoryUrl: acme.directory.letsencrypt.production
144 })
145 const [key, csr] = await acme.crypto.createCsr({ commonName: domain })
145 - const cert = await client.auto({
146 + const cert = await acmeClient.auto({
147 csr,
148 email,
149 challengePriority: ['http-01'],
@@ -212,10 +213,10 @@ const apis: ApiHandlers = {
213 if (!internalPort)
214 return new ApiError(HTTP_FAILED_DEPENDENCY, 'no internal port')
215 if (externalPort)
215 - try { await client.removeMapping({ public: { host: '', port: externalPort } }) }
216 + try { await upnpClient.removeMapping({ public: { host: '', port: externalPort } }) }
217 catch (e: any) { return new ApiError(HTTP_SERVER_ERROR, 'removeMapping failed: ' + String(e) ) }
218 if (external) // must use the object form of 'public' to work around a bug of the library
218 - await client.createMapping({ private: internal || internalPort, public: { host: '', port: external }, description: 'hfs', ttl: 0 })
219 + await upnpClient.createMapping({ private: internal || internalPort, public: { host: '', port: external }, description: 'hfs', ttl: 0 })
220 return {}
221 },
222
@@ -232,14 +233,7 @@ const apis: ApiHandlers = {
233 },
234
235 async make_cert({domain, email}) {
235 - if (!domain) return new ApiError(HTTP_BAD_REQUEST, 'bad params')
236 - const res = await generateSSLCert(domain, email)
237 - const CERT_FILE = 'acme.cert'
238 - const KEY_FILE = 'acme.key'
239 - await fs.writeFile(CERT_FILE, res.cert)
240 - await fs.writeFile(KEY_FILE, res.key)
241 - cert.set(CERT_FILE) // update config
242 - privateKey.set(KEY_FILE)
236 + await makeCert(domain, email)
237 return {}
238 },
239
@@ -248,4 +242,57 @@ const apis: ApiHandlers = {
242 }
243 }
244
245 +export const acme_domain = defineConfig<string>('acme_domain', '')
246 +export const acme_email = defineConfig<string>('acme_email', '')
247 +
248 +export async function makeCert(domain: string, email?: string) {
249 + if (!domain) return new ApiError(HTTP_BAD_REQUEST, 'bad params')
250 + const res = await generateSSLCert(domain, email)
251 + const CERT_FILE = 'acme.cert'
252 + const KEY_FILE = 'acme.key'
253 + await fs.writeFile(CERT_FILE, res.cert)
254 + await fs.writeFile(KEY_FILE, res.key)
255 + cert.set(CERT_FILE) // update config
256 + privateKey.set(KEY_FILE)
257 +}
258 +
259 +export const renewAcme = {
260 + timer: null,
261 + reset() {
262 + if (this.timer !== null)
263 + clearTimeout(this.timer)
264 + this.timer = null
265 + },
266 + set() {
267 + if (this.timer !== null) {
268 + setTimeout(this.set.bind(this), 5_000);
269 + } else {
270 + this.reset()
271 + setImmediate(() => repeat(DAY, renewCert)
272 + .then(v => ((this.timer as any) = v)))
273 + }
274 + }
275 +}
276 +
277 +defineConfig('acme_renew', false) // handle config changes
278 +events.once('https ready', () => renewAcme.set())
279 +/**
280 + * checks if the cert is near expiration date.
281 + * if so renews it
282 + */
283 +async function renewCert() {
284 + const acmeLog = (...args: any[]) => console.log('[acme-renew]:', ...args)
285 + const now = new Date()
286 + const cert = getCertObject()
287 + if (!cert) return
288 + const validTo = new Date(cert.validTo)
289 + const isValid = now > new Date(cert.validFrom) && now < validTo &&
290 + validTo.getTime() - now.getTime() >= 30 * DAY // it's not expiring in a month
291 + if (isValid) return acmeLog("cert is valid")
292 + await makeCert(acme_domain.get(), acme_email.get())
293 + .catch(e => {
294 + acmeLog("error renewing cert:", e.toString())
295 + })
296 +}
297 +
298 export default apis
\ No newline at end of file
src/cross.ts
+2 -2
@@ -229,8 +229,8 @@ export async function asyncGeneratorToArray<T>(generator: AsyncIterable<T>): Pro
229 return ret
230 }
231
232 -export function repeat(every: number, cb: () => unknown) {
233 - Promise.allSettled([cb()]).then(() =>
232 +export function repeat(every: number, cb: () => unknown): Promise<ReturnType<typeof setTimeout>>{
233 + return Promise.allSettled([cb()]).then(() =>
234 setTimeout(() => repeat(every, cb), every) )
235 }
236
src/listen.ts
+5 -1
@@ -15,6 +15,7 @@ import { anyAccountCanLoginAdmin } from './adminApis'
15 import _ from 'lodash'
16 import { X509Certificate } from 'crypto'
17 import { externalIp } from './api.net'
18 +import events from './events'
19
20 interface ServerExtra { name: string, error?: string, busy?: Promise<string> }
21 let httpSrv: undefined | http.Server & ServerExtra
@@ -33,7 +34,7 @@ portCfg.sub(async port => {
34 while (!app)
35 await wait(100)
36 stopServer(httpSrv).then()
36 - httpSrv = Object.assign(http.createServer(commonOptions, app.callback()), { name: 'http' })
37 + httpSrv = Object.assign(http.createServer(commonOptions as any, app.callback()), { name: 'http' })
38 port = await startServer(httpSrv, { port })
39 if (!port) return
40 httpSrv.on('connection', newConnection)
@@ -60,6 +61,7 @@ export function openAdmin() {
61 }
62
63 export function getCertObject() {
64 + if (!httpsOptions.cert) return
65 const o = new X509Certificate(httpsOptions.cert)
66 const some = _.pick(o, ['subject', 'issuer', 'validFrom', 'validTo'])
67 return objSameKeys(some, v => v?.includes('=') ? Object.fromEntries(v.split('\n').map(x => x.split('='))) : v)
@@ -77,6 +79,7 @@ const considerHttps = debounceAsync(async () => {
79 )
80 if (port >= 0) {
81 const cert = getCertObject()
82 + if (!cert) return
83 const cn = cert.subject?.CN
84 if (cn)
85 console.log("certificate loaded for", cn)
@@ -103,6 +106,7 @@ const considerHttps = debounceAsync(async () => {
106 if (!port) return
107 httpsSrv.on('connection', newConnection)
108 printUrls(httpsSrv.name)
109 + events.emit('https ready')
110 })
111
112