config.admin_port and admin_network

Massimo Melina committed Feb 5, 2022 at 16:15 UTC 58eaa394dd93dc37052c82ed1260c1a2847e561b
15 files changed +142 -75
README.md
+2
@@ -64,6 +64,8 @@ When not specified, default values will be used.
64 Supported entries are:
65 - `port` where to accept http connections. Default is 80.
66 - `vfs` the files and folders you want to expose. For details see the dedicated following section.
67 +- `admin_port` the port where to reach admin interface. Default is 63636.
68 +- `admin_network` the network address where to reach admin interface. Default is 127.0.0.1 .
69 - `log` path of the log file. Default is `access.log`.
70 - `error_log` path of the log file for errors. Default is `error.log`.
71 - `errors_in_main_log` if you want to use a single file for both kind of entries. Default is false.
admin/src/ConfigPage.ts
+8 -1
@@ -5,7 +5,7 @@ import { state, useSnapState } from './state'
5 import { Refresh } from '@mui/icons-material'
6 import { Dict } from './misc'
7 import { subscribeKey } from 'valtio/utils'
8 -import { Form, ServerPort, BoolField, NumberField, StringField } from './Form';
8 +import { Form, ServerPort, BoolField, NumberField, StringField, SelectField } from './Form';
9 import StringStringField from './StringStringField'
10
11 let loaded: Dict | undefined
@@ -50,6 +50,13 @@ export default function ConfigPage() {
50 return { md: shortField ? 3 : 6 }
51 },
52 fields: [
53 + { k: 'admin_port', comp: ServerPort, label: 'Admin port' },
54 + { k: 'admin_network', comp: SelectField, label: 'Admin access',
55 + options:[
56 + { value: '127.0.0.1', label: 'localhost only' },
57 + { value: '0.0.0.0', label: 'any network' }
58 + ]
59 + },
60 { k: 'port', comp: ServerPort, label:'HTTP port' },
61 { k: 'https_port', comp: ServerPort, label: 'HTTPS port' },
62 { k: 'cert', comp: StringField, label: 'HTTPS certificate file' },
admin/src/MonitorPage.ts
+1 -1
@@ -53,7 +53,7 @@ function MoreInfo() {
53 }
54
55 function Connections() {
56 - const { list, error } = useApiList ('get_connections')
56 + const { list, error } = useApiList('get_connections')
57 const rows = useMemo(()=> list?.map((x:any,id:number) => ({ id, ...x })), [list])
58 if (error)
59 return h(Alert, { severity: 'error' }, error)
admin/src/index.ts
+2 -1
@@ -1,6 +1,7 @@
1 -import { createElement as h, StrictMode } from 'react';
1 +import { createElement as h, StrictMode } from 'react'
2 import ReactDOM from 'react-dom';
3 import './index.css';
4 +import './min-crypto-polyfill'
5 import App from './App';
6 //import reportWebVitals from './reportWebVitals';
7
admin/src/min-crypto-polyfill.js new
+19
@@ -0,0 +1,19 @@
1 +export {}
2 +// this is the minimum required for lib tssrp6a to work
3 +if (!window.crypto?.subtle) {
4 + console.debug('poly subtle')
5 +
6 + const subtle = {
7 + async digest(algo, buff) {
8 + if (algo !== 'SHA-512')
9 + return alert(algo + ' required but not supported')
10 + const lib = await import('js-sha512')
11 + const sha = lib.default.arrayBuffer
12 + return sha(buff)
13 + }
14 + }
15 + if (!window.crypto)
16 + window.crypto = { subtle }
17 + if (!crypto.subtle)
18 + crypto.subtle = subtle
19 +}
admin/tsconfig.json
+1 -1
@@ -1,6 +1,6 @@
1 {
2 "compilerOptions": {
3 - "target": "es5",
3 + "target": "es2017",
4 "lib": [
5 "dom",
6 "dom.iterable",
frontend/tsconfig.json
+1 -1
@@ -1,6 +1,6 @@
1 {
2 "compilerOptions": {
3 - "target": "es5",
3 + "target": "es2017",
4 "lib": [
5 "dom",
6 "dom.iterable",
package.json
+4 -2
@@ -14,8 +14,10 @@
14 "start-frontend": "cd frontend && npm run start",
15 "start-admin": "cd admin && npm run start",
16 "start": "node dist",
17 - "build": "npm install && tsc --target es2018 && cp -v -r package*.json READ* plugins tests dist && cp config.yaml dist/config-example.yaml && cp accounts.yaml dist/accounts-example.yaml && cd dist && npm ci --production && cd .. && node afterbuild",
18 - "build-all": "rm -rf dist && npm run build && cd frontend && npm install && npm run build && npm audit --production && echo COMPLETED",
17 + "build": "npm install && rm -rf dist/src && tsc --target es2018 && cp -v -r package*.json READ* plugins tests dist && cp config.yaml dist/config-example.yaml && cp accounts.yaml dist/accounts-example.yaml && cd dist && npm ci --production && cd .. && node afterbuild",
18 + "build-all": "rm -rf dist && npm run build && npm run build-frontend && npm run build-admin && npm audit --production && echo COMPLETED",
19 + "build-frontend": "cd frontend && npm install && npm run build",
20 + "build-admin": "cd admin && npm install && npm run build",
21 "test": "mocha -r ts-node/register 'tests/**/*.ts'",
22 "zip-dist": "cd dist && zip hfs.zip -r * -x *.zip *.exe",
23 "exe-dist": "pkg . -C brotli && cd dist && rm -rf src node_modules && zip hfs.exe.zip -r * -x *.zip run.bat",
src/config.ts
+11 -10
@@ -20,20 +20,21 @@ watchLoad(path, data => {
20 setConfig(data)
21 }, { failOnFirstAttempt:()=> setConfig({}) })
22
23 -const configProps:Record<string, ConfigProps> = {}
23 +const configProps:Record<string, ConfigProps<any>> = {}
24
25 -interface ConfigProps {
26 - defaultValue?:any,
27 - caster?:(argV:string)=>any
25 +interface ConfigProps<T> {
26 + defaultValue?: T,
27 + caster?:(argV:string)=> T
28 }
29 -export function defineConfig(k:string, definition:ConfigProps) {
29 +export function defineConfig<T>(k: string, definition: ConfigProps<T>) {
30 configProps[k] = definition
31 if (!definition.caster)
32 if (typeof definition.defaultValue === 'number')
33 + // @ts-ignore
34 definition.caster = Number
35 }
36
36 -export function subscribeConfig({ k, ...definition }:{ k:string } & ConfigProps, cb:(v:any, was?:any)=>void) {
37 +export function subscribeConfig<T>({ k, ...definition }:{ k:string } & ConfigProps<T>, cb:(v:T, was?:T)=>void) {
38 if (definition)
39 defineConfig(k, definition)
40 const { caster, defaultValue } = configProps[k] ?? {}
@@ -91,12 +92,12 @@ export const saveConfigAsap = _.debounce(async () => {
92 .catch(err => console.error('Failed at saving config file, please ensure it is writable.', String(err)))
93 })
94
94 -// async version of getConfig, allowing you to wait for onfig to be ready
95 -export async function getConfigReady(k: string, definition?: object) {
96 - return new Promise(resolve => {
95 +// async version of getConfig, allowing you to wait for config to be ready
96 +export async function getConfigReady<T>(k: string, definition?: object) {
97 + return new Promise<T>(resolve => {
98 const off = subscribeConfig({ k, ...definition }, v => {
99 off?.()
99 - resolve(v)
100 + resolve(v as T)
101 })
102 })
103 }
src/connections.ts
+1 -1
@@ -12,7 +12,7 @@ export interface Connection {
12
13 const all: Connection[] = []
14
15 -export function newConnection(socket: Socket, secure:boolean) {
15 +export function newConnection(socket: Socket, secure:boolean=false) {
16 const conn: Connection = { socket, secure, got: 0, sent: 0, started: new Date() }
17 all.push(conn)
18 app.emit('connection', conn) // we'll use these events for SSE
src/index.ts
+4 -14
@@ -11,8 +11,6 @@ import { headRequests, gzipper, sessions, frontendAndSharedFiles } from './middl
11 import './listen'
12 import { serveAdminFiles } from './serveFrontend'
13 import { adminApis } from './adminApis'
14 -import { getConfigReady } from './config'
15 -import open from 'open'
14
15 export const BUILD_TIMESTAMP = "-"
16 export const SESSION_DURATION = 30*60_000
@@ -21,18 +19,10 @@ export const HFS_STARTED = new Date()
19 console.log('started', HFS_STARTED.toLocaleString(), 'build', BUILD_TIMESTAMP, DEV)
20 console.debug('cwd', process.cwd())
21
24 -getConfigReady('open_browser_at_start', { defaultValue: true }).then(config => {
25 - const ADMIN_PORT = 63636
26 - new Koa()
27 - .use(mount(API_URI, apiMiddleware(adminApis)))
28 - .use(serveAdminFiles)
29 - .on('error', errorHandler)
30 - .listen(ADMIN_PORT, '127.0.0.1', () => {
31 - if (config !== false)
32 - open('http://localhost:' + ADMIN_PORT).then()
33 - console.log('admin interface on http://localhost:' + ADMIN_PORT)
34 - })
35 -})
22 +export const adminApp = new Koa()
23 + .use(mount(API_URI, apiMiddleware(adminApis)))
24 + .use(serveAdminFiles)
25 + .on('error', errorHandler)
26
27 export const app = new Koa({ keys: ['hfs-keys-test'] })
28 app.use(sessions(app))
src/listen.ts
+69 -37
@@ -1,21 +1,48 @@
1 import * as http from 'http'
2 import { getConfig, subscribeConfig } from './config'
3 -import { app } from './index'
3 +import { adminApp, app } from './index'
4 import * as https from 'https'
5 import { watchLoad } from './watchLoad'
6 import { networkInterfaces } from 'os';
7 import { newConnection } from './connections'
8 +import open from 'open'
9 +import { debounceAsync } from './misc'
10
11 let httpSrv: http.Server
12 let httpsSrv: http.Server
13 +let adminSrv: http.Server
14 let cert:string, key: string
15
13 -subscribeConfig({ k:'port', defaultValue: 80 }, async (port: number) => {
16 +subscribeConfig<number>({ k:'port', defaultValue: 80 }, async port => {
17 await stopServer(httpSrv)
15 - httpSrv = http.createServer(app.callback());
16 - await startServer(httpSrv, port ?? 80)
18 + httpSrv = http.createServer(app.callback())
19 + port = await startServer(httpSrv, { port, name:'http' })
20 + httpSrv.on('connection', newConnection)
21 + printUrls(port, 'http')
22 })
23
24 +const considerAdmin = debounceAsync(async () => {
25 + const port = getConfig('admin_port')
26 + const net = getConfig('admin_network')
27 + const ad = adminSrv?.address()
28 + if (ad && typeof ad !== 'string'
29 + && ad.port === port && ad.address === net) return
30 + await stopServer(adminSrv)
31 + adminSrv = http.createServer(adminApp.callback())
32 + const resultPort = await startServer(adminSrv, {
33 + port ,
34 + name: 'admin',
35 + net,
36 + })
37 + if (!resultPort)
38 + return
39 + if (getConfig('open_browser_at_start') !== false)
40 + open('http://localhost:' + resultPort).then()
41 + console.log('admin interface on http://localhost:' + resultPort)
42 +})
43 +
44 +subscribeConfig<string>({ k:'admin_network', defaultValue: '127.0.0.1' }, considerAdmin)
45 +subscribeConfig<number>({ k:'admin_port', defaultValue: 63636 }, considerAdmin)
46
47 subscribeConfig({ k:'cert' }, async (v: string) => {
48 await stopServer(httpsSrv)
@@ -50,20 +77,23 @@ subscribeConfig({ k:CFG_HTTPS_PORT, defaultValue: 443 }, considerHttps)
77
78 async function considerHttps() {
79 await stopServer(httpsSrv)
53 - const port = getConfig('https_port')
54 - httpsSrv = https.createServer({ key, cert }, app.callback());
55 - return await startServer(httpsSrv, !cert || !key ? -1 : port, 's')
80 + httpsSrv = https.createServer({ key, cert }, app.callback())
81 + const port = await startServer(httpsSrv, {
82 + port: !cert || !key ? -1 : getConfig('https_port'),
83 + name: 'https'
84 + })
85 + httpsSrv.on('connection', socket =>
86 + newConnection(socket, true))
87 + printUrls(port, 'https')
88 }
89
58 -function startServer(srv: http.Server, port: number, secure:string='') {
59 - return new Promise((resolve, reject) => {
90 +interface StartServer { port: number, name:string, net?:string }
91 +function startServer(srv: http.Server, { port, name, net='0.0.0.0' }: StartServer) {
92 + return new Promise<number>((resolve, reject) => {
93 try {
61 - if (port < 0) {
62 - console.log('http'+secure+' off')
63 - return resolve(null)
64 - }
65 - srv.listen(port, () => {
66 - const proto = 'http' + secure
94 + if (port < 0)
95 + return resolve(0)
96 + srv.listen(port, net, () => {
97 const ad = srv.address()
98 if (!ad)
99 return reject('no address')
@@ -71,32 +101,13 @@ function startServer(srv: http.Server, port: number, secure:string='') {
101 srv.close()
102 return reject('type of socket not supported')
103 }
74 - port = ad.port
75 - console.log(proto, `serving on port`, port)
76 -
77 - const ignore = /^(lo|.*loopback.*|virtualbox.*|.*\(wsl\).*)$/i // avoid giving too much information
78 - for (const [name, nets] of Object.entries(networkInterfaces()))
79 - if (nets && !ignore.test(name)) {
80 - console.log('network', name)
81 - for (const net of nets) {
82 - if (net.internal) continue
83 - const appendPort = port === (secure ? 443 : 80) ? '' : ':' + port
84 - let { address } = net
85 - if (address.includes(':'))
86 - address = '['+address+']'
87 - console.log('-', proto + '://' + address + appendPort)
88 - }
89 - }
90 -
91 - resolve(null)
104 + console.log(name, "serving on", net, ':', ad.port)
105 + resolve(ad.port)
106 }).on('error', e => {
107 const { code } = e as any
94 - console.error(code === 'EADDRINUSE' ? `couldn't listen on busy port ${port}` : e)
108 + console.error(code === 'EADDRINUSE' ? `couldn't listen on port ${port}` : e)
109 reject(e)
110 })
97 -
98 - srv.on('connection', socket =>
99 - newConnection(socket, Boolean(secure)))
111 }
112 catch(e) {
113 console.error("couldn't listen on port", port, String(e))
@@ -109,6 +120,9 @@ function stopServer(srv: http.Server) {
120 return new Promise(resolve => {
121 if (!srv?.listening)
122 return resolve(null)
123 + const ad = srv.address()
124 + if (ad && typeof ad !== 'string')
125 + console.log('stopped port ' + ad.port)
126 srv.close(err => {
127 if (err && (err as any).code !== 'ERR_SERVER_NOT_RUNNING')
128 console.debug('failed to stop server', String(err))
@@ -123,3 +137,21 @@ export function getStatus() {
137 httpsSrv,
138 }
139 }
140 +
141 +function printUrls(port: number, proto: string) {
142 + if (!port) return
143 + const ignore = /^(lo|.*loopback.*|virtualbox.*|.*\(wsl\).*)$/i // avoid giving too much information
144 + for (const [name, nets] of Object.entries(networkInterfaces())) {
145 + if (!nets || ignore.test(name)) continue
146 + console.log('network', name)
147 + for (const net of nets) {
148 + if (net.internal) continue
149 + const appendPort = port === (proto==='https' ? 443 : 80) ? '' : ':' + port
150 + let { address } = net
151 + if (address.includes(':'))
152 + address = '['+address+']'
153 + console.log('-', proto + '://' + address + appendPort)
154 + }
155 + }
156 +}
157 +
src/misc.ts
+17 -4
@@ -119,10 +119,10 @@ export function onlyTruthy<T>(arr: T[]) {
119
120 type PendingPromise<T> = Promise<T> & { resolve: (value: T) => void, reject: (reason?: any) => void }
121 export function pendingPromise<T>() {
122 - // @ts-ignore
123 - const ret: PendingPromise<T> = new Promise<T>((resolve, reject) =>
124 - Object.assign(ret, { resolve, reject }))
125 - return ret
122 + let takeOut
123 + const ret = new Promise<T>((resolve, reject) =>
124 + takeOut = { resolve, reject })
125 + return Object.assign(ret, takeOut) as PendingPromise<T>
126 }
127
128 // returns an 'uninstall' callback for the handlers you just installed. Pass a map {event:handler}
@@ -135,3 +135,16 @@ export function onOffMap(em: EventEmitter, events: Record<string, (...args: any[
135 em.off(k, events[k])
136 }
137 }
138 +
139 +// avoid for an async function to be overlapped with another execution while awaiting
140 +export function debounceAsync(cb: any, ms: number=100, ...args:any[]) {
141 + const debounced = _.debounce(cb, ms, ...args)
142 + let busy = false
143 + return async () => {
144 + while (busy)
145 + await wait(ms)
146 + busy = true
147 + try { return await debounced() }
148 + finally { busy = false }
149 + }
150 +}
src/serveFrontend.ts
+1 -1
@@ -63,7 +63,7 @@ function serveAdminProxy(port?: string) { // used for development
63 }
64
65 const serveAdminStatic : Koa.Middleware = async (ctx, next) => {
66 - const fullPath = 'admin' + (ctx.path.includes('.') ? ctx.path : 'index.html');
66 + const fullPath = 'admin' + (ctx.path.includes('.') ? ctx.path : '/index.html');
67 return serveFile(fullPath, 'auto')(ctx, next)
68 }
69
src/vfs.ts
+1 -1
@@ -73,7 +73,7 @@ export class Vfs {
73 }
74
75 export const vfs = new Vfs()
76 -subscribeConfig({ k: 'vfs' }, data =>
76 +subscribeConfig<VfsNode>({ k: 'vfs' }, data =>
77 vfs.root = data)
78
79 function findChildByName(name:string, node:VfsNode) {