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)