admin/plugins
Massimo Melina committed
Apr 10, 2022 at 23:20 UTC
234edbac2685df9b7be1961240901aeb77351f75
6 files changed
+168
-38
README.md
+2
@@ -115,6 +115,8 @@ Let's first look at the things you can return:
115
116
### Things a plugin can return
117
118
+- `description: string` try to explain what this plugin is for
119
+- `version: number` use progressive numbers to distinguish each release
120
- `frontend_css: string | string[]` path to one or more css files that you want the frontend to load. These are to be placed in the `public` folder (refer below).
121
- `frontend_js: string | string[]` path to one or more js files that you want the frontend to load. These are to be placed in the `public` folder (refer below).
122
- `middleware: (Context) => void | true | function` a function that will be used as a middleware: it can interfere with http activity.
admin/src/MainMenu.ts
+3
@@ -4,6 +4,7 @@ import { createElement as h, FC } from 'react';
4
import { List, ListItemButton, ListItemIcon, ListItemText, Box, Typography } from '@mui/material'
5
import {
6
AccountTree,
7
+ Extension,
8
History,
9
Logout,
10
ManageAccounts,
@@ -22,6 +23,7 @@ import HomePage from './HomePage'
23
import LogoutPage from './LogoutPage';
24
import LogsPage from './LogsPage';
25
import { useApi } from './api'
26
+import PluginsPage from './PluginsPage';
27
28
interface MenuEntry {
29
path: string
@@ -38,6 +40,7 @@ export const mainMenu: MenuEntry[] = [
40
{ path: 'monitor', icon: Monitor, comp: MonitorPage },
41
{ path: 'configuration', icon: Settings, comp: ConfigPage },
42
{ path: 'logs', icon: History, comp: LogsPage },
43
+ { path: 'plugins', icon: Extension, comp: PluginsPage },
44
{ path: 'logout', icon: Logout, comp: LogoutPage }
45
]
46
admin/src/PluginsPage.ts
new
+51
@@ -0,0 +1,51 @@
1
+import { createElement as h } from "react"
2
+import { apiCall, useApiList } from './api'
3
+import { DataGrid } from '@mui/x-data-grid'
4
+import { Alert } from '@mui/material'
5
+import { IconBtn } from './misc'
6
+import { PowerSettingsNew } from '@mui/icons-material'
7
+
8
+export default function PluginsPage() {
9
+ const { list, error } = useApiList('get_plugins')
10
+ if (error)
11
+ return h(Alert, { severity: 'error' }, error)
12
+ return h(DataGrid, {
13
+ rows: list,
14
+ disableColumnSelector: true,
15
+ disableColumnMenu: true,
16
+ columns: [
17
+ {
18
+ field: 'id',
19
+ headerName: "name",
20
+ flex: .3,
21
+ },
22
+ {
23
+ field: 'started',
24
+ width: 180,
25
+ valueFormatter: ({ value }) => !value ? "off" : new Date(value as string).toLocaleString()
26
+ },
27
+ {
28
+ field: 'version',
29
+ width: 80,
30
+ },
31
+ {
32
+ field: 'description',
33
+ flex: 1,
34
+ },
35
+ {
36
+ field: "Actions ",
37
+ width: 80,
38
+ align: 'center',
39
+ renderCell({ row }) {
40
+ return h('div', {},
41
+ h(IconBtn, {
42
+ icon: PowerSettingsNew,
43
+ title: (row.started ? "Stop" : "Start") + ' ' + row.id,
44
+ onClick: () => apiCall('set_plugin', { id: row.id, disable: !!row.started }),
45
+ })
46
+ )
47
+ }
48
+ },
49
+ ]
50
+ })
51
+}
plugins/antibrute/plugin.js
+2
-1
@@ -1,8 +1,9 @@
1
const { API_URI } = require('@hfs/server/src/const')
2
3
exports.version = 1
4
-exports.description = `Introduce increasing delays between login attempts.`
4
+exports.description = "Introduce increasing delays between login attempts."
5
6
+// these settings will grant 4 attempts in first minute, 2 in second minute, and 1 from the third one on
7
const INCREMENT = 5_000
8
const CAP = 60_000
9
server/src/adminApis.ts
+52
-13
@@ -18,6 +18,7 @@ import { writeFile } from 'fs/promises'
18
import { createReadStream } from 'fs'
19
import * as readline from 'readline'
20
import { loggers } from './log'
21
+import { mapPlugins, getAvailablePlugins, Plugin } from './plugins'
22
23
export const adminApis: ApiHandlers = {
24
@@ -71,27 +72,20 @@ export const adminApis: ApiHandlers = {
72
},
73
74
get_connections({}, ctx) {
74
- const ret = new Readable({ objectMode: true, read(){} }) // this stream pushes uncaring for when you read. Should we do better?
75
- // start with existing connections
76
- for (const conn of getConnections())
77
- ret.push({ add: serializeConnection(conn) })
78
- // then send updates
79
- const off = onOff(events, {
80
- connection: conn => ret.push({ add: serializeConnection(conn) }),
75
+ const list = sendList( getConnections().map(c => serializeConnection(c)) )
76
+ return list.events(ctx, {
77
+ connection: conn => list.add(serializeConnection(conn)),
78
connectionClosed(conn: Connection) {
82
- ret.push({ remove: [ serializeConnection(conn, true) ] })
79
+ list.remove(serializeConnection(conn, true))
80
},
81
connectionUpdated(conn: Connection, change: Partial<Connection>) {
82
if (change.ctx) {
83
Object.assign(change, fromCtx(change.ctx))
84
delete change.ctx
85
}
89
- ret.push({ update: [{ search: serializeConnection(conn, true), change }] })
86
+ list.update(serializeConnection(conn, true), change)
87
},
88
})
92
- // we never close this stream ourselves, just when connection is closed we have to take care of listeners
93
- ctx.res.once('close', off)
94
- return ret
89
90
function serializeConnection(conn: Connection, minimal?:true) {
91
const { socket, started, secure, got } = conn
@@ -99,7 +93,7 @@ export const adminApis: ApiHandlers = {
93
v: (socket.remoteFamily?.endsWith('6') ? 6 : 4),
94
got,
95
started,
102
- secure: (secure || undefined), // undefined will save some space once json-ed
96
+ secure: (secure || undefined) as boolean|undefined, // undefined will save some space once json-ed
97
...fromCtx(conn.ctx),
98
})
99
}
@@ -148,7 +142,52 @@ export const adminApis: ApiHandlers = {
142
size: m[7] === '-' ? undefined : Number(m[7])
143
}
144
}
145
+ },
146
+
147
+ get_plugins({}, ctx) {
148
+ const list = sendList([ ...mapPlugins(serialize), ...getAvailablePlugins() ])
149
+ return list.events(ctx, {
150
+ pluginLoaded: p => list.add(serialize(p)),
151
+ pluginUnloaded: id => list.remove({ id }),
152
+ pluginAvailableNoMore: p => list.remove({ id: p.id }),
153
+ pluginAvailable: p => list.add(p),
154
+ })
155
+
156
+ function serialize(p: Readonly<Plugin>) {
157
+ return Object.assign(p.getData(), _.pick(p, ['id','started']))
158
+ }
159
+ },
160
+
161
+ async set_plugin({ id, disable }) {
162
+ if (disable !== undefined) {
163
+ const cfgK = 'disable_plugins'
164
+ const a = getConfig(cfgK)
165
+ if (a.includes(id) !== disable)
166
+ setConfig({ [cfgK]: disable ? [...a, id] : a.filter((x: string) => x !== id) })
167
+ }
168
+ return {}
169
+ },
170
+}
171
+
172
+// offer an api for a generic dynamic list
173
+function sendList<T>(addAtStart: T[]=[]) {
174
+ const stream = new Readable({ objectMode: true, read(){} })
175
+ const ret = {
176
+ return: stream,
177
+ add(rec: T) { stream.push({ add: rec }) },
178
+ remove(key: Partial<T>) { stream.push({ remove: [ key ] }) },
179
+ update(search: Partial<T>, change: Partial<T>) {
180
+ stream.push({ update:[{ search, change }] })
181
+ },
182
+ events(ctx: Koa.Context, eventMap: Parameters<typeof onOff>[1]) {
183
+ const off = onOff(events, eventMap)
184
+ ctx.res.once('close', off)
185
+ return stream
186
+ }
187
}
188
+ for (const x of addAtStart)
189
+ ret.add(x)
190
+ return ret
191
}
192
193
function getConnAddress(conn: Connection) {
server/src/plugins.ts
+58
-24
@@ -11,6 +11,8 @@ import { getConfig, subscribeConfig } from './config'
11
import { DirEntry } from './api.file_list'
12
import { VfsNode } from './vfs'
13
import { serveFile } from './serveFile'
14
+import events from './events'
15
+import { readFile } from 'fs/promises'
16
17
const PATH = 'plugins'
18
@@ -29,9 +31,9 @@ export function pluginsMiddleware(): Koa.Middleware {
31
return async (ctx, next) => {
32
const after = []
33
// run middleware plugins
32
- for (const k in plugins)
34
+ for (const id in plugins)
35
try {
34
- const pl = plugins[k]
36
+ const pl = plugins[id]
37
const res = await pl.middleware?.(ctx)
38
if (res === true)
39
ctx.pluginStopped = true
@@ -39,7 +41,7 @@ export function pluginsMiddleware(): Koa.Middleware {
41
after.push(res)
42
}
43
catch(e){
42
- console.log('error middleware plugin', k, String(e))
44
+ console.log('error middleware plugin', id, String(e))
45
console.debug(e)
46
}
47
// expose public plugins' files
@@ -70,16 +72,17 @@ subscribeConfig({ k:'disable_plugins', defaultValue:[] }, () => {
72
interface OnDirEntryParams { entry:DirEntry, ctx:Koa.Context, node:VfsNode }
73
type OnDirEntry = (params:OnDirEntryParams) => void | false
74
73
-class Plugin {
74
- js: any
75
- constructor(readonly k:string, private data:any, private unwatch:()=>void){
75
+export class Plugin {
76
+ started = new Date()
77
+
78
+ constructor(readonly id:string, private readonly data:any, private unwatch:()=>void){
79
if (!data) throw 'invalid data'
80
// if a previous instance is present, we are going to overwrite it, but first call its unload callback
78
- try { plugins[k]?.data?.unload?.() }
81
+ try { plugins[id]?.data?.unload?.() }
82
catch(e){
80
- console.debug('error unloading plugin', k, String(e))
83
+ console.debug('error unloading plugin', id, String(e))
84
}
82
- plugins[k] = this // track this
85
+ plugins[id] = this // track this
86
this.data = data = { ...data } // clone to make object modifiable. Objects coming from import are not.
87
// some validation
88
for (const k of ['frontend_css', 'frontend_js']) {
@@ -91,6 +94,7 @@ class Plugin {
94
console.warn('invalid', k)
95
}
96
}
97
+ events.emit('pluginLoaded', this)
98
}
99
get middleware(): undefined | PluginMiddleware {
100
return this.data?.middleware
@@ -105,14 +109,19 @@ class Plugin {
109
return this.data?.onDirEntry
110
}
111
112
+ getData(): any {
113
+ return { ...this.data }
114
+ }
115
+
116
unload() {
109
- console.log('unloading plugin', this.k)
117
+ console.log('unloading plugin', this.id)
118
try { this.data?.unload?.() }
119
catch(e) {
112
- console.debug('error unloading plugin', this.k, String(e))
120
+ console.debug('error unloading plugin', this.id, String(e))
121
}
114
- delete plugins[this.k]
122
+ delete plugins[this.id]
123
this.unwatch()
124
+ events.emit('pluginUnloaded', this.id)
125
}
126
}
127
@@ -120,38 +129,63 @@ type PluginMiddleware = (ctx:Koa.Context) => void | Stop | CallMeAfter
129
type Stop = true
130
type CallMeAfter = ()=>void
131
132
+let availablePlugins: Record<string, { id: string, description?: string, version?: number }> = {}
133
+
134
+export function getAvailablePlugins() {
135
+ return Object.values(availablePlugins)
136
+}
137
+
138
async function rescan() {
139
console.debug('scanning plugins')
140
const found = []
141
+ const foundDisabled: typeof availablePlugins = {}
142
const disable_plugins = wantArray(getConfig('disable_plugins'))
143
for (let f of await glob(PATH+'/*/plugin.js')) {
128
- const k = f.split('/').slice(-2)[0]
129
- if (k.endsWith('-disabled') || disable_plugins.includes(k)) continue
130
- found.push(k)
131
- if (plugins[k]) // already loaded
144
+ const id = f.split('/').slice(-2)[0]
145
+ if (id.endsWith('-disabled')) continue
146
+ if (disable_plugins.includes(id)) {
147
+ const pl = foundDisabled[id] = { id } as typeof foundDisabled[0]
148
+ try {
149
+ const source = await readFile(f, 'utf8')
150
+ pl.description = /exports.description *= *"([^"]*)"/.exec(source)?.[1]
151
+ pl.version = Number(/exports.version *= *(\d+)/.exec(source)?.[1]) || undefined
152
+ }
153
+ catch {}
154
+ continue
155
+ }
156
+ found.push(id)
157
+ if (plugins[id]) // already loaded
158
continue
159
f = resolve(f) // without this, import won't work
160
const { unwatch } = watchLoad(f, async () => {
161
try {
136
- console.log(plugins[k] ? 'reloading plugin' : 'loading plugin', k)
137
- const data = await import(f)
162
+ console.log(plugins[id] ? 'reloading plugin' : 'loading plugin', id)
163
+ const { init, ...data } = await import(f)
164
+ delete data.default
165
deleteModule(require.resolve(f)) // avoid caching
139
- const res = await data.init?.call(null, {
166
+ const res = await init?.call(null, {
167
srcDir: __dirname,
168
require,
169
getConfig: (cfgKey: string) =>
143
- getConfig('plugins_config')?.[k]?.[cfgKey]
170
+ getConfig('plugins_config')?.[id]?.[cfgKey]
171
})
172
Object.assign(data, res)
146
- new Plugin(k, data, unwatch)
173
+ new Plugin(id, data, unwatch)
174
} catch (e) {
175
console.log('plugin error:', e)
176
}
177
})
178
}
152
- for (const k in plugins)
153
- if (!found.includes(k))
154
- plugins[k].unload()
179
+ for (const id in plugins)
180
+ if (!found.includes(id))
181
+ plugins[id].unload()
182
+ for (const k in foundDisabled)
183
+ if (!availablePlugins[k])
184
+ events.emit('pluginAvailable', foundDisabled[k])
185
+ for (const k in availablePlugins)
186
+ if (!foundDisabled[k])
187
+ events.emit('pluginAvailableNoMore', availablePlugins[k])
188
+ availablePlugins = foundDisabled
189
}
190
191
function deleteModule(id: string) {