| 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 | } |