mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
feat(rest-api): migrate functions to registerRestRoutes (#9895)
This commit is contained in:
@@ -24,7 +24,13 @@ function buildOpenApiField(field: FieldDefinition): OpenAPIV3.SchemaObject {
|
||||
return property
|
||||
}
|
||||
|
||||
export function buildOpenApiSchema(def: Record<string, FieldDefinition>): OpenAPIV3.SchemaObject {
|
||||
export function buildOpenApiSchema(def: Record<string, FieldDefinition> | FieldDefinition): OpenAPIV3.SchemaObject {
|
||||
// A single FieldDefinition (e.g. a top-level array response) has a string `type`,
|
||||
// whereas a fields map holds FieldDefinition objects as values.
|
||||
if (typeof def.type === 'string') {
|
||||
return buildOpenApiField(def)
|
||||
}
|
||||
|
||||
const schema: OpenAPIV3.SchemaObject = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
|
||||
@@ -19,6 +19,7 @@ export const useXoTrafficRuleDeleteJob = defineJob('traffic-rule.delete', [xoTra
|
||||
: `plugins/sdn-controller/networks/${rule.networkId}/actions/delete_traffic_rule`
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
allow: rule.allow,
|
||||
direction: rule.direction,
|
||||
ipRange: rule.ipRange,
|
||||
protocol: rule.protocol,
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
- [Web-Core/TabItem] Update the component to remove uppercase for better readability (PR [#10338](https://github.com/vatesfr/xen-orchestra/pull/10338))
|
||||
- [REST API] VDI can now be exported in qcow2 format, and the VHD export uses NBD when available. Both formats work whatever the format the disk is stored in. Every export format, raw included, now provides the size of the download (PR [#10350](https://github.com/vatesfr/xen-orchestra/pull/10350))
|
||||
- [REST API] SSE now supports Non XAPI objects (user, group, acl-privilege, acl-role, proxy, server, backup-repository, backup-job, schedule) (PR [#10278](https://github.com/vatesfr/xen-orchestra/pull/10278))
|
||||
- [REST API/SDN Controller, Audit] Traffic rule and audit record routes are now documented in the Swagger/OpenAPI spec (PR [#9895](https://github.com/vatesfr/xen-orchestra/pull/9895))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
@@ -35,6 +36,7 @@
|
||||
- [Backup] Fix `uncaught exception AssertionError: assert(!this.paused)` in the logs, when a host closes a transfer while XO is writing to a slower destination (PR [#10282](https://github.com/vatesfr/xen-orchestra/pull/10282))
|
||||
- [xo-server] If an HTTP proxy was configured, internal routes (`/openmetrics`, `/v5`) were wrongly routed through it when xo-server listened on a wildcard address. `localhost` targets are now always reached directly, bypassing the HTTP proxy, whether the configured listen address is a wildcard (`0.0.0.0`, `::`) or a specific one (PR [#10335](https://github.com/vatesfr/xen-orchestra/pull/10335))
|
||||
- [Backups] Fix slow replication startup and fallback to full on qcow2 (PR [#10333](https://github.com/vatesfr/xen-orchestra/pull/10333))
|
||||
- [REST API/SDN Controller] Fix deleting a non-existent traffic rule wrongly returning success instead of a 404 (PR [#9895](https://github.com/vatesfr/xen-orchestra/pull/9895))
|
||||
|
||||
### Packages to release
|
||||
|
||||
@@ -63,5 +65,8 @@
|
||||
- @xen-orchestra/xapi patch
|
||||
- xen-api major
|
||||
- xo-server minor
|
||||
- xo-server-audit patch
|
||||
- xo-server-sdn-controller patch
|
||||
- xo-web minor
|
||||
|
||||
<!--packages-end-->
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as CM from 'complex-matcher'
|
||||
import asyncIteratorToStream from 'async-iterator-to-stream'
|
||||
import ndjson from 'ndjson'
|
||||
import { alteredAuditRecord, missingAuditRecord } from 'xo-common/api-errors'
|
||||
@@ -11,6 +10,8 @@ import { fromCallback } from 'promise-toolbox'
|
||||
import { AlteredRecordError, AuditCore, MissingRecordError, NULL_ID, Storage } from '@xen-orchestra/audit-core'
|
||||
import { PassThrough, pipeline, promises } from 'readable-stream'
|
||||
|
||||
import { createRestRoutes } from './rest-api.js'
|
||||
|
||||
const log = createLogger('xo:xo-server-audit')
|
||||
|
||||
const DEFAULT_BLOCKED_LIST = {
|
||||
@@ -306,40 +307,7 @@ class AuditXoPlugin {
|
||||
})
|
||||
)
|
||||
|
||||
cleaners.push(
|
||||
this._xo.registerRestApi(
|
||||
{
|
||||
records: {
|
||||
':id': {
|
||||
_get: async (req, _, next) => {
|
||||
const record = await this._auditCore.get(req.params.id)
|
||||
if (record !== undefined) {
|
||||
return record
|
||||
}
|
||||
next()
|
||||
},
|
||||
},
|
||||
|
||||
_get: async function* ({ query }) {
|
||||
const limit = query.limit === undefined ? Infinity : +query.limit
|
||||
const filter = query.filter === undefined ? () => true : CM.parse(query.filter).createPredicate()
|
||||
|
||||
let i = 0
|
||||
for await (const record of this._auditCore.getFrom(query.from)) {
|
||||
if (++i > limit) {
|
||||
break
|
||||
}
|
||||
|
||||
if (filter(record)) {
|
||||
yield record
|
||||
}
|
||||
}
|
||||
}.bind(this),
|
||||
},
|
||||
},
|
||||
'/plugins/audit'
|
||||
)
|
||||
)
|
||||
cleaners.push(this._xo.registerRestRoutes(createRestRoutes(this), '/plugins/audit'))
|
||||
}
|
||||
|
||||
unload() {
|
||||
|
||||
103
packages/xo-server-audit/src/rest-api.js
Normal file
103
packages/xo-server-audit/src/rest-api.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as CM from 'complex-matcher'
|
||||
import { noSuchObject } from 'xo-common/api-errors.js'
|
||||
|
||||
const RECORD_FIELDS = {
|
||||
data: {
|
||||
type: 'object',
|
||||
fields: {
|
||||
method: { type: 'string', example: 'vm.start' },
|
||||
params: { type: 'object', fields: {} },
|
||||
},
|
||||
},
|
||||
event: { type: 'string', example: 'apiCall' },
|
||||
id: { type: 'string', example: 'FYUYLGWX0uUv6uKyDpc6ftAJhrEB7iuf/BYefEbBLYc=' },
|
||||
previousId: { type: 'string', example: 'ArU8U8Ck2u4dBBHc3wj0/Ht+SsyU8YKb5RfnFqBLZ2Y=' },
|
||||
subject: {
|
||||
type: 'object',
|
||||
fields: {
|
||||
userId: { type: 'string', example: '2f8f6c95-2fb7-4b5c-9b6f-8f4e7b6a1c3d' },
|
||||
userIp: { type: 'string', example: '192.168.0.1' },
|
||||
userName: { type: 'string', example: 'admin@admin.net' },
|
||||
},
|
||||
},
|
||||
time: { type: 'number', example: 1699000000000 },
|
||||
}
|
||||
const RECORD_FIELDS_ARRAY = {
|
||||
type: 'array',
|
||||
items: { type: 'object', fields: RECORD_FIELDS },
|
||||
}
|
||||
|
||||
function makeRecordMapper(req) {
|
||||
const href = record => `${req.baseUrl}${req.path}/${record.id}`
|
||||
const { fields } = req.query
|
||||
|
||||
if (fields === '*') {
|
||||
return record => ({ ...record, href: href(record) })
|
||||
}
|
||||
if (typeof fields === 'string') {
|
||||
const keys = fields.split(',')
|
||||
return record => {
|
||||
const picked = {}
|
||||
for (const key of keys) {
|
||||
if (key in record) picked[key] = record[key]
|
||||
}
|
||||
return { ...picked, href: href(record) }
|
||||
}
|
||||
}
|
||||
return href
|
||||
}
|
||||
|
||||
export function createRestRoutes(plugin) {
|
||||
return [
|
||||
{
|
||||
endpoint: '/records',
|
||||
method: 'get',
|
||||
description: 'List audit records, newest first',
|
||||
tags: ['audit'],
|
||||
query: {
|
||||
from: { type: 'string', optional: true, example: 'FYUYLGWX0uUv6uKyDpc6ftAJhrEB7iuf/BYefEbBLYc=' },
|
||||
limit: { type: 'number', optional: true, example: 100 },
|
||||
filter: { type: 'string', optional: true, example: 'event:apiCall' },
|
||||
fields: { type: 'string', optional: true, example: '*' },
|
||||
},
|
||||
responses: [{ status: 200, description: 'The list of audit records', schema: RECORD_FIELDS_ARRAY }],
|
||||
callback: async function* ({ req }) {
|
||||
const { query } = req
|
||||
const limit = query.limit === undefined ? Infinity : +query.limit
|
||||
const filter = query.filter === undefined ? () => true : CM.parse(query.filter).createPredicate()
|
||||
const mapRecord = makeRecordMapper(req)
|
||||
|
||||
let i = 0
|
||||
for await (const record of plugin._auditCore.getFrom(query.from)) {
|
||||
if (++i > limit) {
|
||||
break
|
||||
}
|
||||
|
||||
if (filter(record)) {
|
||||
yield mapRecord(record)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
endpoint: '/records/{id}',
|
||||
method: 'get',
|
||||
description: 'Get a single audit record by its ID',
|
||||
tags: ['audit'],
|
||||
params: {
|
||||
id: { type: 'string', example: 'FYUYLGWX0uUv6uKyDpc6ftAJhrEB7iuf/BYefEbBLYc=' },
|
||||
},
|
||||
responses: [
|
||||
{ status: 200, description: 'The audit record', schema: RECORD_FIELDS },
|
||||
{ status: 404, description: 'No record found for this ID' },
|
||||
],
|
||||
callback: async ({ req }) => {
|
||||
const record = await plugin._auditCore.get(req.params.id)
|
||||
if (record === undefined) {
|
||||
throw noSuchObject(req.params.id, 'audit-record')
|
||||
}
|
||||
return record
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -14,8 +14,8 @@ import { PrivateNetwork } from './private-network/private-network'
|
||||
import { TlsHelper } from './utils/tls-helper'
|
||||
import { instantiateController } from './openflow-controller'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { invalidParameters, noSuchObject } from 'xo-common/api-errors.js'
|
||||
import { SDN_CONTROLLER_OF_RULES_KEY } from '@vates/types'
|
||||
import { createRestRoutes } from './rest-api'
|
||||
|
||||
// =============================================================================
|
||||
|
||||
const log = createLogger('xo:xo-server-sdn-controller')
|
||||
@@ -255,41 +255,6 @@ function isControllerNeeded(xapi) {
|
||||
return !(controller?.protocol === PROTOCOL && controller.address === '' && controller.port === 0)
|
||||
}
|
||||
|
||||
function validateRuleFields(obj, errors, prefix = '') {
|
||||
if (!obj.direction || typeof obj.direction !== 'string') {
|
||||
errors.push(`${prefix}direction is required and must be a string`)
|
||||
}
|
||||
if (!obj.ipRange || typeof obj.ipRange !== 'string') {
|
||||
errors.push(`${prefix}ipRange is required and must be a string`)
|
||||
}
|
||||
if (!obj.protocol || typeof obj.protocol !== 'string') {
|
||||
errors.push(`${prefix}protocol is required and must be a string`)
|
||||
}
|
||||
if (obj.port != null && !Number.isInteger(obj.port)) {
|
||||
errors.push(`${prefix}port must be an integer`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateRuleWithAllow(obj, errors, prefix = '') {
|
||||
if (obj.allow === undefined || typeof obj.allow !== 'boolean') {
|
||||
errors.push(`${prefix}allow is required and must be a boolean`)
|
||||
}
|
||||
validateRuleFields(obj, errors, prefix)
|
||||
}
|
||||
|
||||
// Apply a partial update on a rule: a `null` value removes the field
|
||||
function applyRulePatch(oldRule, patch) {
|
||||
const rule = { ...oldRule }
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete rule[key]
|
||||
} else if (value !== undefined) {
|
||||
rule[key] = value
|
||||
}
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
class SDNController extends EventEmitter {
|
||||
@@ -477,334 +442,8 @@ class SDNController extends EventEmitter {
|
||||
},
|
||||
})
|
||||
|
||||
const createAction = async (res, callback, { sync = false, statusCode = 200, taskProperties }) => {
|
||||
taskProperties.name = 'REST API: ' + taskProperties.name
|
||||
taskProperties.type = 'xo:rest-api:action'
|
||||
this._cleaners.push(this._xo.registerRestRoutes(createRestRoutes(this), '/plugins/sdn-controller'))
|
||||
|
||||
const task = this._xo.tasks.create(taskProperties)
|
||||
const pResult = task.run(callback)
|
||||
|
||||
if (sync) {
|
||||
const result = await pResult
|
||||
res.status(statusCode)
|
||||
return result ?? {}
|
||||
} else {
|
||||
pResult.catch(() => {})
|
||||
res.status(202).set('Location', `/rest/v0/tasks/${task.id}`).json({ taskId: task.id })
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
this._xo.registerRestApi(
|
||||
{
|
||||
networks: {
|
||||
':id/actions/add_traffic_rule': {
|
||||
_post: async (req, res, next) => {
|
||||
const validationErrors = []
|
||||
validateRuleWithAllow(req.body, validationErrors)
|
||||
if (validationErrors.length > 0) {
|
||||
throw invalidParameters(validationErrors)
|
||||
}
|
||||
|
||||
const rule = {
|
||||
allow: req.body.allow,
|
||||
direction: req.body.direction,
|
||||
ipRange: req.body.ipRange,
|
||||
protocol: req.body.protocol,
|
||||
networkId: req.params.id,
|
||||
}
|
||||
|
||||
if (req.body.port != null) {
|
||||
rule.port = req.body.port
|
||||
}
|
||||
|
||||
return createAction(res, () => this._addNetworkRule(rule), {
|
||||
sync: req.query.sync ?? false,
|
||||
statusCode: 204,
|
||||
taskProperties: {
|
||||
name: 'add network traffic rule',
|
||||
objectId: rule.networkId,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
':id/actions/delete_traffic_rule': {
|
||||
_post: async (req, res, next) => {
|
||||
const validationErrors = []
|
||||
validateRuleFields(req.body, validationErrors)
|
||||
if (validationErrors.length > 0) {
|
||||
throw invalidParameters(validationErrors)
|
||||
}
|
||||
|
||||
const rule = {
|
||||
direction: req.body.direction,
|
||||
ipRange: req.body.ipRange,
|
||||
protocol: req.body.protocol,
|
||||
networkId: req.params.id,
|
||||
}
|
||||
|
||||
if (req.body.port != null) {
|
||||
rule.port = req.body.port
|
||||
}
|
||||
|
||||
return createAction(res, () => this._deleteNetworkOfRule(rule), {
|
||||
sync: req.query.sync ?? false,
|
||||
statusCode: 204,
|
||||
taskProperties: {
|
||||
name: 'delete network traffic rule',
|
||||
objectId: rule.networkId,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
vifs: {
|
||||
':id/actions/add_traffic_rule': {
|
||||
_post: async (req, res, next) => {
|
||||
const validationErrors = []
|
||||
validateRuleWithAllow(req.body ?? {}, validationErrors)
|
||||
if (validationErrors.length > 0) {
|
||||
throw invalidParameters(validationErrors)
|
||||
}
|
||||
|
||||
const rule = {
|
||||
allow: req.body.allow,
|
||||
direction: req.body.direction,
|
||||
ipRange: req.body.ipRange,
|
||||
protocol: req.body.protocol,
|
||||
vifId: req.params.id,
|
||||
}
|
||||
|
||||
if (req.body.port != null) {
|
||||
rule.port = req.body.port
|
||||
}
|
||||
|
||||
return createAction(res, () => this._addRule(rule), {
|
||||
sync: req.query.sync ?? false,
|
||||
statusCode: 204,
|
||||
taskProperties: {
|
||||
name: 'add vif traffic rule',
|
||||
objectId: rule.vifId,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
':id/actions/delete_traffic_rule': {
|
||||
_post: async (req, res, next) => {
|
||||
const validationErrors = []
|
||||
validateRuleFields(req.body, validationErrors)
|
||||
if (validationErrors.length > 0) {
|
||||
throw invalidParameters(validationErrors)
|
||||
}
|
||||
|
||||
const rule = {
|
||||
direction: req.body.direction,
|
||||
ipRange: req.body.ipRange,
|
||||
protocol: req.body.protocol,
|
||||
vifId: req.params.id,
|
||||
}
|
||||
|
||||
if (req.body.port != null) {
|
||||
rule.port = req.body.port
|
||||
}
|
||||
|
||||
return createAction(res, () => this._deleteRule(rule), {
|
||||
sync: req.query.sync ?? false,
|
||||
statusCode: 204,
|
||||
taskProperties: {
|
||||
name: 'delete vif traffic rule',
|
||||
objectId: rule.vifId,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/plugins/sdn-controller'
|
||||
)
|
||||
this._cleaners.push(
|
||||
this._xo.registerRestRoutes(
|
||||
[
|
||||
{
|
||||
endpoint: '/networks/{id}/actions/update_traffic_rule',
|
||||
description:
|
||||
'Update a rule on a network: `oldRule` identifies the rule to update and must be given in full, `newRule` is a partial update where a field set to `null` is removed from the rule.\n\nRequired privilege:\n - resource: network, action: update:other_config',
|
||||
|
||||
method: 'post',
|
||||
tags: ['sdn-controller'],
|
||||
params: {
|
||||
id: { type: 'string', example: 'b97f4e69-d275-4b25-9dc9-c1ac4e9b3fa5' },
|
||||
},
|
||||
query: {
|
||||
sync: { type: 'boolean', optional: true },
|
||||
},
|
||||
body: {
|
||||
oldRule: {
|
||||
type: 'object',
|
||||
fields: {
|
||||
allow: { type: 'boolean', example: true },
|
||||
direction: { type: 'string', example: 'to' },
|
||||
ipRange: { type: 'string', example: '192.168.0.0/24' },
|
||||
protocol: { type: 'string', example: 'tcp' },
|
||||
port: { type: 'number', example: 443, optional: true },
|
||||
},
|
||||
},
|
||||
newRule: {
|
||||
type: 'object',
|
||||
fields: {
|
||||
allow: { type: 'boolean', example: true, optional: true },
|
||||
direction: { type: 'string', example: 'to', optional: true },
|
||||
ipRange: { type: 'string', example: '10.0.0.0/8', optional: true },
|
||||
protocol: { type: 'string', example: 'tcp', optional: true },
|
||||
port: { type: 'number', example: 80, optional: true, nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: [
|
||||
{
|
||||
status: 204,
|
||||
description: 'Rule updated successfully',
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: 'Old rule does not exist on this network',
|
||||
},
|
||||
],
|
||||
middlewares: [
|
||||
{ name: 'json' },
|
||||
{ name: 'acl', acls: { resource: 'network', action: 'update:other_config', objectId: 'params.id' } },
|
||||
],
|
||||
callback: ({ req, createAction }) => {
|
||||
return createAction(
|
||||
async () => {
|
||||
const { oldRule, newRule: partialNewRule } = req.body
|
||||
const networkId = req.params.id
|
||||
const network = this._xo.getObject(networkId, 'network')
|
||||
const networkRules = JSON.parse(network.other_config[SDN_CONTROLLER_OF_RULES_KEY] || '[]').map(
|
||||
JSON.parse
|
||||
)
|
||||
if (
|
||||
!networkRules.some(
|
||||
rule =>
|
||||
rule.allow === oldRule.allow &&
|
||||
rule.direction === oldRule.direction &&
|
||||
rule.ipRange === oldRule.ipRange &&
|
||||
rule.port === oldRule.port &&
|
||||
rule.protocol.toLowerCase() === oldRule.protocol.toLowerCase()
|
||||
)
|
||||
) {
|
||||
throw noSuchObject(JSON.stringify(oldRule), 'traffic-rule')
|
||||
}
|
||||
const newRule = applyRulePatch(oldRule, partialNewRule)
|
||||
|
||||
await this._deleteNetworkOfRule({ ...oldRule, networkId })
|
||||
await this._addNetworkRule({ ...newRule, networkId })
|
||||
},
|
||||
{
|
||||
sync: req.query.sync ?? false,
|
||||
statusCode: 204,
|
||||
taskProperties: {
|
||||
name: 'update network traffic rule',
|
||||
objectId: req.params.id,
|
||||
params: req.body,
|
||||
objectType: 'network',
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
endpoint: '/vifs/{id}/actions/update_traffic_rule',
|
||||
description:
|
||||
'Update a rule on a VIF: `oldRule` identifies the rule to update and must be given in full, `newRule` is a partial update where a field set to `null` is removed from the rule.\n\nRequired privilege:\n - resource: vif, action: update:other_config',
|
||||
method: 'post',
|
||||
tags: ['sdn-controller'],
|
||||
params: {
|
||||
id: { type: 'string', example: 'b97f4e69-d275-4b25-9dc9-c1ac4e9b3fa5' },
|
||||
},
|
||||
query: {
|
||||
sync: { type: 'boolean', optional: true },
|
||||
},
|
||||
body: {
|
||||
oldRule: {
|
||||
type: 'object',
|
||||
fields: {
|
||||
allow: { type: 'boolean', example: true },
|
||||
direction: { type: 'string', example: 'from' },
|
||||
ipRange: { type: 'string', example: '192.168.0.0/24' },
|
||||
protocol: { type: 'string', example: 'tcp' },
|
||||
port: { type: 'number', example: 443, optional: true },
|
||||
},
|
||||
},
|
||||
newRule: {
|
||||
type: 'object',
|
||||
fields: {
|
||||
allow: { type: 'boolean', example: true, optional: true },
|
||||
direction: { type: 'string', example: 'to', optional: true },
|
||||
ipRange: { type: 'string', example: '10.0.0.0/8', optional: true },
|
||||
protocol: { type: 'string', example: 'tcp', optional: true },
|
||||
port: { type: 'number', example: 80, optional: true, nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
responses: [
|
||||
{
|
||||
status: 204,
|
||||
description: 'Rule updated successfully',
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: 'Old rule does not exist on this VIF',
|
||||
},
|
||||
],
|
||||
middlewares: [
|
||||
{ name: 'json' },
|
||||
{ name: 'acl', acls: { resource: 'vif', action: 'update:other_config', objectId: 'params.id' } },
|
||||
],
|
||||
callback: ({ req, createAction }) => {
|
||||
return createAction(
|
||||
async () => {
|
||||
const { oldRule, newRule: partialNewRule } = req.body
|
||||
const vifId = req.params.id
|
||||
const vif = this._xo.getObject(vifId, 'VIF')
|
||||
const rawVifRules = vif.other_config[SDN_CONTROLLER_OF_RULES_KEY]
|
||||
const vifRules = rawVifRules !== undefined ? JSON.parse(rawVifRules).map(JSON.parse) : []
|
||||
if (
|
||||
!vifRules.some(
|
||||
rule =>
|
||||
rule.allow === oldRule.allow &&
|
||||
rule.direction === oldRule.direction &&
|
||||
rule.ipRange === oldRule.ipRange &&
|
||||
rule.port === oldRule.port &&
|
||||
rule.protocol.toLowerCase() === oldRule.protocol.toLowerCase()
|
||||
)
|
||||
) {
|
||||
throw noSuchObject(JSON.stringify(oldRule), 'traffic-rule')
|
||||
}
|
||||
|
||||
const newRule = applyRulePatch(oldRule, partialNewRule)
|
||||
|
||||
await this._deleteRule({ ...oldRule, vifId })
|
||||
await this._addRule({ ...newRule, vifId })
|
||||
},
|
||||
{
|
||||
sync: req.query.sync ?? false,
|
||||
statusCode: 204,
|
||||
taskProperties: {
|
||||
name: 'update vif traffic rule',
|
||||
objectId: req.params.id,
|
||||
params: req.body,
|
||||
objectType: 'VIF',
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
'/plugins/sdn-controller'
|
||||
)
|
||||
)
|
||||
forOwn(this._xo.getAllXapis(), xapi => {
|
||||
if (xapi.status === 'connected') {
|
||||
this._handleConnectedXapi(xapi)
|
||||
|
||||
217
packages/xo-server-sdn-controller/src/rest-api.js
Normal file
217
packages/xo-server-sdn-controller/src/rest-api.js
Normal file
@@ -0,0 +1,217 @@
|
||||
import { flatMap } from 'lodash'
|
||||
import { noSuchObject } from 'xo-common/api-errors.js'
|
||||
import { SDN_CONTROLLER_OF_RULES_KEY } from '@vates/types'
|
||||
|
||||
const QUERY_SYNC = { sync: { type: 'boolean', optional: true } }
|
||||
|
||||
const PARAMS_ID = { id: { type: 'string', example: 'b97f4e69-d275-4b25-9dc9-c1ac4e9b3fa5' } }
|
||||
|
||||
const RULE_FIELDS = {
|
||||
allow: { type: 'boolean', example: true },
|
||||
direction: { type: 'string', example: 'to' },
|
||||
ipRange: { type: 'string', example: '192.168.0.0/24' },
|
||||
protocol: { type: 'string', example: 'tcp' },
|
||||
port: { type: 'number', example: 443, optional: true },
|
||||
}
|
||||
|
||||
const BODY_UPDATE_RULE = {
|
||||
oldRule: { type: 'object', fields: RULE_FIELDS },
|
||||
newRule: {
|
||||
type: 'object',
|
||||
fields: {
|
||||
allow: { type: 'boolean', example: true, optional: true },
|
||||
direction: { type: 'string', example: 'to', optional: true },
|
||||
ipRange: { type: 'string', example: '10.0.0.0/8', optional: true },
|
||||
protocol: { type: 'string', example: 'tcp', optional: true },
|
||||
port: { type: 'number', example: 80, optional: true, nullable: true },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// network and vif traffic-rule routes only differ by these tokens
|
||||
const RESOURCES = [
|
||||
{
|
||||
collection: 'networks',
|
||||
acl: 'network',
|
||||
type: 'network',
|
||||
idKey: 'networkId',
|
||||
addRule: (controller, rule) => controller._addNetworkRule(rule),
|
||||
deleteRule: (controller, rule) => controller._deleteNetworkOfRule(rule),
|
||||
},
|
||||
{
|
||||
collection: 'vifs',
|
||||
acl: 'vif',
|
||||
type: 'VIF',
|
||||
idKey: 'vifId',
|
||||
addRule: (controller, rule) => controller._addRule(rule),
|
||||
deleteRule: (controller, rule) => controller._deleteRule(rule),
|
||||
},
|
||||
]
|
||||
|
||||
function jsonAndAcl(resource) {
|
||||
return [{ name: 'json' }, { name: 'acl', acls: { resource, action: 'update:other_config', objectId: 'params.id' } }]
|
||||
}
|
||||
|
||||
function parseRules(raw) {
|
||||
return raw == null ? [] : JSON.parse(raw).map(JSON.parse)
|
||||
}
|
||||
|
||||
function rulesEqual(a, b) {
|
||||
return (
|
||||
a.allow === b.allow &&
|
||||
a.direction === b.direction &&
|
||||
a.ipRange === b.ipRange &&
|
||||
a.port === b.port &&
|
||||
a.protocol.toLowerCase() === b.protocol.toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
// Apply a partial update on a rule: a `null` value removes the field
|
||||
function applyRulePatch(oldRule, patch) {
|
||||
const rule = { ...oldRule }
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete rule[key]
|
||||
} else if (value !== undefined) {
|
||||
rule[key] = value
|
||||
}
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
function ruleFromBody(req, idKey) {
|
||||
const rule = {
|
||||
allow: req.body.allow,
|
||||
direction: req.body.direction,
|
||||
ipRange: req.body.ipRange,
|
||||
protocol: req.body.protocol,
|
||||
[idKey]: req.params.id,
|
||||
}
|
||||
if (req.body.port != null) {
|
||||
rule.port = req.body.port
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
function addRuleRoute(controller, resource) {
|
||||
return {
|
||||
endpoint: `/${resource.collection}/{id}/actions/add_traffic_rule`,
|
||||
description: `Add a traffic rule to a ${resource.type}.\n\nRequired privilege:\n - resource: ${resource.acl}, action: update:other_config`,
|
||||
method: 'post',
|
||||
tags: ['sdn-controller'],
|
||||
params: PARAMS_ID,
|
||||
query: QUERY_SYNC,
|
||||
body: RULE_FIELDS,
|
||||
responses: [
|
||||
{ status: 204, description: 'Rule added successfully' },
|
||||
{ status: 404, description: `No ${resource.type} found for this ID` },
|
||||
],
|
||||
middlewares: jsonAndAcl(resource.acl),
|
||||
callback: ({ req, createAction }) => {
|
||||
const rule = ruleFromBody(req, resource.idKey)
|
||||
return createAction(() => resource.addRule(controller, rule), {
|
||||
sync: req.query.sync ?? false,
|
||||
statusCode: 204,
|
||||
taskProperties: {
|
||||
name: `add ${resource.acl} traffic rule`,
|
||||
objectId: rule[resource.idKey],
|
||||
objectType: resource.type,
|
||||
params: req.body,
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function deleteRuleRoute(controller, resource) {
|
||||
return {
|
||||
endpoint: `/${resource.collection}/{id}/actions/delete_traffic_rule`,
|
||||
description: `Delete a traffic rule from a ${resource.type}.\n\nRequired privilege:\n - resource: ${resource.acl}, action: update:other_config`,
|
||||
method: 'post',
|
||||
tags: ['sdn-controller'],
|
||||
params: PARAMS_ID,
|
||||
query: QUERY_SYNC,
|
||||
body: RULE_FIELDS,
|
||||
responses: [
|
||||
{ status: 204, description: 'Rule deleted successfully' },
|
||||
{ status: 404, description: `No ${resource.type} found for this ID, or rule not found` },
|
||||
],
|
||||
middlewares: jsonAndAcl(resource.acl),
|
||||
callback: ({ req, createAction }) => {
|
||||
const rule = ruleFromBody(req, resource.idKey)
|
||||
const id = req.params.id
|
||||
return createAction(
|
||||
async () => {
|
||||
const object = controller._xo.getObject(id, resource.type)
|
||||
const rules = parseRules(object.other_config[SDN_CONTROLLER_OF_RULES_KEY])
|
||||
if (!rules.some(r => rulesEqual(r, rule))) {
|
||||
throw noSuchObject(JSON.stringify(rule), 'traffic-rule')
|
||||
}
|
||||
await resource.deleteRule(controller, rule)
|
||||
},
|
||||
{
|
||||
sync: req.query.sync ?? false,
|
||||
statusCode: 204,
|
||||
taskProperties: {
|
||||
name: `delete ${resource.acl} traffic rule`,
|
||||
objectId: rule[resource.idKey],
|
||||
objectType: resource.type,
|
||||
params: req.body,
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function updateRuleRoute(controller, resource) {
|
||||
return {
|
||||
endpoint: `/${resource.collection}/{id}/actions/update_traffic_rule`,
|
||||
description: `Update a rule on a ${resource.type}: \`oldRule\` identifies the rule to update and must be given in full, \`newRule\` is a partial update where a field set to \`null\` is removed from the rule.\n\nRequired privilege:\n - resource: ${resource.acl}, action: update:other_config`,
|
||||
method: 'post',
|
||||
tags: ['sdn-controller'],
|
||||
params: PARAMS_ID,
|
||||
query: QUERY_SYNC,
|
||||
body: BODY_UPDATE_RULE,
|
||||
responses: [
|
||||
{ status: 204, description: 'Rule updated successfully' },
|
||||
{ status: 404, description: `Old rule does not exist on this ${resource.type}` },
|
||||
],
|
||||
middlewares: jsonAndAcl(resource.acl),
|
||||
callback: ({ req, createAction }) => {
|
||||
const { oldRule, newRule: partialNewRule } = req.body
|
||||
const id = req.params.id
|
||||
return createAction(
|
||||
async () => {
|
||||
const object = controller._xo.getObject(id, resource.type)
|
||||
const rules = parseRules(object.other_config[SDN_CONTROLLER_OF_RULES_KEY])
|
||||
if (!rules.some(rule => rulesEqual(rule, oldRule))) {
|
||||
throw noSuchObject(JSON.stringify(oldRule), 'traffic-rule')
|
||||
}
|
||||
const newRule = applyRulePatch(oldRule, partialNewRule)
|
||||
|
||||
await resource.deleteRule(controller, { ...oldRule, [resource.idKey]: id })
|
||||
await resource.addRule(controller, { ...newRule, [resource.idKey]: id })
|
||||
},
|
||||
{
|
||||
sync: req.query.sync ?? false,
|
||||
statusCode: 204,
|
||||
taskProperties: {
|
||||
name: `update ${resource.acl} traffic rule`,
|
||||
objectId: id,
|
||||
params: req.body,
|
||||
objectType: resource.type,
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createRestRoutes(controller) {
|
||||
return flatMap(RESOURCES, r => [
|
||||
addRuleRoute(controller, r),
|
||||
deleteRuleRoute(controller, r),
|
||||
updateRuleRoute(controller, r),
|
||||
])
|
||||
}
|
||||
Reference in New Issue
Block a user