admin/config: better errors for https

Massimo Melina committed Jun 2, 2022 at 23:59 UTC 080f1f2b979888290e9e3667fb0e8484fd928b9b
4 files changed +69 -46
admin/src/ConfigPage.ts
+29 -10
@@ -81,8 +81,15 @@ export default function ConfigPage() {
81 return v
82 }
83 },
84 - values.https_port >= 0 && { k: 'cert', comp: FileField, label: "HTTPS certificate file" },
85 - values.https_port >= 0 && { k: 'private_key', comp: FileField, label: "HTTPS private key file" },
84 + values.https_port >= 0 && { k: 'cert', comp: FileField, label: "HTTPS certificate file",
85 + ...with_(status?.https.error, e => isCertError(e) ? {
86 + error: true,
87 + helperText: [e, ' - ', h(Link, { key: 'fix', sx: { cursor: 'pointer' }, onClick: makeCertAndSave }, "make one")]
88 + } : null)
89 + },
90 + values.https_port >= 0 && { k: 'private_key', comp: FileField, label: "HTTPS private key file",
91 + ...with_(status?.https.error, e => isKeyError(e) ? { error: true, helperText: e } : null)
92 + },
93 { k: 'open_browser_at_start', comp: BoolField },
94 { k: 'localhost_admin', comp: BoolField, label: "Admin access for localhost connections",
95 getError: x => !x && admins?.length===0 && "First create at least one admin account",
@@ -149,17 +156,24 @@ function recalculateChanges() {
156 }
157
158 export function isCertError(error: any) {
152 - return typeof error === 'string' && /certificate|key/.test(error)
159 + return /certificate/.test(error)
160 +}
161 +
162 +export function isKeyError(error: any) {
163 + return /private key/.test(error)
164 }
165
155 -function ServerPort({ label, value, onChange, status, suggestedPort=1 }: FieldProps<number | null>) {
166 +function ServerPort({ label, value, onChange, getApi, status, suggestedPort=1, error }: FieldProps<number | null>) {
167 const lastCustom = useRef(suggestedPort)
168 if (value! > 0)
169 lastCustom.current = value!
170 const selectValue = Number(value! > 0 ? lastCustom.current : value) || 0
160 - let error = status?.error
161 - if (isCertError(error))
162 - error = [error, ' - ', h(Link, { key: 'fix', sx: { cursor: 'pointer' }, onClick: makeCertAndSave }, "make one")]
171 + let errMsg = status?.error
172 + if (errMsg)
173 + if (isCertError(errMsg) || isKeyError(errMsg))
174 + errMsg = undefined // never mind, we'll show this error elsewhere
175 + else
176 + error = true
177 return h(Box, {},
178 h(Box, { display: 'flex' },
179 h(SelectField as Field<number>, {
@@ -173,11 +187,11 @@ function ServerPort({ label, value, onChange, status, suggestedPort=1 }: FieldPr
187 ],
188 onChange,
189 }),
176 - value! > 0 && h(NumberField, { label: 'Number', fullWidth: false, value, onChange, min: 1, max: 65535, sx: { minWidth:'5.5em' } }),
190 + value! > 0 && h(NumberField, { label: 'Number', fullWidth: false, value, onChange, getApi, error, min: 1, max: 65535, sx: { minWidth:'5.5em' } }),
191 ),
178 - status && h(FormHelperText, { error: Boolean(error) },
192 + status && h(FormHelperText, { error },
193 status === true ? '...'
180 - : error ?? (status?.listening && "Correctly working on port "+ status.port) )
194 + : errMsg ?? (status?.listening && "Correctly working on port " + status.port) )
195 )
196 }
197
@@ -243,3 +257,8 @@ async function makeCert(attributes: Record<string, string>) {
257 private_key: pki.privateKeyToPem(keys.privateKey),
258 }
259 }
260 +
261 +export function with_<T,RT>(par:T, cb: (par:T) => RT) {
262 + return cb(par)
263 +}
264 +
admin/src/HomePage.ts
+3 -4
@@ -8,7 +8,7 @@ import { CheckCircle, Error, Info, Launch, Warning } from '@mui/icons-material'
8 import md from './md'
9 import { useSnapState } from './state'
10 import { confirmDialog } from './dialog'
11 -import { isCertError, makeCertAndSave } from './ConfigPage'
11 +import { isCertError, isKeyError, makeCertAndSave } from './ConfigPage'
12 import { VfsNode } from './VfsPage'
13 import { Account } from './AccountsPage'
14
@@ -33,10 +33,9 @@ export default function HomePage() {
33 : v.error )
34 const errors = errorMap && onlyTruthy(Object.entries(errorMap).map(([k,v]) =>
35 v && [md(`Protocol _${k}_ cannot work: `), v,
36 - isCertError(v) && [
36 + (isCertError(v) || isKeyError(v)) && [
37 SOLUTION_SEP, h(Link, { sx: { cursor: 'pointer' }, onClick() { makeCertAndSave().then(reloadCfg).then(reloadStatus) } }, "make one"),
38 - " or ",
39 - SOLUTION_SEP, cfgLink("provide adequate files")
38 + " or ", SOLUTION_SEP, cfgLink("provide adequate files")
39 ]]))
40 return h(Box, { display:'flex', gap: 2, flexDirection:'column' },
41 username && entry('', "Welcome "+username),
mui-grid-form/src/Form.ts
+21 -20
@@ -36,6 +36,18 @@ export interface FieldDescriptor<T=any> {
36 // it seems necessary to cast (Multi)SelectField sometimes
37 export type Field<T> = FC<FieldProps<T>>
38
39 +type Promisable<T> = T | Promise<T>
40 +interface FieldApi { getError: () => Promisable<ValidationError>, [rest: string]: any }
41 +export interface FieldProps<T> {
42 + label?: string | ReactElement
43 + value?: T
44 + onChange: (v: T, more: { was?: T, event: any, [rest: string]: any }) => void
45 + getApi?: (api: FieldApi) => void
46 + error?: true
47 + helperText?: ReactNode
48 + [rest: string]: any
49 +}
50 +
51 type Dict<T=any> = Record<string,T>
52
53 export interface FormProps<Values> extends Partial<BoxProps> {
@@ -91,21 +103,21 @@ export function Form<Values extends Dict>({ fields, values, set, defaults, save,
103 return null
104 if (isValidElement(row))
105 return h(Grid, { key: idx, item: true, xs: 12 }, row)
94 - const { k, fromField=_.identity, toField=_.identity, getError, ...field } = row
95 - let error = errors[k]
96 - if (error === true)
97 - error = "Not valid"
106 + const { k, fromField=_.identity, toField=_.identity, getError, error, ...field } = row
107 + let errMsg = errors[k]
108 + if (errMsg === true)
109 + errMsg = "Not valid"
110 if (k) {
111 const originalValue = values?.[k]
112 const whole = { ...row, ...field }
113 Object.assign(field, {
114 value: toField(originalValue),
103 - error: Boolean(error) || undefined,
115 + error: Boolean(errMsg || error) || undefined,
116 getApi(api) { apis[k] = api },
117 onBlur() {
118 pleaseValidate(k)
119 },
108 - async onChange(v, { event }) {
120 + onChange(v, { event }) {
121 try {
122 v = fromField(v)
123 if (_.isEqual(v, originalValue)) return
@@ -120,9 +132,9 @@ export function Form<Values extends Dict>({ fields, values, set, defaults, save,
132 }
133 },
134 } as Partial<FieldProps<any>>)
123 - if (error) // special rendering when we have both error and helperText. "hr" would be nice but issues a warning because contained in a <p>
124 - field.helperText = field.helperText ? h(Fragment, {}, h('span', { style: { borderBottom: '1px solid' } }, error), h('br'), field.helperText)
125 - : error
135 + if (errMsg) // special rendering when we have both error and helperText. "hr" would be nice but issues a warning because contained in a <p>
136 + field.helperText = field.helperText ? h(Fragment, {}, h('span', { style: { borderBottom: '1px solid' } }, errMsg), h('br'), field.helperText)
137 + : errMsg
138 if (field.label === undefined)
139 field.label = labelFromKey(k)
140 _.defaults(field, defaults?.(whole))
@@ -205,14 +217,3 @@ export function Form<Values extends Dict>({ fields, values, set, defaults, save,
217 export function labelFromKey(k: string) {
218 return _.capitalize(k.replace(/_/g, ' '))
219 }
208 -
209 -type Promisable<T> = T | Promise<T>
210 -interface FieldApi { getError: () => Promisable<ValidationError>, [rest: string]: any }
211 -export interface FieldProps<T> {
212 - label?: string | ReactElement
213 - value?: T
214 - onChange: (v: T, more: { was?: T, event: any, [rest: string]: any }) => void
215 - getApi?: (api: FieldApi) => void
216 - error?: true
217 - [rest: string]: any
218 -}
server/src/listen.ts
+16 -12
@@ -8,7 +8,7 @@ import { watchLoad } from './watchLoad'
8 import { networkInterfaces } from 'os';
9 import { newConnection } from './connections'
10 import open from 'open'
11 -import { debounceAsync, onlyTruthy, prefix, wait } from './misc'
11 +import { debounceAsync, onlyTruthy, wait } from './misc'
12 import { ADMIN_URI, DEV } from './const'
13 import findProcess from 'find-process'
14
@@ -39,21 +39,25 @@ const considerHttps = debounceAsync(async () => {
39 while (!app)
40 await wait(100)
41 httpsSrv = Object.assign(
42 - https.createServer(port < 0 ? {} : httpsOptions, app.callback()),
43 - { name: 'https' }
42 + https.createServer(port < 0 ? {} : { key: httpsOptions.private_key, cert: httpsOptions.cert }, app.callback()),
43 + { name: 'https', error: undefined }
44 )
45 - const missingCfg = httpsNeeds.find(x => !x.get())
46 - httpsSrv.error = port < 0 ? undefined
47 - : missingCfg && prefix(missingCfg.get() ? "cannot read file for " : "missing ", (httpsNeedsNames as any)[missingCfg.key()])
48 - if (httpsSrv.error)
49 - return
45 + if (port >= 0) {
46 + const namesForOutput: any = { cert: 'certificate', private_key: 'private key' }
47 + const missing = httpsNeeds.find(x => !x.get())?.key()
48 + if (missing)
49 + return httpsSrv.error = "missing " + namesForOutput[missing]
50 + const cantRead = httpsNeeds.find(x => !httpsOptions[x.key() as HttpsKeys])?.key()
51 + if (cantRead)
52 + return httpsSrv.error = "cannot read " + namesForOutput[cantRead]
53 + }
54 }
55 catch(e) {
56 httpsSrv.error = "bad private key or certificate"
57 console.log("failed to create https server: check your private key and certificate", String(e))
58 return
59 }
56 - port = await startServer(httpsSrv, { port: httpsPortCfg.get() })
60 + port = await startServer(httpsSrv, { port })
61 if (!port) return
62 httpsSrv.on('connection', socket =>
63 newConnection(socket, true))
@@ -64,13 +68,13 @@ const considerHttps = debounceAsync(async () => {
68 const cert = defineConfig<string>('cert')
69 const privateKey = defineConfig<string>('private_key')
70 const httpsNeeds = [cert, privateKey]
67 -const httpsNeedsNames = { cert: 'certificate', private_key: 'private key' }
68 -const httpsOptions = { key: '', cert: '' }
71 +const httpsOptions = { cert: '', private_key: '' }
72 +type HttpsKeys = keyof typeof httpsOptions
73 for (const cfg of httpsNeeds) {
74 let unwatch: ReturnType<typeof watchLoad>['unwatch']
75 cfg.sub(async v => {
76 unwatch?.()
73 - const k = cfg.key() === 'private_key' ? 'key' : 'cert'
77 + const k = cfg.key() as HttpsKeys
78 httpsOptions[k] = v
79 if (!v || v.includes('\n'))
80 return considerHttps()