| 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 }) |