main
ts 349 lines 14.9 KB
Raw
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 * as http from 'http'
4 import { defineConfig, subMultipleConfigs } from './config'
5 import { app } from './index'
6 import * as https from 'https'
7 import { watchLoad } from './watchLoad'
8 import { networkInterfaces } from 'os';
9 import { getConnections, newConnection } from './connections'
10 import { TLSSocket } from 'node:tls'
11 import open from 'open'
12 import {
13 CFG, debounceAsync, ipForUrl, makeNetMatcher, MINUTE, onlyTruthy, prefix, runAt, wait, xlate
14 } from './misc'
15 import { PORT_DISABLED, ADMIN_URI, IS_WINDOWS } from './const'
16 import findProcess from 'find-process'
17 import { anyAccountCanLoginAdmin } from './adminApis'
18 import _ from 'lodash'
19 import { X509Certificate } from 'crypto'
20 import events from './events'
21 import { isIPv6 } from 'net'
22 import { defaultBaseUrl } from './nat'
23 import { storedMap } from './persistence'
24 import { argv } from './argv'
25 import { consoleHint } from './consoleLog'
26 import { onProcessExit, quitting } from './first'
27 import { fileAttrDb } from './fileAttr'
28
29 interface ServerExtra { name: string, error?: string, busy?: Promise<string> }
30 let httpSrv: undefined | http.Server & ServerExtra
31 let httpsSrv: undefined | http.Server & ServerExtra
32
33 // the update relaunch can keep a bridge process alive, so we proactively close listeners here to release ports before the next binary binds; do it before (5) the storage file is closed, because sockets write there
34 onProcessExit(() => Promise.all([stopServer(httpSrv), stopServer(httpsSrv)]), 5)
35
36 const openBrowserAtStart = defineConfig('open_browser_at_start', true)
37
38 export const baseUrl = defineConfig(CFG.base_url, '',
39 x => /(?<=\/\/)[^\/]+/.exec(x)?.[0]) // compiled is host only
40
41 export async function getBaseUrlOrDefault() {
42 return baseUrl.get() || await defaultBaseUrl.get()
43 }
44
45 export function getHttpsWorkingPort() {
46 return httpsSrv?.listening && (httpsSrv.address() as any)?.port
47 }
48
49 const commonServerOptions: http.ServerOptions = {
50 requestTimeout: 0, // disable timeout on long uploads
51 maxHeaderSize: 32768, // allow a larger header for a larger list in ?get=zip
52 }
53 // these are properties that can be assigned to the server object
54 const commonServerAssign = { headersTimeout: 30_000, timeout: MINUTE } // 'headersTimeout' is not recognized by type lib, and 'timeout' is not effective when passed in parameters
55
56 const readyToListen = Promise.all([ storedMap.isOpening(), fileAttrDb.isOpening(), events.once('app') ])
57
58 const considerHttp = debounceAsync(async () => {
59 await readyToListen
60 void stopServer(httpSrv)
61 httpSrv = Object.assign(http.createServer(commonServerOptions, app.callback()), { name: 'http' }, commonServerAssign)
62 const host = listenInterface.get()
63 const port = portCfg.get()
64 if (port === PORT_DISABLED) return
65 if (!await startServer(httpSrv, { port, host }))
66 if (port !== 80)
67 return consoleHint(`try specifying a different port, enter this command: config ${portCfg.key()} 1080`)
68 else if (!await startServer(httpSrv, { port: 8080, host }))
69 return
70 httpSrv.on('connection', newConnection)
71 printUrls(httpSrv.name)
72 if (openBrowserAtStart.get() && !argv.updated && openOnce) {
73 openOnce = false
74 openAdmin()
75 }
76 })
77 let openOnce = true
78
79 export const portCfg = defineConfig('port', 80)
80 const listenInterface = defineConfig('listen_interface', '')
81 subMultipleConfigs(considerHttp, [portCfg, listenInterface])
82
83 export function openAdmin() {
84 for (const srv of [httpSrv, httpsSrv]) {
85 const a = srv?.address()
86 if (!a || typeof a === 'string') continue
87 const i = listenInterface.get()
88 // open() will fail with ::1, don't know why, as my browser correctly opens the resulting url
89 const hostname = i === '::1' || i in genericInterfaceNames ? 'localhost' : i
90 const baseUrl = `${srv!.name}://${hostname}:${a.port}`
91 open(baseUrl + ADMIN_URI, { wait: true}).catch(async e => {
92 console.debug(String(e))
93 console.warn("Cannot launch browser on this machine >PLEASE< open your browser and reach one of these (you may need a different address)",
94 ...Object.values(await getUrls()).flat().map(x => '\n - ' + x + ADMIN_URI))
95 if (! anyAccountCanLoginAdmin())
96 consoleHint(`you can enter this command: create-admin YOUR_PASSWORD`)
97 })
98 return true
99 }
100 console.log("OpenAdmin failed")
101 }
102
103 export function getCertObject() {
104 const c = cert.compiled()
105 if (!c) return
106 const all = new X509Certificate(c)
107 const some = _.pick(all, ['subject', 'issuer', 'validFrom', 'validTo'])
108 const ret = _.mapValues(some, v => v?.includes('=') ? Object.fromEntries(v.split('\n').map(x => x.split('='))) : v)
109 return Object.assign(ret, { altNames: all.subjectAltName?.replace(/DNS:/g, '').split(/, */) })
110 }
111
112 const considerHttps = debounceAsync(async () => {
113 await readyToListen
114 void stopServer(httpsSrv)
115 defaultBaseUrl.proto = 'http'
116 defaultBaseUrl.port = getCurrentPort(httpSrv) ?? 0
117 let port = httpsPortCfg.get()
118 try {
119 const moreOptions = Object.assign({}, ...await events.emitAsync('httpsServerOptions') || []) // emitAsync returns an array of objects
120 httpsSrv = Object.assign(
121 https.createServer(port === PORT_DISABLED ? {} : {
122 ...commonServerOptions,
123 key: privateKey.compiled(),
124 cert: cert.compiled(),
125 ...moreOptions,
126 }, app.callback()),
127 { name: 'https' },
128 commonServerAssign
129 )
130 if (port >= 0) {
131 const certObj = getCertObject()
132 if (certObj) {
133 const cn = certObj.subject?.CN
134 if (cn)
135 console.log("Certificate loaded for", certObj.altNames?.join(' + ') || cn)
136 const from = new Date(certObj.validFrom)
137 const to = new Date(certObj.validTo)
138 updateError() // error will change at from and to dates of the certificate
139 const cancelTo = runAt(to.getTime(), updateError)
140 const cancelFrom = runAt(from.getTime(), updateError)
141 httpsSrv.on('close', () => {
142 cancelTo()
143 cancelFrom()
144 })
145 function updateError() {
146 if (!httpsSrv) return
147 const now = new Date()
148 httpsSrv.error = from > now ? "certificate not valid yet" : to < now ? "certificate expired" : undefined
149 }
150 }
151 const namesForOutput: any = { cert: 'certificate', private_key: 'private key' }
152 for (const x of httpsNeeds)
153 if (!x.get())
154 return httpsSrv.error = "missing " + namesForOutput[x.key()]
155 else if (!x.compiled())
156 return httpsSrv.error = "cannot read " + namesForOutput[x.key()]
157 }
158 }
159 catch(e: any) {
160 httpsSrv ||= Object.assign(https.createServer({}), { name: 'https' }) // a dummy container, in case creation failed because of certificate errors
161 httpsSrv.error = "bad private key or certificate"
162 console.error("Failed to create https server: check your private key and certificate", e.message)
163 return
164 }
165 httpsSrv.on('connection', newConnection) // this event is emitted as soon as the tcp layer is connected
166 httpsSrv.on('secureConnection', (socket: TLSSocket) => { // emitted when the TLS layer is connected
167 for (const c of getConnections()) // TLSSocket shares the same ip:port, so we can find its matching Connection
168 if (socket.remoteAddress === c.socket.remoteAddress
169 && socket.remotePort === c.socket.remotePort)
170 return c.socket.emit('secure', socket) // let know Connection about the secure socket
171 })
172 port = await startServer(httpsSrv, { port, host: listenInterface.get() })
173 if (!port) return
174 printUrls(httpsSrv.name)
175 events.emit('httpsReady')
176 defaultBaseUrl.proto = 'https'
177 defaultBaseUrl.port = getCurrentPort(httpsSrv) ?? 0
178 }, { wait: 200 }) // give time to have key and cert ready
179
180 export const cert = defineConfig('cert', '' as string, load)
181 export const privateKey = defineConfig('private_key', '' as string, load)
182 const httpsNeeds = [cert, privateKey]
183
184 function load(v: string, { object }: any) {
185 object.watcher?.unwatch()
186 if (!v || v.includes('\n'))
187 return v
188 // v is a path, we'll watch the file for changes
189 object.watcher = watchLoad(v, x => object.setCompiled(x), { immediateFirst: true })
190 return ''
191 }
192
193 export const httpsPortCfg = defineConfig('https_port', PORT_DISABLED)
194 subMultipleConfigs(considerHttps, [httpsPortCfg, listenInterface, ...httpsNeeds])
195
196 const genericInterfaceNames = {
197 '0.0.0.0': "any IPv4",
198 '::': "any IPv6",
199 '': "any network",
200 }
201
202 function renderHost(host: string) {
203 return xlate(host, genericInterfaceNames)
204 }
205
206 interface StartServer { port: number, host?:string }
207 export function startServer(srv: typeof httpSrv, { port, host }: StartServer) {
208 return new Promise<number>(async resolve => {
209 if (!srv) return resolve(0)
210 try {
211 if (port === PORT_DISABLED)
212 return resolve(0)
213 if (!host && !await testIpV4()) // !host means ipV4+6, and if v4 port alone is busy, we won't be notified of the failure, so we'll first test it on its own
214 throw srv.error
215 // from a few tests, this seems enough to support the expect-100 http/1.1 mechanism, at least with curl -T, not used by chrome|firefox anyway
216 srv.on('checkContinue', (req, res) => srv.emit('request', req, res))
217 port = await listen(host)
218 if (port)
219 console.log(`Serving ${srv.name} on ${renderHost(host || '')} port ${port}`)
220 resolve(port)
221 }
222 catch(e) {
223 srv.error = String(e)
224 console.error(srv.name, `Couldn't listen on port ${port}:`, srv.error)
225 resolve(0)
226 }
227 })
228
229 async function testIpV4() {
230 const res = await listen('0.0.0.0', true)
231 await new Promise(res => srv?.close(res)) // close, if any, and wait
232 return res > 0
233 }
234
235 function listen(host?: string, silence=false) {
236 return new Promise<number>(async (resolve, reject) => {
237 srv?.on('error', onError).listen({ port, host }, () => {
238 const ad = srv.address()
239 if (!ad)
240 return reject('no address')
241 if (typeof ad === 'string') {
242 srv.close()
243 return reject('type of socket not supported')
244 }
245 srv.removeListener('error', onError) // necessary in case someone calls stop/start many times
246 events.emit('listening', { server: srv, port: ad.port })
247 resolve(ad.port)
248 })
249
250 async function onError(e?: Error) {
251 if (!srv) return
252 srv.error = String(e)
253 srv.busy = undefined
254 const { code } = e as any
255 if (code)
256 srv.busy = findProcess('port', port).then(
257 res => res?.map(x => prefix("Service", x.name === 'svchost.exe' && x.cmd.split(x.name)[1]?.trim()) || x.name).join(' + '),
258 () => '')
259 if (code === 'EACCES' && port < 1024 && !srv.busy) // on Windows, when port is used by a service, we get EACCES
260 srv.error = `lacking permission on port ${port}, try with permission (${IS_WINDOWS ? 'administrator' : 'sudo'}) or port > 1024`
261 if (code === 'EADDRINUSE' || srv.busy)
262 srv.error = `port ${port} busy: ${await srv.busy || "unknown process"}`
263 if (!silence)
264 console.error(srv.name, srv.error)
265 resolve(0)
266 }
267 })
268 }
269 }
270
271 export function stopServer(srv?: http.Server) {
272 return new Promise(resolve => {
273 if (!srv?.listening)
274 return resolve(null)
275 const ad = srv.address()
276 if (ad && typeof ad !== 'string')
277 console.log("Stopped port", ad.port)
278 srv.close(err => {
279 if (err && (err as any).code !== 'ERR_SERVER_NOT_RUNNING')
280 console.debug("Failed to stop server", String(err))
281 resolve(err)
282 })
283 if (quitting)
284 srv.closeAllConnections()
285 })
286 }
287
288 function getCurrentPort(srv: typeof httpSrv) {
289 return (srv?.address() as any)?.port as number | undefined
290 }
291
292 export async function getServerStatus(includeSrv=true) {
293 return {
294 http: await serverStatus(httpSrv, portCfg.get()),
295 https: await serverStatus(httpsSrv, httpsPortCfg.get()),
296 }
297
298 async function serverStatus(srv: typeof httpSrv, configuredPort: number) {
299 const busy = await srv?.busy
300 await wait(0) // simple trick to wait for also .error to be updated. If this trickery becomes necessary elsewhere, then we should make also error a Promise.
301 return {
302 ..._.pick(srv, ['listening', 'error']),
303 busy,
304 port: getCurrentPort(srv) || configuredPort,
305 configuredPort,
306 srv: includeSrv ? srv : undefined,
307 }
308 }}
309
310 const ignore = /^(lo|.*loopback.*|virtualbox.*|.*\(wsl\).*|llw\d|awdl\d|utun\d|anpi\d)$/i // avoid giving too much information
311
312 // AKA auto-ip https://en.wikipedia.org/wiki/Link-local_address
313 const isLinkLocal = makeNetMatcher('169.254.0.0/16|FE80::/10')
314
315 export async function getIps(external=true) {
316 const only = { '0.0.0.0': 'IPv4', '::' : 'IPv6' }[listenInterface.get()] || ''
317 const ips = onlyTruthy(Object.entries(networkInterfaces()).flatMap(([name, nets]) =>
318 nets && !ignore.test(name) && nets.map(net => !net.internal && (!only || only === net.family) && net.address)
319 ))
320 const e = external && defaultBaseUrl.externalIp
321 if (e && !ips.includes(e))
322 ips.push(e)
323 const noLinkLocal = ips.filter(x => !isLinkLocal(x))
324 const ret = _.sortBy(noLinkLocal.length ? noLinkLocal : ips, [
325 x => x !== defaultBaseUrl.localIp, // use the "nat" info to put best ip first
326 isIPv6 // false=IPV4 comes first
327 ])
328 defaultBaseUrl.localIp ||= ret[0] || ''
329 return ret
330 }
331
332 export async function getUrls() {
333 const on = listenInterface.get()
334 const ips = on === renderHost(on) ? [on] : await getIps()
335 return Object.fromEntries(onlyTruthy([httpSrv, httpsSrv].map(srv => {
336 if (!srv?.listening)
337 return false
338 const port = (srv?.address() as any)?.port
339 const appendPort = port === (srv.name === 'https' ? 443 : 80) ? '' : ':' + port
340 const urls = ips.map(ip => `${srv.name}://${ipForUrl(ip)}${appendPort}`)
341 return urls.length && [srv.name, urls]
342 })))
343 }
344
345 function printUrls(srvName: string) {
346 getUrls().then(urls =>
347 _.each(urls[srvName], url =>
348 console.log('Serving on', url)))
349 }