admin/internet: HTTPS box
Massimo Melina committed
Sep 16, 2023 at 16:02 UTC
7c37427e21a919d0a2504014198d35625241142c
8 files changed
+253
-171
admin/src/HomePage.ts
+6
-6
@@ -21,7 +21,7 @@ import { BrowserUpdated as UpdateIcon, CheckCircle, Error, Info, Launch, OpenInN
21
import md, { replaceStringToReact } from './md'
22
import { state, useSnapState } from './state'
23
import { alertDialog, confirmDialog, toast } from './dialog'
24
-import { isCertError, isKeyError, makeCertAndSave } from './OptionsPage'
24
+import { isCertError, isKeyError, suggestMakingCert } from './OptionsPage'
25
import { VfsNode } from './VfsPage'
26
import { Account } from './AccountsPage'
27
import _ from 'lodash'
@@ -44,7 +44,7 @@ export default function HomePage() {
44
const { data: status, reload: reloadStatus, element: statusEl } = useApiEx<Status>('get_status')
45
const { data: vfs } = useApiEx<{ root?: VfsNode }>('get_vfs')
46
const { data: account } = useApiEx<Account>(username && 'get_account')
47
- const { data: cfg, reload: reloadCfg } = useApiEx('get_config', { only: ['https_port', 'cert', 'private_key', 'proxies', 'update_to_beta'] })
47
+ const cfg = useApiEx('get_config', { only: ['https_port', 'cert', 'private_key', 'proxies', 'update_to_beta'] })
48
const { list: plugins } = useApiList('get_plugins')
49
const [updates, setUpdates] = useState<undefined | any[]>()
50
if (statusEl || !status)
@@ -61,7 +61,7 @@ export default function HomePage() {
61
v && [md(`Protocol <u>${k}</u> cannot work: `), v,
62
(isCertError(v) || isKeyError(v)) && [
63
SOLUTION_SEP, h(LinkBtn, {
64
- onClick() { makeCertAndSave().then(reloadCfg).then(reloadStatus) } },
64
+ onClick() { suggestMakingCert().then(() => wait(999)).then(cfg.reload).then(reloadStatus) } },
65
"make one"
66
), " or ", SOLUTION_SEP, cfgLink("provide adequate files")
67
]]))
@@ -87,12 +87,12 @@ export default function HomePage() {
87
async onClick() {
88
if (await confirmDialog("Go on only if you know what you are doing")
89
&& await apiCall('set_config', { values: { ignore_proxies: true } }))
90
- reloadCfg()
90
+ cfg.reload()
91
}
92
}, "ignore this warning"),
93
SOLUTION_SEP, wikiLink('Proxy-warning', "Explanation")
94
),
95
- (cfg?.proxies > 0 || status?.proxyDetected) && entry('', wikiLink('Reverse-proxy', "Read our guide on proxies")),
95
+ (cfg.data?.proxies > 0 || status?.proxyDetected) && entry('', wikiLink('Reverse-proxy', "Read our guide on proxies")),
96
status.frpDetected && entry('warning', `FRP is detected. It should not be used with "type = tcp" with HFS. Possible solutions are`,
97
h('ol',{},
98
h('li',{}, `configure FRP with type=http (best solution)`),
@@ -179,6 +179,6 @@ function cfgLink(text=`Options page`) {
179
}
180
181
export function proxyWarning(cfg: any, status: any) {
182
- return cfg && !cfg.proxies && status?.proxyDetected
182
+ return cfg.data && !cfg.data.proxies && status?.proxyDetected
183
&& "A proxy was detected but none is configured"
184
}
admin/src/InternetPage.ts
+82
-22
@@ -1,12 +1,13 @@
1
import { createElement as h, useEffect, useState } from 'react'
2
-import { Alert, Box, Button, Card, CardContent, CircularProgress, LinearProgress, Link } from '@mui/material'
3
-import { HomeWorkTwoTone, PublicTwoTone, RouterTwoTone } from '@mui/icons-material'
2
+import { Alert, Box, Button, Card, CardContent, CircularProgress, Divider, LinearProgress, Link } from '@mui/material'
3
+import { CardMembership, HomeWorkTwoTone, Lock, PublicTwoTone, RouterTwoTone, Send } from '@mui/icons-material'
4
import { apiCall, useApiEx } from './api'
5
-import { closeDialog, with_ } from '@hfs/shared'
6
-import { Flex, LinkBtn } from './misc'
5
+import { closeDialog, DAY, formatTimestamp, with_ } from '@hfs/shared'
6
+import { Flex, LinkBtn, manipulateConfig } from './misc'
7
import { alertDialog, confirmDialog, promptDialog, toast } from './dialog'
8
-import { NumberField } from '@hfs/mui-grid-form'
8
+import { Form, NumberField } from '@hfs/mui-grid-form'
9
import md from './md'
10
+import { isCertError } from './OptionsPage'
11
import { changeBaseUrl } from './FileForm'
12
13
const PORT_FORWARD_URL = 'https://portforward.com/'
@@ -18,10 +19,10 @@ export default function InternetPage() {
19
const [checking, setChecking] = useState(false)
20
const [mapping, setMapping] = useState(false)
21
const [verifyAgain, setVerifyAgain] = useState(false)
21
- const { data: status, reload: reloadStatus } = useApiEx('get_status')
22
- const localColor = with_([status?.http?.error, status?.https?.error], ([h, s]) =>
22
+ const status = useApiEx('get_status')
23
+ const localColor = with_([status.data?.http?.error, status.data?.https?.error], ([h, s]) =>
24
h && s ? 'error' : h || s ? 'warning' : 'success')
24
- const { data: nat, reload, error, loading, element } = useApiEx('get_nat')
25
+ const { data: nat, reload: reloadNat, error, loading, element } = useApiEx('get_nat')
26
const port = nat?.internalPort
27
const wrongMap = nat?.mapped && nat.mapped.private.port !== port
28
const doubleNat = nat?.externalIp && nat.externalIp !== nat.publicIp
@@ -30,21 +31,77 @@ export default function InternetPage() {
31
setVerifyAgain(false)
32
verify().then()
33
}, [verifyAgain, nat, loading])
33
- return h(Flex, { vert: true },
34
+ return h(Flex, { vert: true, gap: '2em', maxWidth: '40em' },
35
h(Alert, { severity: 'info' }, "This page helps you making your server work on the Internet"),
36
baseUrlBox(),
37
networkBox(),
38
+ httpsBox(),
39
)
40
41
+ function httpsBox() {
42
+ const { error, listening } = status.data?.https ||{}
43
+ const [acme, setAcme] = useState<any>()
44
+ const cert = useApiEx('get_cert')
45
+ useEffect(() => { apiCall('get_config', { only: ['acme_domain', 'acme_email'] }).then(setAcme) } , [])
46
+ if (!status || !acme) return h(CircularProgress)
47
+ return element || status.element || h(Card, {}, h(CardContent, {},
48
+ h(Flex, { gap: '.5em', fontSize: 'x-large', mb: 1, alignItems: 'center' }, "HTTPS",
49
+ h(Lock, { color: listening && !error ? 'success' : 'warning' }) ),
50
+ h(Box, { mt: 1 },
51
+ isCertError(error) && h(Alert, { severity: 'warning', sx: { mb: 1 } }, error),
52
+ cert.element || with_(cert.data, c => h(Box, {}, h(CardMembership, { fontSize: 'small', sx: { mr: 1, verticalAlign: 'middle' } }), "Current certificate", h('ul', {},
53
+ h('li', {}, "Domain: ", c.subject?.CN || '-'),
54
+ h('li', {}, "Issuer: ", c.issuer?.O || h('i', {}, 'self-signed')),
55
+ h('li', {}, "Validity: ", ['validFrom', 'validTo'].map(k => formatTimestamp(c[k])).join(' – ')),
56
+ ))),
57
+ !listening && "Not enabled. " || error && "For HTTPS to work, you need a valid certificate.",
58
+ h(Divider, { sx: { my: 2 } }),
59
+ h(Form, {
60
+ gap: 1,
61
+ gridProps: {rowSpacing:1},
62
+ values: acme,
63
+ set(v, k) {
64
+ setAcme((was: any) => ({ ...was, [k]: v }))
65
+ },
66
+ fields: [
67
+ md("Generate certificate using [Let's Encrypt](https://letsencrypt.org)"),
68
+ { k: 'acme_domain', label: "Certificate domain", sm: 6, required: true },
69
+ { k: 'acme_email', label: "Certificate e-mail", sm: 6 },
70
+ ],
71
+ save: {
72
+ children: "Request",
73
+ startIcon: h(Send),
74
+ async onClick() {
75
+ const domain = acme.acme_domain
76
+ const fresh = domain === cert.data.subject?.CN && Number(new Date(cert.data.validTo)) - Date.now() >= 30 * DAY
77
+ if (fresh && !await confirmDialog("Your certificate is still good", { confirmText: "Make a new one anyway" }))
78
+ return
79
+ await apiCall('set_config', { values: acme })
80
+ if (!await confirmDialog("HFS must temporarily serve HTTP on public port 80, and your router must be configured or this operation will fail")) return
81
+ await apiCall('make_cert', { domain, email: acme.acme_email }, { timeout: 20_000 })
82
+ .then(async () => {
83
+ await alertDialog("Certificate created", 'success')
84
+ await manipulateConfig('https_port', async v => {
85
+ return v < 0 && await confirmDialog("HTTPS is currently off", { confirmText: "Switch on" }) ? 443 : v
86
+ })
87
+ status.reload()
88
+ cert.reload()
89
+ }, alertDialog)
90
+ }
91
+ },
92
+ })
93
+ )
94
+ ))
95
+ }
96
+
97
function baseUrlBox() {
40
- if (!status) return h(CircularProgress)
41
- return h(Card, {}, h(CardContent, {},
98
+ return status.element || h(Card, {}, h(CardContent, {},
99
h(Box, { fontSize: 'x-large', mb: 2 }, "Address / Domain"),
100
h(Flex, { flexWrap: 'wrap', alignItems: 'center' },
44
- status?.baseUrl || "Automatic, not configured",
101
+ status.data?.baseUrl || "Automatic, not configured",
102
h(Button, {
103
size: 'small',
47
- onClick() { changeBaseUrl().then(reloadStatus) }
104
+ onClick() { changeBaseUrl().then(status.reload) }
105
}, "Change"),
106
)
107
))
@@ -53,7 +110,7 @@ export default function InternetPage() {
110
function networkBox() {
111
if (error) return element
112
if (!nat) return h(CircularProgress)
56
- return h(Flex, { justifyContent: 'space-around', alignItems: 'center', maxWidth: '40em' },
113
+ return h(Flex, { justifyContent: 'space-around', alignItems: 'center' },
114
h(Device, { name: "Local network", icon: HomeWorkTwoTone, color: localColor, ip: nat?.localIp,
115
below: port && h(Box, { fontSize: 'smaller' }, "port ", port),
116
}),
@@ -63,7 +120,7 @@ export default function InternetPage() {
120
color: nat?.mapped && (wrongMap ? 'warning' : 'success'),
121
below: mapping ? h(LinearProgress, { sx: { height: '1em' } })
122
: h(LinkBtn, { fontSize: 'smaller', display: 'block', onClick: configure },
66
- "port ", wrongMap ? 'is wrong' : nat?.mapped ? nat.mapped.public.port : "unknown"),
123
+ "port ", wrongMap ? 'is wrong' : nat?.externalPort || "unknown"),
124
}),
125
h(Sep),
126
h(Device, { name: "Internet", icon: PublicTwoTone, ip: nat?.publicIp,
@@ -99,9 +156,9 @@ export default function InternetPage() {
156
const { close } = alertDialog(h(Box, {}, msg + "Possible causes:", h('ul', {},
157
!nat.upnp && h('li', {}, "Your router may need to be configured. ", h(Link, { href: PORT_FORWARD_URL, target: 'help' }, "How?")),
158
h('li', {}, "There could be a firewall, try configuring or disabling it."),
102
- nat.mapped?.public.port <= 1024 && h('li', {},
159
+ nat.externalPort <= 1024 && h('li', {},
160
"Your Internet Provider may be blocking ports under 1024. ",
104
- h(Button, { size: 'small', onClick() { close(); mapPort(HIGHER_PORT).then(retry) } }, "Try " + HIGHER_PORT) ),
161
+ nat.upnp && h(Button, { size: 'small', onClick() { close(); mapPort(HIGHER_PORT).then(retry) } }, "Try " + HIGHER_PORT) ),
162
nat.mapped && h('li', {}, "A bug in your modem/router, try rebooting it."),
163
h('li', {}, MSG_ISP),
164
)), 'warning')
@@ -125,7 +182,7 @@ export default function InternetPage() {
182
if (!nat?.upnp)
183
return alertDialog(h(Box, { lineHeight: 1.5 }, md(`We cannot help you configuring your router because UPnP is not available.\nFind more help [on this website](${PORT_FORWARD_URL}).`)), 'info')
184
const res = await promptDialog(md(`This will ask the router to map your port, so that it can be reached from the Internet.\nYou can set the same number of the local network (${port}), or a different one.`), {
128
- value: nat?.mapped?.public.port || port,
185
+ value: nat?.externalPort || port,
186
field: { label: "Port seen from the Internet", comp: NumberField },
187
addToBar: nat?.mapped && [h(Button, { color: 'warning', onClick: remove }, "Remove")],
188
dialogProps: { sx: { maxWidth: '20em' } },
@@ -140,18 +197,21 @@ export default function InternetPage() {
197
}
198
199
function fixPort() {
143
- return mapPort(nat.mapped.public.port, "Forwarding corrected")
200
+ return mapPort(nat.externalPort, "Forwarding corrected")
201
}
202
203
async function mapPort(external: number, msg='') {
204
setMapping(true)
205
try {
206
await apiCall('map_port', { external })
150
- reload()
207
+ reloadNat()
208
if (msg) toast(msg, 'success')
209
}
153
- catch {
154
- return alertDialog("Operation failed", 'error')
210
+ catch(e) {
211
+ const msg = "Operation failed"
212
+ + (external && Math.min(external, nat.internalPort) ? ". Some routers refuse to work with ports under 1024." : '')
213
+ await alertDialog(msg, 'error')
214
+ throw e
215
}
216
finally {
217
setMapping(false)
admin/src/OptionsPage.ts
+36
-53
@@ -1,15 +1,15 @@
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 { Box, Button, FormHelperText, Link } from '@mui/material';
3
+import { Box, Button, FormHelperText } from '@mui/material';
4
import { createElement as h, Fragment, useEffect, useRef } from 'react';
5
import { apiCall, useApiEx } from './api'
6
import { state, useSnapState } from './state'
7
-import { Info, Refresh, Warning } from '@mui/icons-material'
8
-import { Dict, Flex, iconTooltip, LinkBtn, modifiedSx, REPO_URL, wikiLink, with_ } from './misc'
7
+import { CardMembership, Refresh, Warning } from '@mui/icons-material'
8
+import { Dict, iconTooltip, InLink, LinkBtn, modifiedSx, REPO_URL, wait, wikiLink, with_ } from './misc'
9
import { Form, BoolField, NumberField, SelectField, FieldProps, Field, StringField } from '@hfs/mui-grid-form';
10
import { ArrayField } from './ArrayField'
11
import FileField from './FileField'
12
-import { alertDialog, closeDialog, confirmDialog, newDialog, toast } from './dialog'
12
+import { alertDialog, confirmDialog, newDialog, toast, waitDialog } from './dialog'
13
import { proxyWarning } from './HomePage'
14
import _ from 'lodash';
15
import { proxy, subscribe, useSnapshot } from 'valtio'
@@ -102,7 +102,7 @@ export default function OptionsPage() {
102
helperText: wikiLink('HTTPS#certificate', "What is this?"),
103
error: with_(status?.https.error, e => isCertError(e) && (
104
status.https.listening ? e
105
- : [e, ' - ', h(LinkBtn, { key: 'fix', onClick: makeCertAndSave }, "make one")] )),
105
+ : [e, ' - ', h(LinkBtn, { key: 'fix', onClick: suggestMakingCert }, "make one")] )),
106
},
107
httpsEnabled && { k: 'private_key', comp: FileField, md: 4, label: "HTTPS private key file",
108
...with_(status?.https.error, e => isKeyError(e) ? { error: true, helperText: e } : null)
@@ -305,55 +305,38 @@ function WildcardsSupported() {
305
return wikiLink('Wildcards', "Wildcards supported")
306
}
307
308
-function suggestMakingCert() {
309
- newDialog({
310
- Content: () => h(Box, {},
311
- h(Box, { display: 'flex', gap: 1 },
312
- h(Info), "You are enabling HTTPs. It needs a valid certificate + private key to work."
313
- ),
314
- h(Box, { mt: 4, display: 'flex', gap: 1, justifyContent: 'space-around', },
315
- h(Button, { variant: 'contained', onClick(){
316
- closeDialog()
317
- makeCertAndSave().then()
318
- } }, "Help me!"),
319
- h(Button, { onClick: closeDialog }, "I will handle the matter myself"),
320
- ),
321
- )
322
- })
323
-}
308
+export async function suggestMakingCert() {
309
+ return new Promise(resolve => {
310
+ const { close } = newDialog({
311
+ icon: CardMembership,
312
+ title: "Get a certificate",
313
+ onClose: resolve,
314
+ Content: () => h(Box, { p: 1, lineHeight: 1.5, },
315
+ h(Box, {}, "HTTPS needs a certificate to work."),
316
+ h(Box, {}, "We suggest you to ", h(InLink, { to: 'internet', onClick: close }, "get a free but proper certificate"), '.'),
317
+ h(Box, {}, "If you don't have a domain ", h(LinkBtn, { onClick: makeCertAndSave }, "make a self-signed certificate"),
318
+ " but that ", wikiLink('HTTPS#certificate', " won't be perfect"), '.' ),
319
+ )
320
+ })
321
325
-export async function makeCertAndSave() {
326
- if (!window.crypto.subtle)
327
- return alertDialog("Retry this procedure on localhost", 'warning')
328
- const { close } = newDialog({
329
- title: "Get a certificate",
330
- Content: () => h(Flex, { flexDirection: 'column' },
331
- h('p', {}, "HTTPS needs a certificate to work."),
332
- "We suggest you to ",
333
- h(Link, {
334
- target: 'cert',
335
- href: 'https://letsencrypt.org/',
336
- onClick: close,
337
- }, h(Button, { size: 'small', color: 'success' }, "get a free but proper certificate")),
338
- "or, if you are in a hurry",
339
- h(Button, {
340
- size: 'small',
341
- color: 'warning',
342
- async onClick() {
343
- try {
344
- const saved = await apiCall('save_pem', await makeCert({}))
345
- await apiCall('set_config', { values: saved })
346
- if (loaded) // when undefined we are not in this page
347
- Object.assign(loaded, saved)
348
- setTimeout(exposedReloadStatus!, 1000) // give some time for backend to apply
349
- Object.assign(state.config, saved)
350
- await alertDialog("Certificate saved", 'success')
351
- }
352
- finally { close() }
353
- }
354
- }, "make a basic certificate"),
355
- wikiLink('HTTPS#certificate', h(Flex, {}, h(Warning, { color: 'warning' }), "but BEWARE it won't be perfect"))
356
- )
322
+ async function makeCertAndSave() {
323
+ if (!window.crypto.subtle)
324
+ return alertDialog("Retry this procedure on localhost", 'warning')
325
+ const stop = waitDialog()
326
+ try {
327
+ await wait(50) // give time to start animation before cpu intensive task
328
+ const saved = await apiCall('save_pem', await makeCert({}))
329
+ stop()
330
+ await apiCall('set_config', { values: saved })
331
+ if (loaded) // when undefined we are not in this page
332
+ Object.assign(loaded, saved)
333
+ setTimeout(exposedReloadStatus!, 1000) // give some time for backend to apply
334
+ Object.assign(state.config, saved)
335
+ close()
336
+ await alertDialog("Certificate saved", 'success')
337
+ }
338
+ finally { stop() }
339
+ }
340
})
341
}
342
admin/src/dialog.ts
+3
-2
@@ -58,12 +58,13 @@ dialogsDefaults.Container = function Container(d:DialogOptions) {
58
},
59
d.title && h(DialogTitle, {
60
sx: {
61
- position: 'sticky', top: 0, py: 1, pr: 1, zIndex: 2, boxShadow: '0 0 8px #0004',
61
+ position: 'sticky', top: 0, p: 1, zIndex: 2, boxShadow: '0 0 8px #0004',
62
display: 'flex', alignItems: 'center',
63
...titleSx
64
}
65
},
66
- h(Box, { flex:1, minWidth: 40 }, componentOrNode(d.title)),
66
+ componentOrNode(d.icon),
67
+ h(Box, { flex:1, minWidth: 40, ml: 1.5 }, componentOrNode(d.title)),
68
h(IconBtn, { icon: Close, title: "close", onClick: () => closeDialog() }),
69
),
70
h(DialogContent, {
shared/dialogs.ts
+3
-2
@@ -1,7 +1,8 @@
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 { createElement as h, Fragment, FunctionComponent, ReactNode, useEffect, useRef } from 'react'
3
+import { createElement as h, Fragment, FunctionComponent, isValidElement, ReactNode, useEffect, useRef } from 'react'
4
import { proxy, useSnapshot } from 'valtio'
5
+import { isPrimitive } from '.'
6
7
export interface DialogOptions {
8
Content: FunctionComponent<any>,
@@ -120,7 +121,7 @@ function Dialog(d:DialogOptions) {
121
}
122
123
export function componentOrNode(x: ReactNode | FunctionComponent) {
123
- return typeof x === 'function' ? h(x) : x
124
+ return isPrimitive(x) || isValidElement(x) ? x : h(x as any)
125
}
126
127
function onKeyDown(ev:any) {
src/api.net.ts
+105
-80
@@ -2,18 +2,24 @@
2
3
import { ApiError, ApiHandlers } from './apiMiddleware'
4
import { Client } from 'nat-upnp'
5
-import { HTTP_BAD_REQUEST, HTTP_FAILED_DEPENDENCY, HTTP_SERVER_ERROR, HTTP_SERVICE_UNAVAILABLE, IS_MAC, IS_WINDOWS } from './const'
5
+import {
6
+ HTTP_BAD_REQUEST, HTTP_FAILED_DEPENDENCY, HTTP_OK, HTTP_SERVER_ERROR, HTTP_SERVICE_UNAVAILABLE,
7
+ IS_MAC, IS_WINDOWS
8
+} from './const'
9
import axios from 'axios'
10
import {parse} from 'node-html-parser'
11
import _ from 'lodash'
9
-import { cert, getIps, getServerStatus, privateKey, startServer, stopServer } from './listen'
12
+import { cert, getCertObject, getIps, getServerStatus, privateKey } from './listen'
13
import { getProjectInfo } from './github'
14
import { httpString } from './util-http'
15
import { exec } from 'child_process'
13
-import { debounceAsync, findDefined, HOUR, MINUTE, repeat, Dict } from './misc'
16
+import { debounceAsync, HOUR, MINUTE, objSameKeys, repeat } from './misc'
17
import acme from 'acme-client'
18
import fs from 'fs/promises'
16
-import { createServer } from 'http'
19
+import { Dict } from './misc'
20
+import { createServer, RequestListener } from 'http'
21
+import { Middleware } from 'koa'
22
+import { lookup } from 'dns/promises'
23
24
const client = new Client({ timeout: 4_000 })
25
const originalMethod = client.getGateway
@@ -37,7 +43,6 @@ const getNatInfo = debounceAsync(async () => {
43
const localIp = res?.address || (await getIps())[0]
44
const internalPort = status?.https?.listening && status.https.port || status?.http?.listening && status.http.port
45
const mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort)
40
- console.debug('responding')
46
return {
47
upnp: Boolean(res),
48
localIp,
@@ -73,19 +78,49 @@ function findGateway(): Promise<string | undefined> {
78
}) )
79
}
80
76
-async function generateSSLCert(domain: string, email?: string) {
77
- const acmeTokens: Dict<string> = {}
78
- // create temporary server to answer the challenge
81
+let acmeMiddlewareEnabled = false
82
+const acmeTokens: Dict<string> = {}
83
+const acmeListener: RequestListener = (req, res) => { // node format
84
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])
85
+ if (!req.url?.startsWith(BASE)) return
86
+ const token = req.url.slice(BASE.length)
87
+ console.debug("got http challenge", token)
88
+ res.statusCode = HTTP_OK
89
+ res.end(acmeTokens[token])
90
+ return true
91
+}
92
+export const acmeMiddleware: Middleware = (ctx, next) => { // koa format
93
+ if (!acmeMiddlewareEnabled || !Boolean(acmeListener(ctx.req, ctx.res)))
94
+ return next()
95
+}
96
+
97
+async function generateSSLCert(domain: string, email?: string) {
98
+ const { address: domainIp } = await lookup(domain).catch(e => {
99
+ throw e.code !== 'ENOTFOUND' ? e : new ApiError(HTTP_FAILED_DEPENDENCY, "this domain doesn't exist")
100
})
85
- await new Promise<void>((resolve, reject) =>
86
- srv.listen(80, resolve).on('error', (e: any) => reject(e.code || e)) )
101
+ // will answer challenge through our koa app (if on port 80) or must we spawn a dedicated server?
102
+ const { http } = await getServerStatus()
103
+ const tempSrv = http.listening && http.port === 80 ? undefined : createServer(acmeListener)
104
+ if (tempSrv)
105
+ await new Promise<void>((resolve, reject) =>
106
+ tempSrv.listen(80, resolve).on('error', (e: any) => reject(e.code || e)) )
107
+ else
108
+ acmeMiddlewareEnabled = true
109
console.debug('acme challenge server ready')
110
try {
111
+ const { publicIp, upnp, externalPort } = await getNatInfo() // do this before stopping the server
112
+ if (publicIp !== domainIp)
113
+ throw new ApiError(HTTP_FAILED_DEPENDENCY, `please configure your domain to point to ${publicIp} (currently on ${domainIp})`)
114
+ let check = await checkPort(domain, 80) // some check services may not consider the domain, but we already verified that
115
+ if (check && !check.success && upnp && externalPort !== 80) { // consider a short-lived mapping
116
+ // @ts-ignore
117
+ await client.createMapping({ private: 80, public: { host: '', port: 80 }, description: 'hfs challenge', ttl: 0 }).catch(() => {})
118
+ check = await checkPort(domain, 80) // repeat test
119
+ }
120
+ if (!check)
121
+ throw new ApiError(HTTP_FAILED_DEPENDENCY, "couldn't test port 80")
122
+ if (!check.success)
123
+ throw new ApiError(HTTP_FAILED_DEPENDENCY, "port 80 is not working on the specified domain")
124
const client = new acme.Client({
125
accountKey: await acme.crypto.createPrivateKey(),
126
directoryUrl: acme.directory.letsencrypt.production
@@ -108,98 +143,88 @@ async function generateSSLCert(domain: string, email?: string) {
143
return { key, cert }
144
}
145
finally {
111
- await new Promise(res => srv.close(res))
146
+ acmeMiddlewareEnabled = false
147
+ if (tempSrv) await new Promise(res => tempSrv.close(res))
148
console.debug('acme terminated')
149
}
150
}
151
152
+async function checkPort(ip: string, port: number) {
153
+ interface PortScannerService {
154
+ url: string
155
+ headers: {[k: string]: string}
156
+ method: string
157
+ selector: string
158
+ body?: string
159
+ regexpFailure: string
160
+ regexpSuccess: string
161
+ }
162
+ const prjInfo = await getProjectInfo()
163
+ for (const services of _.chunk(_.shuffle<PortScannerService>(prjInfo.checkServerServices), 2)) {
164
+ try {
165
+ return Promise.any(services.map(async svc => {
166
+ const service = new URL(svc.url).hostname
167
+ console.log('trying service', service)
168
+ const api = (axios as any)[svc.method]
169
+ const body = svc.body?.replace('$IP', ip).replace('$PORT', String(port)) || ''
170
+ const res = await api(svc.url, body, {headers: svc.headers})
171
+ const parsed = parse(res.data).querySelector(svc.selector)?.innerText
172
+ if (!parsed) throw console.debug('empty:' + service)
173
+ const success = new RegExp(svc.regexpSuccess).test(parsed)
174
+ const failure = new RegExp(svc.regexpFailure).test(parsed)
175
+ if (success === failure) throw console.debug('inconsistent:' + service) // this result cannot be trusted
176
+ console.debug(service, 'responded', success)
177
+ return { success, service }
178
+ }))
179
+ }
180
+ catch {}
181
+ }
182
+}
183
+
184
const apis: ApiHandlers = {
185
get_nat: getNatInfo,
186
119
- async map_port({ external }) {
120
- const { gatewayIp, externalPort, internalPort } = await getNatInfo()
121
- if (!gatewayIp)
187
+ async map_port({ external, internal }) {
188
+ const { upnp, externalPort, internalPort } = await getNatInfo()
189
+ if (!upnp)
190
return new ApiError(HTTP_SERVICE_UNAVAILABLE, 'upnp failed')
191
if (!internalPort)
192
return new ApiError(HTTP_FAILED_DEPENDENCY, 'no internal port')
193
if (externalPort)
194
try { await client.removeMapping({ public: { host: '', port: externalPort } }) }
195
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 })
196
+ if (external) // must use the object form of 'public' to work around a bug of the library
197
+ await client.createMapping({ private: internal || internalPort, public: { host: '', port: external }, description: 'hfs', ttl: 0 })
198
return {}
199
},
200
133
- async check_server() {
201
+ async check_server({ port }) {
202
const { publicIp, internalPort, externalPort } = await getNatInfo()
203
if (!publicIp)
204
return new ApiError(HTTP_SERVICE_UNAVAILABLE, 'cannot detect public ip')
205
if (!internalPort)
206
return new ApiError(HTTP_FAILED_DEPENDENCY, 'no internal port')
139
- const prjInfo = await getProjectInfo()
140
- const port = externalPort || internalPort
207
+ port ||= externalPort || internalPort
208
console.log(`checking server ${publicIp}:${port}`)
142
- interface PortScannerService {
143
- url: string
144
- headers: {[k: string]: string}
145
- method: string
146
- selector: string
147
- body?: string
148
- regexpFailure: string
149
- regexpSuccess: string
150
- }
151
- for (const services of _.chunk(_.shuffle<PortScannerService>(prjInfo.checkServerServices), 2)) {
152
- try {
153
- return Promise.any(services.map(async svc => {
154
- const service = new URL(svc.url).hostname
155
- console.log('trying service', service)
156
- const api = (axios as any)[svc.method]
157
- const body = svc.body?.replace('$IP', publicIp).replace('$PORT', String(port)) || ''
158
- const res = await api(svc.url, body, {headers: svc.headers})
159
- const parsed = parse(res.data).querySelector(svc.selector)?.innerText
160
- if (!parsed) throw console.debug('empty:' + service)
161
- const success = new RegExp(svc.regexpSuccess).test(parsed)
162
- const failure = new RegExp(svc.regexpFailure).test(parsed)
163
- if (success === failure) throw console.debug('inconsistent:' + service) // this result cannot be trusted
164
- console.debug(service, 'responded', success)
165
- return { success, service }
166
- }))
167
- }
168
- catch {}
169
- }
170
- return new ApiError(HTTP_SERVICE_UNAVAILABLE, 'no service available to detect upnp mapping')
209
+ return await checkPort(publicIp, port)
210
+ || new ApiError(HTTP_SERVICE_UNAVAILABLE)
211
},
212
173
- async make_cert({email, domain}) {
213
+ async make_cert({domain, email}) {
214
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?.() }
215
+ const res = await generateSSLCert(domain, email)
216
+ const CERT_FILE = 'acme.cert'
217
+ const KEY_FILE = 'acme.key'
218
+ await fs.writeFile(CERT_FILE, res.cert)
219
+ await fs.writeFile(KEY_FILE, res.key)
220
+ cert.set(CERT_FILE) // update config
221
+ privateKey.set(KEY_FILE)
222
+ return {}
223
},
224
+
225
+ get_cert() {
226
+ return objSameKeys(_.pick(getCertObject(), ['subject', 'issuer', 'validFrom', 'validTo']), v => v)
227
+ }
228
}
229
230
export default apis
\ No newline at end of file
src/index.ts
+2
@@ -19,6 +19,7 @@ import { ok } from 'assert'
19
import _ from 'lodash'
20
import { randomId } from './misc'
21
import session from 'koa-session'
22
+import { acmeMiddleware } from './api.net'
23
24
ok(_.intersection(Object.keys(frontEndApis), Object.keys(adminApis)).length === 0) // they share same endpoints, don't clash
25
@@ -26,6 +27,7 @@ process.title = 'HFS ' + VERSION
27
const keys = process.env.COOKIE_SIGN_KEYS?.split(',') || [randomId(30)]
28
export const app = new Koa({ keys })
29
app.use(someSecurity)
30
+ .use(acmeMiddleware)
31
.use(session({ key: 'hfs_$id', signed: true, rolling: true, sameSite: 'lax' }, app))
32
.use(prepareState)
33
.use(gzipper)
src/listen.ts
+16
-6
@@ -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, wait } from './misc'
11
+import { debounceAsync, objSameKeys, onlyTruthy, wait } from './misc'
12
import { ADMIN_URI, argv, DEV } from './const'
13
import findProcess from 'find-process'
14
import { anyAccountCanLoginAdmin } from './adminApis'
@@ -57,6 +57,12 @@ export function openAdmin() {
57
console.log("openAdmin failed")
58
}
59
60
+export function getCertObject() {
61
+ const o = new X509Certificate(httpsOptions.cert)
62
+ const some = _.pick(o, ['subject', 'issuer', 'validFrom', 'validTo'])
63
+ return objSameKeys(some, v => v?.includes('=') ? Object.fromEntries(v.split('\n').map(x => x.split('='))) : v)
64
+}
65
+
66
const considerHttps = debounceAsync(async () => {
67
stopServer(httpsSrv).then()
68
let port = httpsPortCfg.get()
@@ -68,8 +74,8 @@ const considerHttps = debounceAsync(async () => {
74
{ name: 'https' }
75
)
76
if (port >= 0) {
71
- const cert = new X509Certificate(httpsOptions.cert)
72
- const cn = cert.subject.split('CN=')[1]?.split('\n')[0]
77
+ const cert = getCertObject()
78
+ const cn = cert.subject?.CN
79
if (cn)
80
console.log("certificate loaded for", cn)
81
const now = new Date()
@@ -154,7 +160,7 @@ export function startServer(srv: typeof httpSrv, { port, host }: StartServer) {
160
161
function listen(host?: string) {
162
return new Promise<number>(async (resolve, reject) => {
157
- srv?.listen({ port, host }, () => {
163
+ srv?.on('error', onError).listen({ port, host }, () => {
164
const ad = srv.address()
165
if (!ad)
166
return reject('no address')
@@ -162,8 +168,12 @@ export function startServer(srv: typeof httpSrv, { port, host }: StartServer) {
168
srv.close()
169
return reject('type of socket not supported')
170
}
171
+ srv.removeListener('error', onError) // necessary in case someone calls stop/start many times
172
resolve(ad.port)
166
- }).on('error', async e => {
173
+ })
174
+
175
+ async function onError(e?: Error) {
176
+ if (!srv) return
177
srv.error = String(e)
178
srv.busy = undefined
179
const { code } = e as any
@@ -175,7 +185,7 @@ export function startServer(srv: typeof httpSrv, { port, host }: StartServer) {
185
const k = (srv === httpSrv? portCfg : httpsPortCfg).key()
186
console.log(` >> try specifying a different port, enter this command: config ${k} 8011`)
187
resolve(0)
178
- })
188
+ }
189
})
190
}
191
}