@samitouri / QOSami-HFS / commits / d4905a5e

admin/options: new "listen interface"

Massimo Melina committed Sep 24, 2023 at 13:00 UTC d4905a5e36ad7223ad837bfd542834931d6e0727
6 files changed +45 -25
admin/src/MonitorPage.ts
+2 -2
@@ -6,7 +6,7 @@ import { apiCall, useApiEvents, useApiEx, useApiList } from "./api"
6 import { PauseCircle, PlayCircle, LinkOff, Lock, Block, FolderZip, Upload, Download } from '@mui/icons-material'
7 import { Alert, Box, Chip, ChipProps } from '@mui/material'
8 import { DataTable } from './DataTable'
9 -import { formatBytes, IconBtn, IconProgress, iconTooltip, manipulateConfig, useBreakpoint } from "./misc"
9 +import { formatBytes, IconBtn, IconProgress, iconTooltip, ipForUrl, manipulateConfig, useBreakpoint } from "./misc"
10 import { Field, SelectField } from '@hfs/mui-grid-form'
11 import { StandardCSSProperties } from '@mui/system/styleFunctionSx/StandardCssProperties'
12 import { toast } from "./dialog"
@@ -116,7 +116,7 @@ function Connections() {
116 headerName: "Address",
117 flex: 1,
118 maxWidth: 400,
119 - renderCell: ({ row, value }) => (value.includes(':') ? `[${value}]` : value) + ' :' + row.port,
119 + renderCell: ({ row, value }) => ipForUrl(value) + ' :' + row.port,
120 mergeRender: { other: 'user', fontSize: 'small' },
121 },
122 {
admin/src/OptionsPage.ts
+12 -9
@@ -5,7 +5,8 @@ import { createElement as h, Fragment, useEffect, useRef } from 'react';
5 import { apiCall, useApiEx } from './api'
6 import { state, useSnapState } from './state'
7 import { CardMembership, Refresh, Warning } from '@mui/icons-material'
8 -import { Dict, iconTooltip, InLink, LinkBtn, MAX_TILES_SIZE, modifiedSx, REPO_URL, wait, wikiLink, with_, try_ } from './misc'
8 +import { Dict, iconTooltip, InLink, LinkBtn, MAX_TILES_SIZE, modifiedSx, REPO_URL, ipLocalHost,
9 + wait, wikiLink, with_, try_, ipForUrl } from './misc'
10 import { Form, BoolField, NumberField, SelectField, FieldProps, Field, StringField } from '@hfs/mui-grid-form';
11 import { ArrayField } from './ArrayField'
12 import FileField from './FileField'
@@ -58,7 +59,7 @@ export default function OptionsPage() {
59 min: 1,
60 unit: "KB/s",
61 placeholder: "no limit",
61 - md: 3,
62 + md: 6,
63 }
64 const httpsEnabled = values.https_port >= 0
65 return h(Form, {
@@ -85,16 +86,15 @@ export default function OptionsPage() {
86 return { sm: 6 }
87 },
88 fields: [
88 - { k: 'port', comp: ServerPort, md: 3, label:"HTTP port", status: status?.http||true, suggestedPort: 80 },
89 - { k: 'https_port', comp: ServerPort, md: 3, label: "HTTPS port", status: status?.https||true, suggestedPort: 443,
89 + { k: 'port', comp: ServerPort, sm: 4, label:"HTTP port", status: status?.http||true, suggestedPort: 80 },
90 + { k: 'https_port', comp: ServerPort, sm: 4, label: "HTTPS port", status: status?.https||true, suggestedPort: 443,
91 onChange(v: number) {
92 if (v >= 0 && !httpsEnabled && !values.cert)
93 suggestMakingCert().then()
94 return v
95 }
96 },
96 - { k: 'max_kbps', ...maxSpeedDefaults, label: "Limit output", helperText: "Doesn't apply to localhost" },
97 - { k: 'max_kbps_per_ip', ...maxSpeedDefaults, label: "Limit output per-ip" },
97 + status && { k: 'listen_interface', comp: SelectField, sm: 4, options: [{ label: "any", value: '' }, '127.0.0.1', '::1', ...status?.ips] },
98 httpsEnabled && values.port >= 0 && { k: 'force_https', comp: BoolField, sm: 12, md: 4, label: "Force HTTPS",
99 helperText: "Not applied to localhost"
100 },
@@ -117,6 +117,8 @@ export default function OptionsPage() {
117 { k: 'file_menu_on_link', comp: SelectField, label: "Access file menu", sm: 12, md: 4,
118 options: { "by clicking on file name": true, "by dedicated button": false }
119 },
120 + { k: 'max_kbps', ...maxSpeedDefaults, label: "Limit output", helperText: "Doesn't apply to localhost" },
121 + { k: 'max_kbps_per_ip', ...maxSpeedDefaults, label: "Limit output per-ip" },
122 { k: 'title', helperText: "You can see this in the tab of your browser" },
123 { k: 'favicon', comp: FileField, placeholder: "None", fileMask: '*.png|*.ico|*.jpg|*.jpeg|*.gif|*.svg',
124 helperText: "The icon associated to your website" },
@@ -204,11 +206,12 @@ export default function OptionsPage() {
206 if (onHttps && certChange && !await confirmDialog("You may disrupt https service, kicking you out"))
207 return
208 await apiCall('set_config', { values: changes })
207 - if (newPort !== undefined) {
209 + if (newPort !== undefined || changes.listen_interface && !(loc.hostname === 'localhost' && ipLocalHost(changes.listen_interface))) {
210 await alertDialog("You are being redirected but in some cases this may fail. Hold on tight!", 'warning')
211 + const host = ipForUrl(changes.listen_interface || loc.hostname)
212 // we have to jump protocol also in case of random port, because we want people to know their port while using GUI
210 - return window.location.href = newPort <= 0 ? (onHttps ? 'http:' : 'https:') + '//' + loc.hostname + ':' + otherPort + loc.pathname
211 - : loc.protocol + '//' + loc.hostname + ':' + newPort + loc.pathname
213 + return window.location.href = newPort <= 0 ? `${onHttps ? 'http:' : 'https:'}//${host}:${otherPort}${loc.pathname}`
214 + : `${loc.protocol}//${host}:${newPort || values[keys[0]]}${loc.pathname}`
215 }
216 const portChange = 'port' in changes || 'https_port' in changes
217 setTimeout(reloadStatus, portChange || certChange ? 1000 : 0) // give some time to apply news
src/adminApis.ts
+2 -1
@@ -2,7 +2,7 @@
2
3 import { ApiError, ApiHandlers, SendListReadable } from './apiMiddleware'
4 import { defineConfig, getWholeConfig, setConfig } from './config'
5 -import { getServerStatus, getUrls } from './listen'
5 +import { getIps, getServerStatus, getUrls } from './listen'
6 import {
7 API_VERSION,
8 BUILD_TIMESTAMP,
@@ -96,6 +96,7 @@ export const adminApis: ApiHandlers = {
96 compatibleApiVersion: COMPATIBLE_API_VERSION,
97 ...await getServerStatus(),
98 urls: await getUrls(),
99 + ips: await getIps(false),
100 baseUrl: baseUrl.get(), // can be retrieved with get_config, but it's very handy with urls and low overhead. Case is different because the context is
101 updatePossible: !updateSupported() ? false : await localUpdateAvailable() ? 'local' : true,
102 proxyDetected: getProxyDetected(),
src/cross.ts
+8
@@ -259,3 +259,11 @@ export function isEqualLax(a: any,b: any): boolean {
259 export function xlate(input: any, table: Record<string, any>) {
260 return table[input] ?? input
261 }
262 +
263 +export function ipLocalHost(ip: string) {
264 + return ip === '::1' || ip.endsWith('127.0.0.1')
265 +}
266 +
267 +export function ipForUrl(ip: string) {
268 + return ip.includes(':') ? '[' + ip + ']' : ip
269 +}
\ No newline at end of file
src/listen.ts
+19 -12
@@ -8,8 +8,8 @@ import { watchLoad } from './watchLoad'
8 import { networkInterfaces } from 'os';
9 import { newConnection } from './connections'
10 import open from 'open'
11 -import { debounceAsync, objSameKeys, onlyTruthy, wait } from './misc'
12 -import { ADMIN_URI, argv, DEV } from './const'
11 +import { debounceAsync, ipForUrl, objSameKeys, onlyTruthy, wait, waitFor } from './misc'
12 +import { ADMIN_URI, argv, DEV, IS_WINDOWS } from './const'
13 import findProcess from 'find-process'
14 import { anyAccountCanLoginAdmin } from './adminApis'
15 import _ from 'lodash'
@@ -29,13 +29,11 @@ export function getHttpsWorkingPort() {
29
30 const commonOptions = { requestTimeout: 0 }
31
32 -export const portCfg = defineConfig<number>('port', 80)
33 -portCfg.sub(async port => {
34 - while (!app)
35 - await wait(100)
32 +const considerHttp = debounceAsync(async () => {
33 + await waitFor(() => app)
34 stopServer(httpSrv).then()
35 httpSrv = Object.assign(http.createServer(commonOptions as any, app.callback()), { name: 'http' })
38 - port = await startServer(httpSrv, { port })
36 + const port = await startServer(httpSrv, { port: portCfg.get(), host: listenInterface.get() })
37 if (!port) return
38 httpSrv.on('connection', newConnection)
39 printUrls(httpSrv.name)
@@ -43,6 +41,11 @@ portCfg.sub(async port => {
41 openAdmin()
42 })
43
44 +export const portCfg = defineConfig<number>('port', 80)
45 +const listenInterface = defineConfig('listen_interface', '')
46 +portCfg.sub(considerHttp)
47 +listenInterface.sub(considerHttp)
48 +
49 export function openAdmin() {
50 for (const srv of [httpSrv, httpsSrv]) {
51 const a = srv?.address()
@@ -102,7 +105,7 @@ const considerHttps = debounceAsync(async () => {
105 console.log("failed to create https server: check your private key and certificate", String(e))
106 return
107 }
105 - port = await startServer(httpsSrv, { port })
108 + port = await startServer(httpsSrv, { port, host: listenInterface.get() })
109 if (!port) return
110 httpsSrv.on('connection', newConnection)
111 printUrls(httpsSrv.name)
@@ -136,6 +139,7 @@ for (const cfg of httpsNeeds) {
139 const PORT_DISABLED = -1
140 export const httpsPortCfg = defineConfig('https_port', PORT_DISABLED)
141 httpsPortCfg.sub(considerHttps)
142 +listenInterface.sub(considerHttps)
143
144 interface StartServer { port: number, host?:string }
145 export function startServer(srv: typeof httpSrv, { port, host }: StartServer) {
@@ -183,6 +187,8 @@ export function startServer(srv: typeof httpSrv, { port, host }: StartServer) {
187 srv.error = String(e)
188 srv.busy = undefined
189 const { code } = e as any
190 + if (code === 'EACCES' && port < 1024)
191 + srv.error = `lacking permission on port ${port}, try with permission (${IS_WINDOWS ? 'administrator' : 'sudo'}) or port > 1024`
192 if (code === 'EADDRINUSE') {
193 srv.busy = findProcess('port', port).then(res => res?.[0]?.name || '', () => '')
194 srv.error = `port ${port} busy: ${await srv.busy || "unknown process"}`
@@ -231,12 +237,12 @@ export async function getServerStatus() {
237
238 const ignore = /^(lo|.*loopback.*|virtualbox.*|.*\(wsl\).*|llw\d|awdl\d|utun\d|anpi\d)$/i // avoid giving too much information
239
234 -export async function getIps() {
240 +export async function getIps(external=true) {
241 const ips = onlyTruthy(Object.entries(networkInterfaces()).map(([name, nets]) =>
242 nets && !ignore.test(name)
243 && v4first(onlyTruthy(nets.map(net => !net.internal && net.address)))[0] // for each interface we consider only 1 address
244 )).flat()
239 - const e = await externalIp
245 + const e = external && await externalIp
246 if (e && !ips.includes(e))
247 ips.unshift(e)
248 return v4first(ips)
@@ -248,13 +254,14 @@ export async function getIps() {
254 }
255
256 export async function getUrls() {
251 - const ips = (await getIps()).map(ip => ip.includes(':') ? '[' + ip + ']' : ip)
257 + const on = listenInterface.get()
258 + const ips = on ? [on] : await getIps()
259 return Object.fromEntries(onlyTruthy([httpSrv, httpsSrv].map(srv => {
260 if (!srv?.listening)
261 return false
262 const port = (srv?.address() as any)?.port
263 const appendPort = port === (srv.name === 'https' ? 443 : 80) ? '' : ':' + port
257 - const urls = ips.map(ip => `${srv.name}://${ip}${appendPort}`)
264 + const urls = ips.map(ip => `${srv.name}://${ipForUrl(ip)}${appendPort}`)
265 return urls.length && [srv.name, urls]
266 })))
267 }
src/misc.ts
+2 -1
@@ -15,6 +15,7 @@ import { SocketAddress, BlockList } from 'node:net'
15 import debounceAsync from './debounceAsync'
16 import { ApiError } from './apiMiddleware'
17 import { HTTP_BAD_REQUEST } from './const'
18 +import { ipLocalHost } from './cross'
19 export { debounceAsync }
20
21 type ProcessExitHandler = (signal:string) => any
@@ -58,7 +59,7 @@ export function onOff(em: EventEmitter, events: { [eventName:string]: (...args:
59
60 export function isLocalHost(c: Connection | Koa.Context | string) {
61 const ip = typeof c === 'string' ? c : c.socket.remoteAddress // don't use Context.ip as it is subject to proxied ips, and that's no use for localhost detection
61 - return ip && (ip === '::1' || ip.endsWith('127.0.0.1'))
62 + return ip && ipLocalHost(ip)
63 }
64
65 export function makeNetMatcher(mask: string, emptyMaskReturns=false) {