main
ts 137 lines 6.41 KB
Raw
1 import {
2 DAY, Dict, haveTimeout, HOUR, HTTP_BAD_REQUEST, HTTP_FAILED_DEPENDENCY, HTTP_OK, MINUTE, repeat, formatDate
3 } from './misc'
4 import { createServer, IncomingMessage, ServerResponse } from 'http'
5 import { Middleware } from 'koa'
6 import { getNatInfo, getUpnpClient, upnpEnabled, upnpMappingParam } from './nat'
7 import { cert, getCertObject, getServerStatus, privateKey } from './listen'
8 import { ApiError } from './apiMiddleware'
9 import acme from 'acme-client'
10 import { debounceAsync } from './debounceAsync'
11 import fs from 'fs/promises'
12 import { defineConfig } from './config'
13 import events from './events'
14 import { selfCheck } from './selfCheck'
15
16 let acmeOngoing = false
17 const acmeTokens: Dict<string> = {}
18 const acmeListener = (req: IncomingMessage, res: ServerResponse) => { // node listener
19 const BASE = '/.well-known/acme-challenge/'
20 if (!req.url?.startsWith(BASE)) return
21 const token = req.url.slice(BASE.length)
22 console.debug("Got http challenge", token)
23 res.statusCode = HTTP_OK
24 res.end(acmeTokens[token])
25 return true // true = responded
26 }
27 export const acmeMiddleware: Middleware = (ctx, next) => { // koa format
28 if (!acmeOngoing || !acmeListener(ctx.req, ctx.res))
29 return next()
30 }
31
32 const TEMP_MAP = upnpMappingParam(80, 80, 'hfs temporary', 5000) // from my tests (zyxel VMG8825), lower values won't make a working mapping
33
34 // remove temporary port mapping, if any is left from previous execution
35 repeat(MINUTE, async stop => {
36 if (!await upnpEnabled.getWhenReady())
37 return stop()
38 const client = getUpnpClient()
39 await client.getGateway() // without this, the next call will break upnp support
40 const res = await client.getMappings()
41 const leftover = res.find(x => x.description === TEMP_MAP.description) // in case the process is interrupted
42 if (!leftover) return void stop() // we are good
43 if (acmeOngoing) return // it doesn't count, as we are in the middle of something. Retry later
44 stop()
45 return client.removeMapping(TEMP_MAP)
46 })
47
48 async function generateSSLCert(domain: string, email?: string, altNames?: string[]) {
49 // will answer the challenge through our koa app (if on port 80) or must we spawn a dedicated server?
50 const nat = await getNatInfo()
51 const { http } = await getServerStatus()
52 const tempSrv = nat.externalPort === 80 || http.listening && http.port === 80 ? undefined
53 : createServer((req, res) => acmeListener(req, res) || res.end('HFS')) // also satisfy self-check
54 if (tempSrv)
55 await new Promise<void>(resolve =>
56 tempSrv.listen(80, resolve).on('error', (e: any) => {
57 console.debug("Cannot listen on 80", e.code || e)
58 resolve() // go on anyway
59 }) )
60 acmeOngoing = true
61 console.debug("ACME challenge server ready")
62 let tempMap: any
63 try {
64 const checkUrl = `http://${domain.split(',')[0]}`
65 let check = await selfCheck(checkUrl) // some check services may not consider the domain, but we already verified that
66 if (check?.success === false && nat.upnp && !nat.mapped80) {
67 console.debug("Setting temporary port forward")
68 tempMap = await haveTimeout(10_000, getUpnpClient().createMapping(TEMP_MAP)).catch(() => {})
69 check = await selfCheck(checkUrl) // repeat test
70 }
71 //if (!check) throw new ApiError(HTTP_FAILED_DEPENDENCY, "couldn't test port 80")
72 if (check?.success === false)
73 throw new ApiError(HTTP_FAILED_DEPENDENCY, "port 80 is not working on the specified domain")
74 const acmeClient = new acme.Client({
75 accountKey: await acme.crypto.createPrivateKey(),
76 directoryUrl: acme.directory.letsencrypt.production
77 })
78 acme.setLogger(console.debug)
79 const [key, csr] = await acme.crypto.createCsr({ commonName: domain, altNames })
80 const cert = await acmeClient.auto({
81 csr,
82 email,
83 challengePriority: ['http-01'],
84 skipChallengeVerification: true, // on NAT, trying to connect to your external ip will likely get your modem instead of the challenge server
85 termsOfServiceAgreed: true,
86 async challengeCreateFn(_, c, ka) { acmeTokens[c.token] = ka },
87 async challengeRemoveFn(_, c) { delete acmeTokens[c.token] },
88 })
89 console.log("ACME certificate generated")
90 return { key, cert }
91 }
92 finally {
93 if (tempMap && upnpEnabled.get()) {
94 console.debug("Removing temporary port forward")
95 getUpnpClient().removeMapping(TEMP_MAP).catch(() => {}) // clean after ourselves
96 }
97 acmeOngoing = false
98 if (tempSrv) await new Promise(res => tempSrv.close(res))
99 console.debug('ACME terminated')
100 }
101 }
102
103 export const makeCert = debounceAsync(async (domain: string, email?: string, altNames?: string[]) => {
104 if (!domain) return new ApiError(HTTP_BAD_REQUEST, 'bad params')
105 const res = await generateSSLCert(domain, email, altNames).catch(e => {
106 throw e.message?.includes('Timeout') ? Error("ensure your router is forwarding port 80 correctly")
107 : e.message?.includes('not match this challenge') ? Error("a different server is responding on port 80 of your domain(s)")
108 : e
109 })
110 const CERT_FILE = 'acme.cer'
111 const KEY_FILE = 'acme.key'
112 await fs.writeFile(CERT_FILE, res.cert)
113 await fs.writeFile(KEY_FILE, res.key)
114 cert.set(CERT_FILE) // update config
115 privateKey.set(KEY_FILE)
116 acmeRenewError = ''
117 })
118
119 export let acmeRenewError = ''
120 const acmeDomain = defineConfig('acme_domain', '')
121 const acmeRenew = defineConfig('acme_renew', false) // handle config changes
122 events.once('httpsReady', () => repeat(HOUR, renewCert))
123
124 // checks if the cert is near expiration date, and if so renews it
125 const renewCert = debounceAsync(async () => {
126 const [domain, ...altNames] = acmeDomain.get().split(',')
127 if (!acmeRenew.get() || !domain) return
128 const cert = getCertObject()
129 if (!cert) return
130 const now = new Date()
131 const validTo = new Date(cert.validTo)
132 // not expiring in a month
133 if (now > new Date(cert.validFrom) && now < validTo && validTo.getTime() - now.getTime() >= 30 * DAY)
134 return console.log("Certificate still good")
135 await makeCert(domain, undefined, altNames)
136 .catch(e => console.log(acmeRenewError = `Error renewing certificate, expiring ${formatDate(validTo)}: ${String(e.message || e)}`))
137 }, { retain: DAY, retainFailure: HOUR })