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