admin/internet: generate certificate with multiple domains
Massimo Melina committed
Nov 30, 2023 at 21:56 UTC
dcdecf59983d7f1b744c1b5f2430be8566aa03c4
4 files changed
+18
-16
admin/src/InternetPage.ts
+4
-4
@@ -103,7 +103,7 @@ export default function InternetPage() {
103
cert.element || with_(cert.data, c => h(Box, {},
104
h(CardMembership, { fontSize: 'small', sx: { mr: 1, verticalAlign: 'middle' } }), "Current certificate",
105
h('ul', {},
106
- h('li', {}, "Domain: ", c.subject?.CN || '-'),
106
+ h('li', {}, "Domain: ", c.altNames?.join(' + ') ||'-'),
107
h('li', {}, "Issuer: ", c.issuer?.O || h('i', {}, 'self-signed')),
108
h('li', {}, "Validity: ", ['validFrom', 'validTo'].map(k => formatTimestamp(c[k])).join(' – ')),
109
)
@@ -122,7 +122,7 @@ export default function InternetPage() {
122
},
123
fields: [
124
md("Generate certificate using [Let's Encrypt](https://letsencrypt.org)"),
125
- { k: 'acme_domain', label: "Domain for certificate", sm: 6, required: true, helperText: "example: your.domain.com" },
125
+ { k: 'acme_domain', label: "Domain for certificate", sm: 6, required: true, helperText: md("Example: your.domain.com\nMultiple domains separated by commas") },
126
{ k: 'acme_email', label: "E-mail for certificate", sm: 6 },
127
{ k: 'acme_renew', label: "Automatic renew one month before expiration", comp: BoolField, disabled: !values.acme_domain },
128
],
@@ -130,7 +130,7 @@ export default function InternetPage() {
130
children: "Request",
131
startIcon: h(Send),
132
async onClick() {
133
- const domain = values.acme_domain
133
+ const [domain, ...altNames] = values.acme_domain.split(',')
134
const fresh = domain === cert.data.subject?.CN && Number(new Date(cert.data.validTo)) - Date.now() >= 30 * DAY
135
if (fresh && !await confirmDialog("Your certificate is still good", { confirmText: "Make a new one anyway" }))
136
return
@@ -138,7 +138,7 @@ export default function InternetPage() {
138
const res = await apiCall('check_domain', { domain }).catch(e =>
139
confirmDialog(String(e), { confirmText: "Continue anyway" }) )
140
if (res === false) return
141
- await apiCall('make_cert', { domain, email: values.acme_email }, { timeout: 20_000 })
141
+ await apiCall('make_cert', { domain, altNames, email: values.acme_email }, { timeout: 20_000 })
142
.then(async () => {
143
await alertDialog("Certificate created", 'success')
144
if (!listening)
src/acme.ts
+4
-4
@@ -28,7 +28,7 @@ export const acmeMiddleware: Middleware = (ctx, next) => { // koa format
28
return next()
29
}
30
31
-async function generateSSLCert(domain: string, email?: string) {
31
+async function generateSSLCert(domain: string, email?: string, altNames?: string[]) {
32
// will answer challenge through our koa app (if on port 80) or must we spawn a dedicated server?
33
const nat = await getNatInfo()
34
const { http } = await getServerStatus()
@@ -58,7 +58,7 @@ async function generateSSLCert(domain: string, email?: string) {
58
directoryUrl: acme.directory.letsencrypt.production
59
})
60
acme.setLogger(console.debug)
61
- const [key, csr] = await acme.crypto.createCsr({ commonName: domain })
61
+ const [key, csr] = await acme.crypto.createCsr({ commonName: domain, altNames })
62
const cert = await acmeClient.auto({
63
csr,
64
email,
@@ -81,9 +81,9 @@ async function generateSSLCert(domain: string, email?: string) {
81
}
82
}
83
84
-export const makeCert = debounceAsync(async (domain: string, email?: string) => {
84
+export const makeCert = debounceAsync(async (domain: string, email?: string, altNames?: string[]) => {
85
if (!domain) return new ApiError(HTTP_BAD_REQUEST, 'bad params')
86
- const res = await generateSSLCert(domain, email)
86
+ const res = await generateSSLCert(domain, email, altNames)
87
const CERT_FILE = 'acme.cert'
88
const KEY_FILE = 'acme.key'
89
await fs.writeFile(CERT_FILE, res.cert)
src/api.net.ts
+6
-5
@@ -1,11 +1,12 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import { ApiError, ApiHandlers } from './apiMiddleware'
4
-import { HTTP_FAILED_DEPENDENCY, HTTP_SERVER_ERROR, HTTP_SERVICE_UNAVAILABLE, HTTP_PRECONDITION_FAILED } from './const'
4
+import { HTTP_FAILED_DEPENDENCY, HTTP_SERVER_ERROR, HTTP_SERVICE_UNAVAILABLE, HTTP_PRECONDITION_FAILED, HTTP_NOT_FOUND
5
+} from './const'
6
import _ from 'lodash'
7
import { getCertObject } from './listen'
8
import { getProjectInfo } from './github'
8
-import { apiAssertTypes, objSameKeys, onlyTruthy, promiseBestEffort } from './misc'
9
+import { apiAssertTypes, onlyTruthy, promiseBestEffort } from './misc'
10
import { lookup, Resolver } from 'dns/promises'
11
import { isIPv6 } from 'net'
12
import { getNatInfo, upnpClient } from './nat'
@@ -70,13 +71,13 @@ const apis: ApiHandlers = {
71
return results.length ? results : new ApiError(HTTP_SERVICE_UNAVAILABLE)
72
},
73
73
- async make_cert({domain, email}) {
74
- await makeCert(domain, email)
74
+ async make_cert({domain, email, altNames}) {
75
+ await makeCert(domain, email, altNames)
76
return {}
77
},
78
79
get_cert() {
79
- return objSameKeys(_.pick(getCertObject(), ['subject', 'issuer', 'validFrom', 'validTo']), v => v)
80
+ return getCertObject() || new ApiError(HTTP_NOT_FOUND)
81
}
82
}
83
src/listen.ts
+4
-3
@@ -73,9 +73,10 @@ export function openAdmin() {
73
74
export function getCertObject() {
75
if (!httpsOptions.cert) return
76
- const o = new X509Certificate(httpsOptions.cert)
77
- const some = _.pick(o, ['subject', 'issuer', 'validFrom', 'validTo'])
78
- return objSameKeys(some, v => v?.includes('=') ? Object.fromEntries(v.split('\n').map(x => x.split('='))) : v)
76
+ const all = new X509Certificate(httpsOptions.cert)
77
+ const some = _.pick(all, ['subject', 'issuer', 'validFrom', 'validTo'])
78
+ const ret = objSameKeys(some, v => v?.includes('=') ? Object.fromEntries(v.split('\n').map(x => x.split('='))) : v)
79
+ return Object.assign(ret, { altNames: all.subjectAltName?.replace(/DNS:/g, '').split(/, */) })
80
}
81
82
const considerHttps = debounceAsync(async () => {