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