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) {