@samitouri / QOSami-HFS / commits / 7c37427e

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 }