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 () => {