acme challenge for letsencrypt ssl cert (#345)

damienzonly committed Sep 16, 2023 at 15:17 UTC c078654074e7e92b57ca69e8546e85ee3a1b7da8
3 files changed +85 -11
package.json
+1
@@ -68,6 +68,7 @@
68 "dependencies": {
69 "@koa/router": "^10.1.1",
70 "@node-rs/crc32": "^1.6.0",
71 + "acme-client": "^5.0.0",
72 "basic-auth": "^2.0.1",
73 "buffer-crc32": "^0.2.13",
74 "fast-glob": "^3.2.7",
src/api.net.ts
+80 -7
@@ -2,15 +2,18 @@
2
3 import { ApiError, ApiHandlers } from './apiMiddleware'
4 import { Client } from 'nat-upnp'
5 -import { HTTP_FAILED_DEPENDENCY, HTTP_SERVER_ERROR, HTTP_SERVICE_UNAVAILABLE, IS_MAC, IS_WINDOWS } from './const'
5 +import { HTTP_BAD_REQUEST, HTTP_FAILED_DEPENDENCY, HTTP_SERVER_ERROR, HTTP_SERVICE_UNAVAILABLE, IS_MAC, IS_WINDOWS } from './const'
6 import axios from 'axios'
7 import {parse} from 'node-html-parser'
8 import _ from 'lodash'
9 -import { getIps, getServerStatus } from './listen'
9 +import { cert, getIps, getServerStatus, privateKey, startServer, stopServer } from './listen'
10 import { getProjectInfo } from './github'
11 import { httpString } from './util-http'
12 import { exec } from 'child_process'
13 -import { debounceAsync, HOUR, MINUTE, repeat } from './misc'
13 +import { debounceAsync, findDefined, HOUR, MINUTE, repeat, Dict } from './misc'
14 +import acme from 'acme-client'
15 +import fs from 'fs/promises'
16 +import { createServer } from 'http'
17
18 const client = new Client({ timeout: 4_000 })
19 const originalMethod = client.getGateway
@@ -33,7 +36,7 @@ const getNatInfo = debounceAsync(async () => {
36 const gatewayIp = res ? new URL(res.gateway.description).hostname : await findGateway().catch(() => null)
37 const localIp = res?.address || (await getIps())[0]
38 const internalPort = status?.https?.listening && status.https.port || status?.http?.listening && status.http.port
36 - const mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort || x.description === 'hfs')
39 + const mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort)
40 console.debug('responding')
41 return {
42 upnp: Boolean(res),
@@ -70,17 +73,57 @@ function findGateway(): Promise<string | undefined> {
73 }) )
74 }
75
76 +async function generateSSLCert(domain: string, email?: string) {
77 + const acmeTokens: Dict<string> = {}
78 + // create temporary server to answer the challenge
79 + const BASE = '/.well-known/acme-challenge/'
80 + const srv = createServer((req, res) => {
81 + const token = req.url?.startsWith(BASE) && req.url.slice(BASE.length) || ''
82 + console.debug("got http challenge", token || req.url)
83 + res.end(acmeTokens[token])
84 + })
85 + await new Promise<void>((resolve, reject) =>
86 + srv.listen(80, resolve).on('error', (e: any) => reject(e.code || e)) )
87 + console.debug('acme challenge server ready')
88 + try {
89 + const client = new acme.Client({
90 + accountKey: await acme.crypto.createPrivateKey(),
91 + directoryUrl: acme.directory.letsencrypt.production
92 + })
93 + const [key, csr] = await acme.crypto.createCsr({ commonName: domain })
94 + const cert = await client.auto({
95 + csr,
96 + email,
97 + challengePriority: ['http-01'],
98 + skipChallengeVerification: true, // on NAT, trying to connect to your external ip will likely get your modem instead of the challenge server
99 + termsOfServiceAgreed: true,
100 + async challengeCreateFn(_, c, ka) {
101 + console.debug("producing challenge")
102 + acmeTokens[c.token] = ka
103 + },
104 + async challengeRemoveFn(_, c) {
105 + delete acmeTokens[c.token]
106 + },
107 + })
108 + return { key, cert }
109 + }
110 + finally {
111 + await new Promise(res => srv.close(res))
112 + console.debug('acme terminated')
113 + }
114 +}
115 +
116 const apis: ApiHandlers = {
117 get_nat: getNatInfo,
118
119 async map_port({ external }) {
77 - const { gatewayIp, mapped, internalPort } = await getNatInfo()
120 + const { gatewayIp, externalPort, internalPort } = await getNatInfo()
121 if (!gatewayIp)
122 return new ApiError(HTTP_SERVICE_UNAVAILABLE, 'upnp failed')
123 if (!internalPort)
124 return new ApiError(HTTP_FAILED_DEPENDENCY, 'no internal port')
82 - if (mapped)
83 - try { await client.removeMapping({ public: { host: '', port: mapped.public.port } }) }
125 + if (externalPort)
126 + try { await client.removeMapping({ public: { host: '', port: externalPort } }) }
127 catch (e: any) { return new ApiError(HTTP_SERVER_ERROR, 'removeMapping failed: ' + String(e) ) }
128 if (external) // must use the object form of 'public' to workaround a bug of the library
129 await client.createMapping({ private: internalPort, public: { host: '', port: external }, description: 'hfs', ttl: 0 })
@@ -127,6 +170,36 @@ const apis: ApiHandlers = {
170 return new ApiError(HTTP_SERVICE_UNAVAILABLE, 'no service available to detect upnp mapping')
171 },
172
173 + async make_cert({email, domain}) {
174 + if (!domain) return new ApiError(HTTP_BAD_REQUEST, 'bad params')
175 + const { externalPort } = await getNatInfo() // do this before stopping the server
176 + if (externalPort !== 80)
177 + await client.createMapping({ private: 80, public: { host: '', port: 80 }, description: 'hfs challenge', ttl: 0 }).catch(() => {})
178 + // we could have a server on port 80 already. With upnp it should be easy to forward to a different internal port and workaround the conflict, but we could as well be on a VPS with public ip and no forwarding at all
179 + // therefore the catch-all solution is to temporarily disable the server on 80, without changing configuration, to avoid persisting if we crash in the middle
180 + const restore = findDefined(await getServerStatus(), x => {
181 + if (!x.listening || x.port !== 80) return
182 + stopServer(x.srv)
183 + return () => startServer(x.srv, { port: x.configuredPort }) // return a callback to restore the server
184 + })
185 + try {
186 + // if possible, create a short-lived mapping
187 + const res = await generateSSLCert(domain, email)
188 + const SUFFIX = '-acme.pem'
189 + const CERT_FILE = 'cert' + SUFFIX
190 + const KEY_FILE = 'key' + SUFFIX
191 + await fs.writeFile(CERT_FILE, res.cert)
192 + await fs.writeFile(KEY_FILE, res.key)
193 + cert.set(CERT_FILE) // update config
194 + privateKey.set(KEY_FILE)
195 + return {}
196 + }
197 + catch (e:any) { //TODO if this request was made on port 80, this reply will never be received because the server was shut down. Possible solution: GUI could ask for outcome on the temporary server
198 + console.log(e?.message || String(e))
199 + return new ApiError(HTTP_FAILED_DEPENDENCY, String(e))
200 + }
201 + finally { await restore?.() }
202 + },
203 }
204
205 export default apis
\ No newline at end of file
src/listen.ts
+4 -4
@@ -98,8 +98,8 @@ const considerHttps = debounceAsync(async () => {
98 })
99
100
101 -const cert = defineConfig('cert', '')
102 -const privateKey = defineConfig('private_key', '')
101 +export const cert = defineConfig('cert', '')
102 +export const privateKey = defineConfig('private_key', '')
103 const httpsNeeds = [cert, privateKey]
104 const httpsOptions = { cert: '', private_key: '' }
105 type HttpsKeys = keyof typeof httpsOptions
@@ -126,7 +126,7 @@ export const httpsPortCfg = defineConfig('https_port', PORT_DISABLED)
126 httpsPortCfg.sub(considerHttps)
127
128 interface StartServer { port: number, host?:string }
129 -function startServer(srv: typeof httpSrv, { port, host }: StartServer) {
129 +export function startServer(srv: typeof httpSrv, { port, host }: StartServer) {
130 return new Promise<number>(async resolve => {
131 if (!srv) return 0
132 try {
@@ -180,7 +180,7 @@ function startServer(srv: typeof httpSrv, { port, host }: StartServer) {
180 }
181 }
182
183 -function stopServer(srv?: http.Server) {
183 +export function stopServer(srv?: http.Server) {
184 return new Promise(resolve => {
185 if (!srv?.listening)
186 return resolve(null)