feat(rest-api): add endpoint to plugins (#10588)

* feat(rest-api): add endpoint to plugins

* add changelog

* review

* mathieu's review

* remove patch endpoint

---------

Co-authored-by: MathieuRA <contact@mathieu-raisin.fr>
This commit is contained in:
Joris K
2026-10-21 11:25:06 +02:00
committed by GitHub
parent 57282c0457
commit 0ebc92b50f
19 changed files with 2082 additions and 30 deletions

View File

@@ -4,5 +4,6 @@ export * from './xo.mjs'
export * from './xo-app.mjs'
export * from './lib/xen-orchestra-xapi.mjs'
export * from './lib/xen-orchestra-acl.mjs'
export * from './lib/xen-orchestra-plugins.mjs'
export * from './lib/complex-matcher.mjs'
export * from './lib/rest-api.mjs'

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import type {
XoConfigBackupArchive,
XoGroup,
XoHost,
XoPlugin,
XoPool,
XoPoolBackupArchive,
XoProxy,
@@ -347,8 +348,10 @@ export type XoApp = EventEmitter & {
deleteUser(id: XoUser['id']): Promise<void>
detachHostFromPool(hostId: XoHost['id']): Promise<void>
createGroup(params: { name: string; provider?: string; providerGroup?: string }): Promise<XoGroup>
disablePluginAutoload(id: XoPlugin['id']): Promise<void>
/* disconnect a server (XCP-ng/XenServer) */
disconnectXenServer(id: XoServer['id']): Promise<void>
enablePluginAutoload(id: XoPlugin['id']): Promise<void>
findEnabledScheduleSequenceFromSchedule(id: XoSchedule['id']): Promise<XoSchedule | undefined>
getAclV2Privilege(id: XoAclBasePrivilege['id']): Promise<XoAclBasePrivilege>
getAclV2Privileges(): Promise<XoAclBasePrivilege[]>
@@ -374,6 +377,8 @@ export type XoApp = EventEmitter & {
getAllProxies(): Promise<XoProxy[]>
getAllJobs<T extends AnyXoJob['type']>(type: T): Promise<Extract<AnyXoJob, { type: T }>[]>
getAllJobs(type?: string): Promise<AnyXoJob[]>
getPlugin(id: XoPlugin['id']): Promise<XoPlugin>
getPlugins(): Promise<XoPlugin[]>
getProxy(id: XoProxy['id']): Promise<XoProxy>
getRemote(id: XoBackupRepository['id']): Promise<XoBackupRepository>
getAllRemotes(): Promise<XoBackupRepository[]>
@@ -453,6 +458,7 @@ export type XoApp = EventEmitter & {
getXoEventEmitterByType(type: string): EventEmitter
hasFeatureAuthorization(featureCode: string): Promise<boolean>
hasObject<T extends XapiXoRecord>(id: T['id'], type: T['type']): boolean
loadPlugin(id: XoPlugin['id']): Promise<void>
listMetadataBackups(backupRepositoryIds: XoBackupRepository['id'][]): Promise<{
xo: Record<XoBackupRepository['id'], XoConfigBackupArchive[]>
pool: Record<XoBackupRepository['id'], Record<XoPool['id'], XoPoolBackupArchive[]>>
@@ -475,6 +481,7 @@ export type XoApp = EventEmitter & {
diskId: string
hostId: XoHost['id']
}): Promise<BackupArchiveDiskMount>
purgePluginConfiguration(id: XoPlugin['id']): Promise<void>
pingRemote(id: XoBackupRepository['id']): Promise<{ success: true }>
/**
* Record the live mounts a proxy created by itself, while running a restore: they never went
@@ -515,6 +522,7 @@ export type XoApp = EventEmitter & {
removeUserFromGroup(userId: XoUser['id'], id: XoGroup['id']): Promise<void>
runJob(job: AnyXoJob, schedule: XoSchedule): void
runWithApiContext: (user: XoUser | undefined, fn: () => void) => Promise<unknown>
testPlugin(id: XoPlugin['id'], data?: unknown): Promise<void>
testRemote(
id: XoBackupRepository['id']
): Promise<
@@ -524,6 +532,7 @@ export type XoApp = EventEmitter & {
touchXoObject(type: string, id: NonXapiXoRecord['id']): Promise<void>
/** Detach a disk mounted by `mountBackupArchiveDisk` and stop serving it */
unmountBackupArchiveDisk(id: BackupArchiveDiskMount['id']): Promise<void>
unloadPlugin(id: XoPlugin['id']): Promise<void>
reclaimSpace(
remoteId: XoBackupRepository['id'],
opts?: { vmUuid?: XoVm['id']; merge?: boolean; remove?: boolean }

View File

@@ -21,6 +21,29 @@ import type {
import type * as CMType from './lib/complex-matcher.mjs'
import { XoAclBasePrivilege, XoAclRole } from './lib/xen-orchestra-acl.mjs'
import type { XenApiHost, XenApiPool } from './xen-api.mjs'
import type {
XoAuditPlugin,
XoAuthGithubPlugin,
XoAuthGooglePlugin,
XoAuthLdapPlugin,
XoAuthOidcPlugin,
XoAuthSamlPlugin,
XoBackupReportsPlugin,
XoIpmiSensorsPlugin,
XoLoadBalancerPlugin,
XoNetboxPlugin,
XoOpenmetricsPlugin,
XoPerfAlertPlugin,
XoSdnControllerPlugin,
XoTestPluginPlugin,
XoTransportEmailPlugin,
XoTransportIcinga2Plugin,
XoTransportNagiosPlugin,
XoTransportSlackPlugin,
XoTransportXmppPlugin,
XoUsageReportPlugin,
XoWebHooksPlugin,
} from './lib/xen-orchestra-plugins.mjs'
type BaseXapiXo = {
$pool: XoPool['id']
@@ -460,6 +483,29 @@ export type XoPool = BaseXapiXo & {
zstdSupported: boolean
}
export type XoPlugin =
| XoAuditPlugin
| XoAuthGithubPlugin
| XoAuthGooglePlugin
| XoAuthLdapPlugin
| XoAuthOidcPlugin
| XoAuthSamlPlugin
| XoBackupReportsPlugin
| XoIpmiSensorsPlugin
| XoLoadBalancerPlugin
| XoNetboxPlugin
| XoOpenmetricsPlugin
| XoPerfAlertPlugin
| XoSdnControllerPlugin
| XoTestPluginPlugin
| XoTransportEmailPlugin
| XoTransportIcinga2Plugin
| XoTransportNagiosPlugin
| XoTransportSlackPlugin
| XoTransportXmppPlugin
| XoUsageReportPlugin
| XoWebHooksPlugin
export type XoProxy = {
id: Branded<'proxy'>
url: string
@@ -701,6 +747,7 @@ export type XoTask = {
| 'backup-log'
| 'backup-repository'
| 'group'
| 'plugin'
| 'proxy'
| 'restore'
| 'restore-log'
@@ -916,6 +963,7 @@ export type NonXapiXoRecord =
| AnyXoLog
| XoAuthenticationToken
| XoGroup
| XoPlugin
| XoProxy
| XoJob
| XoBackupRepository

View File

@@ -14,6 +14,7 @@ import { default as pbd } from './pbd.mjs'
import { default as pci } from './pci.mjs'
import { default as pgpu } from './pgpu.mjs'
import { default as pif } from './pif.mjs'
import { default as plugin } from './plugin.mjs'
import { default as pool } from './pool.mjs'
import { default as proxy } from './proxy.mjs'
import { default as restoreLog } from './restore-log.mjs'
@@ -56,6 +57,7 @@ export const SUPPORTED_ACTIONS_BY_RESOURCE = {
pci,
pgpu,
pif,
plugin,
pool,
proxy,
'restore-log': restoreLog,

View File

@@ -0,0 +1,10 @@
export default {
load: true,
read: true,
test: true,
unload: true,
update: {
autoload: true,
configuration: true,
},
}

View File

@@ -36,6 +36,7 @@
},
"dependencies": {
"@vates/async-each": "^1.0.3",
"@vates/obfuscate": "^0.1.0",
"@vates/task": "^0.7.0",
"@vates/types": "^2.3.0",
"@xen-orchestra/acl": "^1.7.0",

View File

@@ -7,9 +7,10 @@ import type { PassThrough } from 'node:stream'
import { Listener } from '../abstract-classes/listener.mjs'
import type { CollectionEventType, EventType, SubscriberId, XoListenerType } from './event.type.mjs'
import type { AnyXoJob, XoAlarm, XoAuthenticationToken, XoMessage, XoRecord, XoUser } from '@vates/types'
import type { AnyXoJob, XoAlarm, XoAuthenticationToken, XoMessage, XoPlugin, XoRecord, XoUser } from '@vates/types'
import type { AlarmService } from '../alarms/alarm.service.mjs'
import type { BackupJobService } from '../backup-jobs/backup-job.service.mjs'
import { obfuscatePluginSecrets } from '../plugins/plugin.helper.mjs'
const log = createLogger('xo:rest-api:event-service')
@@ -148,6 +149,10 @@ export class XoListener extends Listener<XoListenerType> {
_object = (object as XoAuthenticationToken | undefined)?.user_id === subscriber.userId ? object : undefined
_prevObject =
(previousObj as XoAuthenticationToken | undefined)?.user_id === subscriber.userId ? previousObj : undefined
} else if (this.type === 'plugin') {
// `as` assertions safe because we are in the `plugin` listener
_object = object === undefined ? undefined : obfuscatePluginSecrets(object as XoPlugin)
_prevObject = previousObj === undefined ? undefined : obfuscatePluginSecrets(previousObj as XoPlugin)
}
if (_object === undefined && _prevObject === undefined) {

View File

@@ -9,7 +9,8 @@ import { PassThrough } from 'node:stream'
import '../open-api/routes/routes.js'
import type { AnyPrivilege } from '@xen-orchestra/acl'
import type { XoUser, XoVmBackupArchive } from '@vates/types'
import type { XoPlugin, XoUser, XoVmBackupArchive } from '@vates/types'
import { OBFUSCATED_VALUE } from '@vates/obfuscate'
import { Subscriber, SubscriberManager, XoListener } from './event.class.mjs'
import { iocContainer } from '../ioc/ioc.mjs'
@@ -169,3 +170,30 @@ describe('XoListener on the backup-archive collection', () => {
assert.equal(frames[0].data.backupRepository, REPOSITORY_ID)
})
})
describe('XoListener on the plugin collection', () => {
it('obfuscates the secrets of the plugins', async () => {
const user = bindRestApi({ permission: 'admin' })
const { frames, subscriber } = createSubscriber(user.id)
const emitter = new EventEmitter()
const listener = new XoListener('plugin', emitter)
listener.addSubscriber(subscriber)
const plugin = (clientSecret: string) =>
({ id: 'auth-oidc', name: 'auth-oidc', configuration: { clientID: 'xo', clientSecret } }) as unknown as XoPlugin
emitter.emit('add', plugin('secret'), undefined)
emitter.emit('update', plugin('new secret'), plugin('secret'))
emitter.emit('remove', undefined, plugin('new secret'))
await flush()
assert.deepEqual(
frames.map(({ event }) => event),
['add', 'update', 'remove']
)
frames.forEach(({ data }) => {
assert.deepEqual(data.configuration, { clientID: 'xo', clientSecret: OBFUSCATED_VALUE })
})
})
})

View File

@@ -11,6 +11,7 @@ export type NonXapiListenerType =
| 'group'
| 'acl-privilege'
| 'acl-role'
| 'plugin'
| 'proxy'
| 'server'
| 'backup-repository'

View File

@@ -0,0 +1,3 @@
declare module '@vates/obfuscate' {
export const OBFUSCATED_VALUE: string
}

View File

@@ -0,0 +1,57 @@
export const pluginIds = ['/rest/v0/plugins/auth-ldap', '/rest/v0/plugins/transport-email']
export const partialPlugins = [
{
id: 'auth-ldap',
loaded: true,
href: '/rest/v0/plugins/auth-ldap',
},
{
id: 'transport-email',
loaded: false,
href: '/rest/v0/plugins/transport-email',
},
]
export const plugin = {
id: 'transport-email',
name: 'transport-email',
autoload: true,
description: 'xo-server plugin to send emails',
loaded: true,
keywords: ['email', 'orchestra', 'plugin', 'xen', 'xen-orchestra', 'xo-server'],
unloadable: true,
version: '1.2.0',
configuration: {
from: { address: 'xo@example.org' },
transport: { host: 'smtp.example.org', port: 587, user: 'xo', password: 'obfuscated-q3oi6d9X8uenGvdLnHk2' },
},
configurationSchema: {
type: 'object',
properties: {
from: {
type: 'object',
properties: { address: { type: 'string', format: 'email' } },
required: ['address'],
},
transport: {
type: 'object',
properties: {
host: { type: 'string' },
port: { type: 'integer' },
user: { type: 'string' },
password: { type: 'string' },
},
required: ['host'],
},
},
required: ['from', 'transport'],
},
configured: true,
testable: true,
testSchema: {
type: 'object',
properties: { to: { type: 'string', title: 'Recipient' } },
required: ['to'],
},
}

View File

@@ -0,0 +1,359 @@
import {
Body,
Delete,
Example,
Extension,
Get,
Middlewares,
Patch,
Path,
Post,
Put,
Query,
Request,
Response,
Route,
Security,
SuccessResponse,
Tags,
} from 'tsoa'
import { inject } from 'inversify'
import { forbiddenOperation, incorrectState } from 'xo-common/api-errors.js'
import { json, type Request as ExRequest } from 'express'
import { provide } from 'inversify-binding-decorators'
import type { XoPlugin } from '@vates/types'
import {
asynchronousActionResp,
badRequestResp,
forbiddenOperationResp,
incorrectStateResp,
invalidParameters,
noContentResp,
notFoundResp,
unauthorizedResp,
type Unbrand,
} from '../open-api/common/response.common.mjs'
import { partialPlugins, plugin, pluginIds } from '../open-api/oa-examples/plugin.oa-example.mjs'
import { taskLocation } from '../open-api/oa-examples/task.oa-example.mjs'
import type { SendObjects } from '../helpers/helper.type.mjs'
import type { PluginConfigurationRequestBody, PluginTestRequestBody, UpdatePluginRequestBody } from './plugin.type.mjs'
import { XoController } from '../abstract-classes/xo-controller.mjs'
import type { CreateActionReturnType } from '../abstract-classes/base-controller.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import { acl, actionsFromBody } from '../middlewares/acl.middleware.mjs'
import { assertNoUnknownProperties, obfuscatePluginSecrets } from './plugin.helper.mjs'
import { OBFUSCATED_VALUE } from '@vates/obfuscate'
@Route('plugins')
@Security('*')
@Response(badRequestResp.status, badRequestResp.description)
@Response(unauthorizedResp.status, unauthorizedResp.description)
@Tags('plugins')
@provide(PluginController)
export class PluginController extends XoController<XoPlugin> {
constructor(@inject(RestApi) restApi: RestApi) {
super('plugin', restApi)
}
// --- abstract methods
async getAllCollectionObjects(): Promise<XoPlugin[]> {
return (await this.restApi.xoApp.getPlugins()).map(obfuscatePluginSecrets)
}
async getCollectionObject(id: XoPlugin['id']): Promise<XoPlugin> {
return obfuscatePluginSecrets(await this.restApi.xoApp.getPlugin(id))
}
#assertState(plugin: XoPlugin, property: 'configured' | 'loaded' | 'loading', expected: boolean) {
const actual = plugin[property] === true
if (actual !== expected) {
throw incorrectState({ actual, expected, object: plugin.id, property })
}
}
/**
* Returns all plugins that match the following privilege:
* - resource: plugin, action: read
*
* @example fields "id,name,loaded,autoload"
* @example filter "loaded?"
* @example limit 42
*/
@Example(pluginIds)
@Example(partialPlugins)
@Extension('x-mcp-exposure', 'allow')
@Get('')
@Security('*', ['acl'])
async getPlugins(
@Request() req: ExRequest,
@Query() fields?: string,
@Query() ndjson?: boolean,
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoPlugin>>> {
return this.sendObjects(Object.values(await this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'plugin' },
})
}
/**
* Required privilege:
* - resource: plugin, action: read
*
* @example id "transport-email"
*/
@Example(plugin)
@Extension('x-mcp-exposure', 'allow')
@Get('{id}')
@Middlewares(
acl({
resource: 'plugin',
action: 'read',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getPlugin,
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getPlugin(@Path() id: string): Promise<Unbrand<XoPlugin>> {
return this.getObject(id as XoPlugin['id'])
}
/**
* Required privilege per field provided in the body:
* - resource: plugin, action: update:&lt;field&gt; (e.g. update:autoload)
*
* `autoload`: whether the plugin is loaded when XO starts
*
* @example id "transport-email"
* @example body { "autoload": true }
*/
@Extension('x-mcp-exposure', 'confirm')
@Patch('{id}')
@Middlewares([
json(),
acl({
resource: 'plugin',
actions: actionsFromBody(['update:autoload']),
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getPlugin,
}),
])
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async updatePlugin(@Path() id: string, @Body() body: UpdatePluginRequestBody): Promise<void> {
const pluginId = id as XoPlugin['id']
const { autoload } = body
if (autoload !== undefined) {
await (autoload
? this.restApi.xoApp.enablePluginAutoload(pluginId)
: this.restApi.xoApp.disablePluginAutoload(pluginId))
}
}
/**
* Required privilege:
* - resource: plugin, action: update:configuration
*
* Properties not declared in the `configurationSchema` are rejected.
*
* @example id "transport-email"
* @example body {
* "from": { "address": "xo@example.org" },
* "transport": { "host": "smtp.example.org", "port": 587 }
* }
*/
@Extension('x-mcp-exposure', 'confirm')
@Put('{id}/configuration')
@Middlewares([
json(),
acl({
resource: 'plugin',
action: 'update:configuration',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getPlugin,
}),
])
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(invalidParameters.status, invalidParameters.description)
async configurePlugin(@Path() id: string, @Body() body: PluginConfigurationRequestBody): Promise<void> {
const plugin = await this.getObject(id as XoPlugin['id'])
assertNoUnknownProperties(body, plugin.configurationSchema)
await this.restApi.xoApp.configurePlugin(plugin.id, body)
}
/**
* Required privilege:
* - resource: plugin, action: update:configuration
*
* Remove the plugin configuration. The plugin must be unloaded.
*
* @example id "transport-email"
*/
@Extension('x-mcp-exposure', 'confirm')
@Delete('{id}/configuration')
@Middlewares(
acl({
resource: 'plugin',
action: 'update:configuration',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getPlugin,
})
)
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(incorrectStateResp.status, 'The plugin is loaded')
async deletePluginConfiguration(@Path() id: string): Promise<void> {
const plugin = await this.getObject(id as XoPlugin['id'])
this.#assertState(plugin, 'loaded', false)
await this.restApi.xoApp.purgePluginConfiguration(plugin.id)
}
/**
* Required privilege:
* - resource: plugin, action: load
*
* @example id "transport-email"
*/
@Example(taskLocation)
@Extension('x-mcp-exposure', 'confirm')
@Post('{id}/actions/load')
@Middlewares(
acl({
resource: 'plugin',
action: 'load',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getPlugin,
})
)
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(incorrectStateResp.status, 'The plugin is already loaded, is loading or is not configured')
async loadPlugin(@Path() id: string, @Query() sync?: boolean): CreateActionReturnType<void> {
const plugin = await this.getObject(id as XoPlugin['id'])
return this.createAction<void>(
async () => {
this.#assertState(plugin, 'loaded', false)
this.#assertState(plugin, 'loading', false)
this.#assertState(plugin, 'configured', true)
await this.restApi.xoApp.loadPlugin(plugin.id)
},
{
statusCode: noContentResp.status,
sync,
taskProperties: { name: `load ${plugin.name} plugin`, objectId: plugin.id },
}
)
}
/**
* Required privilege:
* - resource: plugin, action: unload
*
* @example id "transport-email"
*/
@Example(taskLocation)
@Extension('x-mcp-exposure', 'confirm')
@Post('{id}/actions/unload')
@Middlewares(
acl({
resource: 'plugin',
action: 'unload',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getPlugin,
})
)
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(incorrectStateResp.status, 'The plugin is not loaded')
async unloadPlugin(@Path() id: string, @Query() sync?: boolean): CreateActionReturnType<void> {
const plugin = await this.getObject(id as XoPlugin['id'])
return this.createAction<void>(
async () => {
if (!plugin.unloadable) {
throw forbiddenOperation('unload plugin', 'the plugin cannot be unloaded')
}
this.#assertState(plugin, 'loaded', true)
await this.restApi.xoApp.unloadPlugin(plugin.id)
},
{
statusCode: noContentResp.status,
sync,
taskProperties: { name: `unload ${plugin.name} plugin`, objectId: plugin.id },
}
)
}
/**
* Required privilege:
* - resource: plugin, action: test
*
* Test the plugin with its current configuration. The plugin must be loaded.
*
* `data` must match the plugin `testSchema`, if any.
*
* @example id "transport-email"
* @example body { "data": { "to": "admin@example.org" } }
*/
@Example(taskLocation)
@Extension('x-mcp-exposure', 'confirm')
@Post('{id}/actions/test')
@Middlewares([
json(),
acl({
resource: 'plugin',
action: 'test',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getPlugin,
}),
])
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(incorrectStateResp.status, 'The plugin is not loaded')
@Response(invalidParameters.status, 'The data are invalid')
async testPlugin(
@Path() id: string,
@Body() body: PluginTestRequestBody,
@Query() sync?: boolean
): CreateActionReturnType<void> {
const plugin = await this.getObject(id as XoPlugin['id'])
assertNoUnknownProperties(body.data, 'testSchema' in plugin ? plugin.testSchema : undefined)
const params =
body.data !== undefined && 'password' in body.data ? { ...body.data, password: OBFUSCATED_VALUE } : body.data
return this.createAction<void>(
async () => {
if (!plugin.testable) {
throw forbiddenOperation('test plugin', 'the plugin is not testable')
}
this.#assertState(plugin, 'loaded', true)
await this.restApi.xoApp.testPlugin(plugin.id, body.data)
},
{
statusCode: noContentResp.status,
sync,
taskProperties: { name: `test ${plugin.name} plugin`, objectId: plugin.id, params },
}
)
}
}

View File

@@ -0,0 +1,94 @@
import { invalidParameters } from 'xo-common/api-errors.js'
import { OBFUSCATED_VALUE } from '@vates/obfuscate'
import type { XoPlugin } from '@vates/types'
// xo-server only obfuscates the properties named like a password or a token
const SECRETS_BY_PLUGIN: { [P in XoPlugin as P['name']]?: (keyof NonNullable<P['configuration']>)[] } = {
'auth-github': ['clientSecret'],
'auth-google': ['clientSecret'],
'auth-oidc': ['clientSecret'],
openmetrics: ['secret'],
'transport-nagios': ['key'],
'transport-slack': ['webhookUri'],
}
type JsonSchema = {
additionalProperties?: unknown
anyOf?: JsonSchema[]
items?: JsonSchema
oneOf?: JsonSchema[]
properties?: Record<string, JsonSchema>
[keyword: string]: unknown
}
type UnknownPropertyError = {
instancePath: string
keyword: 'additionalProperties'
message: string
params: { additionalProperty: string }
}
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const escapePointer = (key: string) => key.replace(/~/g, '~0').replace(/\//g, '~1')
function collectUnknownProperties(value: unknown, schema: JsonSchema, path: string, errors: UnknownPropertyError[]) {
if (Array.isArray(value)) {
const { items } = schema
if (items !== undefined) {
value.forEach((item, i) => collectUnknownProperties(item, items, `${path}/${i}`, errors))
}
return
}
const { properties } = schema
if (!isObject(value) || properties === undefined || schema.additionalProperties === true) {
return
}
const declared = new Set(Object.keys(properties))
for (const subSchema of [...(schema.anyOf ?? []), ...(schema.oneOf ?? [])]) {
Object.keys(subSchema.properties ?? {}).forEach(key => declared.add(key))
}
for (const [key, propertyValue] of Object.entries(value)) {
if (!declared.has(key)) {
errors.push({
instancePath: `${path}/${escapePointer(key)}`,
keyword: 'additionalProperties',
message: 'must NOT have additional properties',
params: { additionalProperty: key },
})
} else if (properties[key] !== undefined) {
collectUnknownProperties(propertyValue, properties[key], `${path}/${escapePointer(key)}`, errors)
}
}
}
export function assertNoUnknownProperties(value: unknown, schema: JsonSchema | undefined): void {
if (schema === undefined) {
return
}
const errors: UnknownPropertyError[] = []
collectUnknownProperties(value, schema, '', errors)
if (errors.length !== 0) {
throw invalidParameters(errors)
}
}
export function obfuscatePluginSecrets<T extends XoPlugin>(plugin: T): T {
const secrets: string[] | undefined = SECRETS_BY_PLUGIN[plugin.name]
if (secrets === undefined || plugin.configuration === undefined) {
return plugin
}
const configuration: Record<string, unknown> = { ...plugin.configuration }
for (const key of secrets) {
if (typeof configuration[key] === 'string') {
configuration[key] = OBFUSCATED_VALUE
}
}
return { ...plugin, configuration }
}

View File

@@ -0,0 +1,114 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import type { XoPlugin } from '@vates/types'
import { OBFUSCATED_VALUE } from '@vates/obfuscate'
import { assertNoUnknownProperties, obfuscatePluginSecrets } from './plugin.helper.mjs'
const sdnControllerSchema = {
type: 'object',
properties: {
'cert-dir': { type: 'string' },
'override-certs': { type: 'boolean', default: false },
},
}
const unknownPaths = (fn: () => void) => {
try {
fn()
} catch (error) {
return (error as { data: { errors: { instancePath: string }[] } }).data.errors.map(error => error.instancePath)
}
assert.fail('should have thrown')
}
describe('assertNoUnknownProperties', () => {
it('accepts the declared properties', () => {
assertNoUnknownProperties({ 'cert-dir': '/etc/xo-server/sdn', 'override-certs': true }, sdnControllerSchema)
})
it('rejects the properties of another plugin', () => {
const auditSchema = { type: 'object', properties: { active: { type: 'boolean' } } }
assert.deepEqual(
unknownPaths(() => assertNoUnknownProperties({ 'cert-dir': '/etc/xo-server/sdn' }, auditSchema)),
['/cert-dir']
)
})
it('rejects unknown nested properties', () => {
const schema = {
type: 'object',
properties: { transport: { type: 'object', properties: { host: { type: 'string' } } } },
}
assert.deepEqual(
unknownPaths(() => assertNoUnknownProperties({ transport: { host: 'smtp.example.org', foo: 'bar' } }, schema)),
['/transport/foo']
)
})
it('checks the items of arrays', () => {
const schema = {
type: 'object',
properties: {
hooks: { type: 'array', items: { type: 'object', properties: { url: { type: 'string' } } } },
},
}
assert.deepEqual(
unknownPaths(() =>
assertNoUnknownProperties({ hooks: [{ url: 'https://example.org' }, { foo: 'bar' }] }, schema)
),
['/hooks/1/foo']
)
})
it('accepts the properties declared in anyOf and oneOf', () => {
const schema = {
type: 'object',
properties: { clientID: { type: 'string' } },
anyOf: [{ properties: { discoveryURL: { type: 'string' } } }],
oneOf: [{ properties: { issuer: { type: 'string' } } }],
}
assertNoUnknownProperties({ clientID: 'xo', discoveryURL: 'https://example.org', issuer: 'xo' }, schema)
})
it('reports every unknown property', () => {
assert.deepEqual(
unknownPaths(() =>
assertNoUnknownProperties({ foo: 'bar', cert_dir: '/etc/xo-server/sdn' }, sdnControllerSchema)
),
['/foo', '/cert_dir']
)
})
})
describe('obfuscatePluginSecrets', () => {
const plugin = (name: string, configuration?: object) => ({ id: name, name, configuration }) as unknown as XoPlugin
it('obfuscates the secrets that are not named like a password or a token', () => {
assert.deepEqual(
obfuscatePluginSecrets(plugin('auth-oidc', { clientID: 'xo', clientSecret: 'secret' })).configuration,
{
clientID: 'xo',
clientSecret: OBFUSCATED_VALUE,
}
)
})
it('does not modify the plugin', () => {
const github = plugin('auth-github', { clientID: 'xo', clientSecret: 'secret' })
obfuscatePluginSecrets(github)
assert.deepEqual(github.configuration, { clientID: 'xo', clientSecret: 'secret' })
})
it('ignores the plugins without such secrets or without configuration', () => {
const sdnController = plugin('sdn-controller', { 'cert-dir': '/etc/xo-server/sdn' })
assert.equal(obfuscatePluginSecrets(sdnController), sdnController)
const openmetrics = plugin('openmetrics')
assert.equal(obfuscatePluginSecrets(openmetrics), openmetrics)
})
})

View File

@@ -0,0 +1,12 @@
import type { XoPlugin, XoPluginConfiguration, XoPluginTestData } from '@vates/types'
export type UpdatePluginRequestBody = {
autoload?: XoPlugin['autoload']
}
export type PluginConfigurationRequestBody = XoPluginConfiguration
export type PluginTestRequestBody = {
/** data matching the plugin `testSchema` */
data?: XoPluginTestData
}

View File

@@ -63,7 +63,7 @@
- [Backup] New `zstd` compression for the remotes in block mode (needs Node 22.15 or later), faster to back up and about 1.5 times faster to restore than `brotli` (PR [#10545](https://github.com/vatesfr/xen-orchestra/pull/10545))
- [Backup] Remove the unused `vhdDirectoryCompression` setting of the configuration: the compression is the one of the backup repository, brotli by default (PR [#10545](https://github.com/vatesfr/xen-orchestra/pull/10545))
- [Backup] New `zeros` compression for the remotes in block mode: only the pages full of zeroes are removed, much cheaper than a real compression (PR [#10545](https://github.com/vatesfr/xen-orchestra/pull/10545))
- [REST API] Add `/rest/v0/plugins` endpoints to list, configure, load, unload and test the plugins (PR [#10588](https://github.com/vatesfr/xen-orchestra/pull/10588))
### Bug fixes

View File

@@ -15,6 +15,7 @@ const log = createLogger('xo:xo-mixins:plugins')
export default class {
constructor(app) {
this._app = app
this._ajv = new Ajv({
strict: 'log',
// `strictRequired` option seems to require that the object's properties are defined in the same sub-schema (even
@@ -33,6 +34,13 @@ export default class {
crypto: app.cryptoCredentials,
})
app.hooks.emit('registerCollection', {
collection: this._pluginsMetadata,
type: 'plugin',
// metadata of plugins that are not registered (yet) are ignored
decorate: ({ id }) => this.getOptionalPlugin(id),
})
app.addConfigManager(
'plugins',
() => this._pluginsMetadata.get(),
@@ -87,44 +95,54 @@ export default class {
version,
})
const metadata = await this._getPluginMetadata(id)
let autoload = true
let configuration
if (metadata !== undefined) {
;({ autoload, configuration } = metadata)
} else {
log.info(`[NOTICE] register plugin ${name} for the first time`)
await this._pluginsMetadata.update({
id,
autoload,
})
}
if (!plugin.configured) {
const tryEmptyConfig = configuration === undefined
try {
await this._configurePlugin(plugin, tryEmptyConfig ? {} : configuration)
} catch (error) {
// dont throw any error in case the empty config did not work
if (tryEmptyConfig) {
return
}
throw error
try {
const metadata = await this._getPluginMetadata(id)
let autoload = true
let configuration
if (metadata !== undefined) {
;({ autoload, configuration } = metadata)
} else {
log.info(`[NOTICE] register plugin ${name} for the first time`)
await this._pluginsMetadata.update({
id,
autoload,
})
}
}
if (autoload) {
await this.loadPlugin(id)
if (!plugin.configured) {
const tryEmptyConfig = configuration === undefined
try {
await this._configurePlugin(plugin, tryEmptyConfig ? {} : configuration)
} catch (error) {
// dont throw any error in case the empty config did not work
if (tryEmptyConfig) {
return
}
throw error
}
}
if (autoload) {
await this.loadPlugin(id)
}
} finally {
await this._touchPlugin(id)
}
}
_touchPlugin(id) {
return this._app.touchXoObject('plugin', id)
}
async getPlugin(id) {
const {
configurationPresets,
configurationSchema,
configured,
description,
keywords,
loaded,
loading,
name,
testable,
testSchema,
@@ -139,12 +157,14 @@ export default class {
autoload,
description,
loaded,
loading,
keywords,
unloadable,
version,
configuration: Obfuscate.obfuscate(configuration),
configurationPresets,
configurationSchema,
configured,
testable,
testSchema,
}
@@ -235,11 +255,13 @@ export default class {
}
plugin.loading = true
await this._touchPlugin(id)
try {
await plugin.instance.load()
plugin.loaded = true
} finally {
plugin.loading = false
await this._touchPlugin(id)
}
}
@@ -255,11 +277,25 @@ export default class {
await plugin.instance.unload()
plugin.loaded = false
await this._touchPlugin(id)
}
async purgePluginConfiguration(id) {
const plugin = this._getRawPlugin(id)
await this.unloadPlugin(id).catch(noop)
await this._pluginsMetadata.merge(id, { configuration: undefined })
if (plugin.configurationSchema !== undefined) {
// like at registration, the plugin stays configured only if an empty configuration is valid
plugin.configured = false
try {
await this._configurePlugin(plugin, {})
} catch (error) {
log.info('plugin not configured after its configuration was deleted', { error, plugin: id })
}
}
await this._touchPlugin(id)
}
async testPlugin(id, data) {

View File

@@ -76,6 +76,7 @@ export default class Xo extends EventEmitter {
}
this.hooks.on('start', () => this._watchObjects())
// `decorate` can return `undefined` to ignore an object
this.hooks.on('registerCollection', async ({ collection, type, decorate = obj => obj }) => {
const cache = new Map()
const emitter = new EventEmitter()
@@ -86,7 +87,10 @@ export default class Xo extends EventEmitter {
await Promise.all(
objects.map(async object => {
// pass a copy of the object to avoid any mutation on the source object
cache.set(object.id, await decorate({ ...object }))
const obj = await decorate({ ...object })
if (obj !== undefined) {
cache.set(obj.id, obj)
}
})
)
})()
@@ -109,6 +113,13 @@ export default class Xo extends EventEmitter {
const previous = cache.get(object.id)
const newEntry = previous === undefined
const obj = await decorate({ ...object })
if (obj === undefined) {
if (!newEntry) {
cache.delete(object.id)
emitter.emit('remove', undefined, previous)
}
return
}
cache.set(obj.id, obj)
emitter.emit(newEntry ? 'add' : 'update', obj, previous)
})