From 2a74a499955489d58fbf80e98fdf39e0c293014b Mon Sep 17 00:00:00 2001 From: BenjiReis Date: Wed, 29 Jul 2020 09:56:06 +0200 Subject: [PATCH] feat(sdn-controller, xo-web): add & remove network rules to a VM's VIFs (#5177) --- @xen-orchestra/openflow/.babelrc.js | 3 + @xen-orchestra/openflow/.npmignore | 24 + @xen-orchestra/openflow/README.md | 139 ++ @xen-orchestra/openflow/USAGE.md | 108 ++ @xen-orchestra/openflow/package.json | 35 + @xen-orchestra/openflow/parse-socket.js | 1 + .../openflow/src/default-header-scheme.js | 9 + @xen-orchestra/openflow/src/index.js | 38 + .../openflow/src/openflow-11/action/action.js | 58 + .../openflow/src/openflow-11/action/output.js | 45 + .../openflow/src/openflow-11/index.js | 49 + .../src/openflow-11/instruction/actions.js | 102 ++ .../openflow-11/instruction/instruction.js | 43 + .../openflow/src/openflow-11/message/echo.js | 46 + .../openflow/src/openflow-11/message/error.js | 79 ++ .../src/openflow-11/message/features-reply.js | 73 + .../openflow-11/message/features-request.js | 24 + .../src/openflow-11/message/flow-mod.js | 197 +++ .../openflow-11/message/get-config-request.js | 24 + .../src/openflow-11/message/header.js | 39 + .../openflow/src/openflow-11/message/hello.js | 27 + .../src/openflow-11/message/switch-config.js | 38 + .../openflow/src/openflow-11/openflow-11.js | 1170 +++++++++++++++++ .../src/openflow-11/struct/match/match.js | 374 ++++++ .../openflow/src/openflow-11/struct/port.js | 79 ++ @xen-orchestra/openflow/src/parse-socket.js | 45 + .../openflow/src/util/addrress-parser.js | 64 + .../openflow/src/util/get-from-map.js | 11 + .../openflow/src/util/uint-helper.js | 10 + @xen-orchestra/openflow/src/version.js | 9 + CHANGELOG.unreleased.md | 3 + docs/assets/add-rule.png | Bin 0 -> 35433 bytes docs/assets/show-rules.png | Bin 0 -> 88817 bytes docs/sdn_controller.md | 30 +- .../xo-server-sdn-controller/package.json | 3 + .../xo-server-sdn-controller/src/index.js | 343 ++++- .../src/protocol/openflow-channel.js | 394 ++++++ .../src/protocol/ovsdb-client.js | 246 +++- .../src/utils/tls-helper.js | 53 + packages/xo-server/src/xapi-object-to-xo.js | 1 + packages/xo-web/src/common/intl/messages.js | 17 + packages/xo-web/src/common/xo/index.js | 32 + packages/xo-web/src/xo-app/vm/tab-network.js | 248 +++- yarn.lock | 2 +- 44 files changed, 4254 insertions(+), 81 deletions(-) create mode 100644 @xen-orchestra/openflow/.babelrc.js create mode 100644 @xen-orchestra/openflow/.npmignore create mode 100644 @xen-orchestra/openflow/README.md create mode 100644 @xen-orchestra/openflow/USAGE.md create mode 100644 @xen-orchestra/openflow/package.json create mode 100644 @xen-orchestra/openflow/parse-socket.js create mode 100644 @xen-orchestra/openflow/src/default-header-scheme.js create mode 100644 @xen-orchestra/openflow/src/index.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/action/action.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/action/output.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/index.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/instruction/actions.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/instruction/instruction.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/message/echo.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/message/error.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/message/features-reply.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/message/features-request.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/message/flow-mod.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/message/get-config-request.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/message/header.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/message/hello.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/message/switch-config.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/openflow-11.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/struct/match/match.js create mode 100644 @xen-orchestra/openflow/src/openflow-11/struct/port.js create mode 100644 @xen-orchestra/openflow/src/parse-socket.js create mode 100644 @xen-orchestra/openflow/src/util/addrress-parser.js create mode 100644 @xen-orchestra/openflow/src/util/get-from-map.js create mode 100644 @xen-orchestra/openflow/src/util/uint-helper.js create mode 100644 @xen-orchestra/openflow/src/version.js create mode 100644 docs/assets/add-rule.png create mode 100644 docs/assets/show-rules.png create mode 100644 packages/xo-server-sdn-controller/src/protocol/openflow-channel.js create mode 100644 packages/xo-server-sdn-controller/src/utils/tls-helper.js diff --git a/@xen-orchestra/openflow/.babelrc.js b/@xen-orchestra/openflow/.babelrc.js new file mode 100644 index 000000000..4e27ec135 --- /dev/null +++ b/@xen-orchestra/openflow/.babelrc.js @@ -0,0 +1,3 @@ +module.exports = require('../../@xen-orchestra/babel-config')( + require('./package.json') +) diff --git a/@xen-orchestra/openflow/.npmignore b/@xen-orchestra/openflow/.npmignore new file mode 100644 index 000000000..e058b6bc1 --- /dev/null +++ b/@xen-orchestra/openflow/.npmignore @@ -0,0 +1,24 @@ +/benchmark/ +/benchmarks/ +*.bench.js +*.bench.js.map + +/examples/ +example.js +example.js.map +*.example.js +*.example.js.map + +/fixture/ +/fixtures/ +*.fixture.js +*.fixture.js.map +*.fixtures.js +*.fixtures.js.map + +/test/ +/tests/ +*.spec.js +*.spec.js.map + +__snapshots__/ diff --git a/@xen-orchestra/openflow/README.md b/@xen-orchestra/openflow/README.md new file mode 100644 index 000000000..1252851c1 --- /dev/null +++ b/@xen-orchestra/openflow/README.md @@ -0,0 +1,139 @@ + + +# @xen-orchestra/openflow [![Build Status](https://travis-ci.org/vatesfr/xen-orchestra.png?branch=master)](https://travis-ci.org/vatesfr/xen-orchestra) + +> Pack and unpack OpenFlow messages + +## Install + +Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/openflow): + +``` +> npm install --save @xen-orchestra/openflow +``` + +## Usage + +Unpacking a received OpenFlow message from a socket: + +```js +import openflow from '@xen-orchestra/openflow' +import parse from '@xen-orchestra/openflow/parse-socket' + +const version = openflow.versions.openFlow11 +const ofProtocol = openflow.protocols[version] + +function parseOpenFlowMessages(socket) { + for await (const msg of parse(socket)) { + if (msg.header !== undefined) { + const ofType = msg.header.type + switch (ofType) { + case ofProtocol.type.hello: + // Handle OFPT_HELLO + break + case ofProtocol.type.error: + // Handle OFPT_ERROR + break + case ofProtocol.type.echoRequest: + // Handle OFPT_ECHO_REQUEST + break + case ofProtocol.type.packetIn: + // Handle OFPT_PACKET_IN + break + case ofProtocol.type.featuresReply: + // Handle OFPT_FEATURES_REPLY + break + case ofProtocol.type.getConfigReply: + // Handle OFPT_GET_CONFIG_REPLY + break + case ofProtocol.type.portStatus: + // Handle OFPT_PORT_STATUS + break + case ofProtocol.type.flowRemoved: + // Handle OFPT_FLOW_REMOVED + break + default: + // Error: Invalid type + break + } + } else { + // Error: Message is unparseable + } + } +} +``` + +Unpacking a OpenFlow message from a buffer: + +```js +import openflow from '@xen-orchestra/openflow' + +const version = openflow.versions.openFlow11 +const ofProtocol = openflow.protocols[version] + +function processOpenFlowMessage(buf) { + const unpacked = openflow.unpack(buf) + const ofType = unpacked.header.type + switch (ofType) { + case ofProtocol.type.hello: + // Handle OFPT_HELLO + break + case ofProtocol.type.error: + // Handle OFPT_ERROR + break + case ofProtocol.type.echoRequest: + // Handle OFPT_ECHO_REQUEST + break + case ofProtocol.type.packetIn: + // Handle OFPT_PACKET_IN + break + case ofProtocol.type.featuresReply: + // Handle OFPT_FEATURES_REPLY + break + case ofProtocol.type.getConfigReply: + // Handle OFPT_GET_CONFIG_REPLY + break + case ofProtocol.type.portStatus: + // Handle OFPT_PORT_STATUS + break + case ofProtocol.type.flowRemoved: + // Handle OFPT_FLOW_REMOVED + break + default: + // Error: Invalid type + break + } +} +``` + +Packing an OpenFlow OFPT_HELLO message: + +```js +import openflow from '@xen-orchestra/openflow' + +const version = openflow.versions.openFlow11 +const ofProtocol = openflow.protocols[version] + +const buf = openflow.pack({ + header: { + version, + type: ofProtocol.type.hello, + xid: 1, + }, +}) +``` + +## Contributions + +Contributions are _very_ welcomed, either on the documentation or on +the code. + +You may: + +- report any [issue](https://github.com/vatesfr/xen-orchestra/issues) + you've encountered; +- fork and create a pull request. + +## License + +© [Vates SAS](https://vates.fr) diff --git a/@xen-orchestra/openflow/USAGE.md b/@xen-orchestra/openflow/USAGE.md new file mode 100644 index 000000000..22c7c880a --- /dev/null +++ b/@xen-orchestra/openflow/USAGE.md @@ -0,0 +1,108 @@ +Unpacking a received OpenFlow message from a socket: + +```js +import openflow from '@xen-orchestra/openflow' +import parse from '@xen-orchestra/openflow/parse-socket' + +const version = openflow.versions.openFlow11 +const ofProtocol = openflow.protocols[version] + +function parseOpenFlowMessages(socket) { + for await (const msg from parse(socket)) { + if (msg.header !== undefined) { + const ofType = msg.header.type + switch (ofType) { + case ofProtocol.type.hello: + // Handle OFPT_HELLO + break + case ofProtocol.type.error: + // Handle OFPT_ERROR + break + case ofProtocol.type.echoRequest: + // Handle OFPT_ECHO_REQUEST + break + case ofProtocol.type.packetIn: + // Handle OFPT_PACKET_IN + break + case ofProtocol.type.featuresReply: + // Handle OFPT_FEATURES_REPLY + break + case ofProtocol.type.getConfigReply: + // Handle OFPT_GET_CONFIG_REPLY + break + case ofProtocol.type.portStatus: + // Handle OFPT_PORT_STATUS + break + case ofProtocol.type.flowRemoved: + // Handle OFPT_FLOW_REMOVED + break + default: + // Error: Invalid type + break + } + } else { + // Error: Message is unparseable + } + } +} +``` + +Unpacking a OpenFlow message from a buffer: + +```js +import openflow from '@xen-orchestra/openflow' + +const version = openflow.versions.openFlow11 +const ofProtocol = openflow.protocols[version] + +function processOpenFlowMessage(buf) { + const unpacked = openflow.unpack(buf) + const ofType = unpacked.header.type + switch (ofType) { + case ofProtocol.type.hello: + // Handle OFPT_HELLO + break + case ofProtocol.type.error: + // Handle OFPT_ERROR + break + case ofProtocol.type.echoRequest: + // Handle OFPT_ECHO_REQUEST + break + case ofProtocol.type.packetIn: + // Handle OFPT_PACKET_IN + break + case ofProtocol.type.featuresReply: + // Handle OFPT_FEATURES_REPLY + break + case ofProtocol.type.getConfigReply: + // Handle OFPT_GET_CONFIG_REPLY + break + case ofProtocol.type.portStatus: + // Handle OFPT_PORT_STATUS + break + case ofProtocol.type.flowRemoved: + // Handle OFPT_FLOW_REMOVED + break + default: + // Error: Invalid type + break + } +} +``` + +Packing an OpenFlow OFPT_HELLO message: + +```js +import openflow from '@xen-orchestra/openflow' + +const version = openflow.versions.openFlow11 +const ofProtocol = openflow.protocols[version] + +const buf = openflow.pack({ + header: { + version, + type: ofProtocol.type.hello, + xid: 1, + }, +}) +``` diff --git a/@xen-orchestra/openflow/package.json b/@xen-orchestra/openflow/package.json new file mode 100644 index 000000000..08a97027e --- /dev/null +++ b/@xen-orchestra/openflow/package.json @@ -0,0 +1,35 @@ +{ + "description": "Pack and unpack OpenFlow messages", + "private": false, + "name": "@xen-orchestra/openflow", + "homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/openflow", + "bugs": "https://github.com/vatesfr/xen-orchestra/issues", + "repository": { + "directory": "@xen-orchestra/openflow", + "type": "git", + "url": "https://github.com/vatesfr/xen-orchestra.git" + }, + "version": "0.1.0", + "engines": { + "node": ">=8.10" + }, + "main": "dist/", + "scripts": { + "build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/", + "dev": "cross-env NODE_ENV=development babel --watch --source-maps --out-dir=dist/ src/", + "postversion": "npm publish --access public", + "prebuild": "rimraf dist/", + "predev": "yarn run prebuild", + "prepublishOnly": "yarn run build" + }, + "devDependencies": { + "@babel/cli": "^7.7.4", + "@babel/core": "^7.7.4", + "@babel/preset-env": "^7.7.4", + "cross": "^1.0.0", + "rimraf": "^3.0.0" + }, + "dependencies": { + "@vates/read-chunk": "^0.1.0" + } +} diff --git a/@xen-orchestra/openflow/parse-socket.js b/@xen-orchestra/openflow/parse-socket.js new file mode 100644 index 000000000..c848a9f5e --- /dev/null +++ b/@xen-orchestra/openflow/parse-socket.js @@ -0,0 +1 @@ +module.exports = require('./dist/parse-socket') diff --git a/@xen-orchestra/openflow/src/default-header-scheme.js b/@xen-orchestra/openflow/src/default-header-scheme.js new file mode 100644 index 000000000..2323ea51c --- /dev/null +++ b/@xen-orchestra/openflow/src/default-header-scheme.js @@ -0,0 +1,9 @@ +export default { + size: 8, + offsets: { + version: 0, + type: 1, + length: 2, + xid: 4, + }, +} diff --git a/@xen-orchestra/openflow/src/index.js b/@xen-orchestra/openflow/src/index.js new file mode 100644 index 000000000..ffd3bd286 --- /dev/null +++ b/@xen-orchestra/openflow/src/index.js @@ -0,0 +1,38 @@ +import get from './util/get-from-map' +import ofVersion from './version' +// TODO: More openflow versions +import of11 from './openflow-11/index' +import scheme from './default-header-scheme' + +// ============================================================================= + +const OPENFLOW = { + [ofVersion.openFlow11]: of11, +} + +// ============================================================================= + +export default { + versions: ofVersion, + protocols: { [ofVersion.openFlow11]: of11.protocol }, + + // --------------------------------------------------------------------------- + + pack: object => { + const version = object.header.version + return get( + OPENFLOW, + version, + `Unsupported OpenFlow version: ${version}` + ).pack(object) + }, + + unpack: (buffer, offset = 0) => { + const version = buffer.readUInt8(offset + scheme.offsets.version) + return get( + OPENFLOW, + version, + `Unsupported OpenFlow version: ${version}` + ).unpack(buffer, offset) + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/action/action.js b/@xen-orchestra/openflow/src/openflow-11/action/action.js new file mode 100644 index 000000000..8a39ecd4e --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/action/action.js @@ -0,0 +1,58 @@ +import get from '../../util/get-from-map' + +import ofOutput from './output' +import of from '../openflow-11' + +// ============================================================================= + +const ACTION = { + [of.actionType.output]: ofOutput, + /* TODO: + [of.actionType.group]: , + [of.actionType.setVlanId]: , + [of.actionType.setVlanPcp]: , + [of.actionType.setDlSrc]: , + [of.actionType.setDlDst]: , + [of.actionType.setNwSrc]: , + [of.actionType.setNwDst]: , + [of.actionType.setNwTos]: , + [of.actionType.setNwEcn]: , + [of.actionType.setTpSrc]: , + [of.actionType.setTpDst]: , + [of.actionType.copyTtlOut]: , + [of.actionType.copyTtlIn]: , + [of.actionType.setMplsLabel]: , + [of.actionType.setMplsTc]: , + [of.actionType.setMplsTtl]: , + [of.actionType.decMplsTtl]: , + [of.actionType.pushVlan]: , + [of.actionType.popVlan]: , + [of.actionType.pushMpls]: , + [of.actionType.popMpls]: , + [of.actionType.setQueue]: , + [of.actionType.setNwTtl]: , + [of.actionType.decNwTtl]: , + [of.actionType.experimenter]: + */ +} + +// ============================================================================= + +export default { + pack: (object, buffer = undefined, offset = 0) => { + const { type } = object + return get(ACTION, type, `Invalid action type: ${type}`).pack( + object, + buffer, + offset + ) + }, + + unpack: (buffer, offset = 0) => { + const type = buffer.readUInt16BE(offset + of.offsets.actionHeader.type) + return get(ACTION, type, `Invalid action type: ${type}`).unpack( + buffer, + offset + ) + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/action/output.js b/@xen-orchestra/openflow/src/openflow-11/action/output.js new file mode 100644 index 000000000..1eda98044 --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/action/output.js @@ -0,0 +1,45 @@ +import assert from 'assert' + +import of from '../openflow-11' + +// ============================================================================= + +const OFFSETS = of.offsets.actionOutput + +const PAD_LENGTH = 6 + +// ============================================================================= + +export default { + pack: (object, buffer = undefined, offset = 0) => { + assert(object.type === of.actionType.output) + object.len = of.sizes.actionOutput + + buffer = buffer !== undefined ? buffer : Buffer.alloc(object.len) + + buffer.writeUInt16BE(object.type, offset + OFFSETS.type) + buffer.writeUInt16BE(object.len, offset + OFFSETS.len) + + buffer.writeUInt32BE(object.port, offset + OFFSETS.port) + buffer.writeUInt16BE(object.max_len, offset + OFFSETS.maxLen) + + buffer.fill(0, offset + OFFSETS.pad, offset + OFFSETS.pad + PAD_LENGTH) + + return buffer + }, + + unpack: (buffer, offset = 0) => { + const object = {} + + object.type = buffer.readUInt16BE(offset + OFFSETS.type) + assert(object.type === of.actionType.output) + + object.len = buffer.readUInt16BE(offset + OFFSETS.len) + assert(object.len === of.sizes.actionOutput) + + object.port = buffer.readUInt32BE(offset + OFFSETS.port) + object.max_len = buffer.readUInt16BE(offset + OFFSETS.maxLen) + + return object + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/index.js b/@xen-orchestra/openflow/src/openflow-11/index.js new file mode 100644 index 000000000..5b3bbbdfc --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/index.js @@ -0,0 +1,49 @@ +import get from '../util/get-from-map' + +import echo from './message/echo' +import error from './message/error' +import hello from './message/hello' +import featuresRequest from './message/features-request' +import featuresReply from './message/features-reply' +import getConfigRequest from './message/get-config-request' +import switchConfig from './message/switch-config' +import flowMod from './message/flow-mod' +import of from './openflow-11' + +// ============================================================================= + +const MESSAGE = { + [of.type.hello]: hello, + [of.type.error]: error, + [of.type.featuresRequest]: featuresRequest, + [of.type.featuresReply]: featuresReply, + [of.type.echoRequest]: echo, + [of.type.echoReply]: echo, + [of.type.getConfigRequest]: getConfigRequest, + [of.type.getConfigReply]: switchConfig, + [of.type.setConfig]: switchConfig, + [of.type.flowMod]: flowMod, +} + +// ============================================================================= + +export default { + protocol: of, + + // --------------------------------------------------------------------------- + + pack: object => { + const type = object.header.type + return get(MESSAGE, type, `Invalid OpenFlow message type: ${type}`).pack( + object + ) + }, + + unpack: (buffer, offset = 0) => { + const type = buffer.readUInt8(offset + of.offsets.header.type) + return get(MESSAGE, type, `Invalid OpenFlow message type: ${type}`).unpack( + buffer, + offset + ) + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/instruction/actions.js b/@xen-orchestra/openflow/src/openflow-11/instruction/actions.js new file mode 100644 index 000000000..3fcecfb4b --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/instruction/actions.js @@ -0,0 +1,102 @@ +import assert from 'assert' + +import get from '../../util/get-from-map' + +import ofAction from '../action/action' +import of from '../openflow-11' + +// ============================================================================= + +const SIZES = { + [of.actionType.output]: of.sizes.actionOutput, + [of.actionType.group]: of.sizes.actionGroup, + [of.actionType.setVlanId]: of.sizes.actionVlanId, + [of.actionType.setVlanPcp]: of.sizes.actionVlanPcp, + [of.actionType.setDlSrc]: of.sizes.actionDlAddr, + [of.actionType.setDlDst]: of.sizes.actionDlAddr, + [of.actionType.setNwSrc]: of.sizes.actionNwAddr, + [of.actionType.setNwDst]: of.sizes.actionNwAddr, + [of.actionType.setNwTos]: of.sizes.actionNwTos, + [of.actionType.setNwEcn]: of.sizes.actionNwEcn, + [of.actionType.setTpSrc]: of.sizes.actionTpPort, + [of.actionType.setTpDst]: of.sizes.actionTpPort, + [of.actionType.copyTtlOut]: of.sizes.actionHeader, + [of.actionType.copyTtlIn]: of.sizes.actionHeader, + [of.actionType.setMplsLabel]: of.sizes.actionMplsLabel, + [of.actionType.setMplsTc]: of.sizes.actionMplsTc, + [of.actionType.setMplsTtl]: of.sizes.actionMplsTtl, + [of.actionType.decMplsTtl]: of.sizes.actionMplsTtl, + [of.actionType.pushVlan]: of.sizes.actionPush, + [of.actionType.popVlan]: of.sizes.actionHeader, + [of.actionType.pushMpls]: of.sizes.actionPush, + [of.actionType.popMpls]: of.sizes.actionPopMpls, + [of.actionType.setQueue]: of.sizes.actionSetQueue, + [of.actionType.setNwTtl]: of.sizes.actionNwTtl, + [of.actionType.decNwTtl]: of.sizes.actionNwTtl, +} + +// ----------------------------------------------------------------------------- + +const TYPES = [ + of.instructionType.clearActions, + of.instructionType.writeActions, + of.instructionType.applyActions, +] +const OFFSETS = of.offsets.instructionActions + +const PAD_LENGTH = 4 + +// ============================================================================= + +export default { + pack: (object, buffer = undefined, offset = 0) => { + const { type } = object + assert(TYPES.includes(type)) + object.len = of.sizes.instructionActions + const { actions = [] } = object + actions.forEach(action => { + assert(Object.values(of.actionType).includes(action.type)) + // TODO: manage experimenter + object.len += get( + SIZES, + action.type, + `Invalid action type: ${action.type}` + ) + }) + + buffer = buffer !== undefined ? buffer : Buffer.alloc(object.len) + + buffer.writeUInt16BE(type, offset + OFFSETS.type) + buffer.writeUInt16BE(object.len, offset + OFFSETS.len) + buffer.fill(0, offset + OFFSETS.pad, offset + OFFSETS.pad + PAD_LENGTH) + + let actionOffset = offset + OFFSETS.actions + actions.forEach(action => { + ofAction.pack(action, buffer, actionOffset) + actionOffset += SIZES[action.type] + }) + }, + + unpack: (buffer = undefined, offset = 0) => { + const type = buffer.readUInt16BE(offset + OFFSETS.type) + assert(TYPES.includes(type)) + + const object = { type } + object.len = buffer.readUInt16BE(offset + OFFSETS.len) + + if (type === of.instructionType.clearActions) { + // No actions for this type + return object + } + + object.actions = [] + let actionOffset = offset + OFFSETS.actions + while (actionOffset < object.len) { + const action = ofAction.unpack(buffer, actionOffset) + actionOffset += action.len + object.actions.push(action) + } + + return object + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/instruction/instruction.js b/@xen-orchestra/openflow/src/openflow-11/instruction/instruction.js new file mode 100644 index 000000000..81efdee18 --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/instruction/instruction.js @@ -0,0 +1,43 @@ +import get from '../../util/get-from-map' + +import actions from './actions' +// import goToTable from './goToTable' +import of from '../openflow-11' +// import writeMetadata from './writeMetadata' + +// ============================================================================= + +const INSTRUCTION = { + /* TODO: + [of.instructionType.goToTable]: goToTable, + [of.instructionType.writeMetadata]: writeMetadata, + */ + [of.instructionType.writeActions]: actions, + [of.instructionType.applyActions]: actions, + [of.instructionType.clearActions]: actions, +} + +// ----------------------------------------------------------------------------- + +const OFFSETS = of.offsets.instruction + +// ============================================================================= + +export default { + pack: (object, buffer = undefined, offset = 0) => { + const { type } = object + return get(INSTRUCTION, type, `Invalid instruction type: ${type}`).pack( + object, + buffer, + offset + ) + }, + + unpack: (buffer = undefined, offset = 0) => { + const type = buffer.readUInt16BE(offset + OFFSETS.type) + return get(INSTRUCTION, type, `Invalid instruction type: ${type}`).unpack( + buffer, + offset + ) + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/message/echo.js b/@xen-orchestra/openflow/src/openflow-11/message/echo.js new file mode 100644 index 000000000..ccbffca30 --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/message/echo.js @@ -0,0 +1,46 @@ +import assert from 'assert' +import ofHeader from './header' +import of from '../openflow-11' + +// ============================================================================= + +const OFFSETS = of.offsets.echo +const TYPES = [of.type.echoRequest, of.type.echoReply] + +// ============================================================================= + +export default { + pack: object => { + const { header, data } = object + assert(TYPES.includes(header.type)) + const dataSize = data !== undefined ? data.length : 0 + header.length = of.sizes.header + dataSize + + const buffer = Buffer.alloc(header.length) + ofHeader.pack(header, buffer, OFFSETS.header) + if (dataSize > 0) { + data.copy(buffer, OFFSETS.data, 0, dataSize) + } + + return buffer + }, + + unpack: (buffer, offset = 0) => { + const header = ofHeader.unpack(buffer, offset + OFFSETS.header) + assert(TYPES.includes(header.type)) + + const object = { header } + const dataSize = header.length - of.sizes.header + if (dataSize > 0) { + object.data = Buffer.alloc(dataSize) + buffer.copy( + object.data, + 0, + offset + OFFSETS.data, + offset + OFFSETS.data + dataSize + ) + } + + return object + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/message/error.js b/@xen-orchestra/openflow/src/openflow-11/message/error.js new file mode 100644 index 000000000..a103f6322 --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/message/error.js @@ -0,0 +1,79 @@ +import assert from 'assert' + +import get from '../../util/get-from-map' + +import ofHeader from './header' +import of from '../openflow-11' + +// ============================================================================= + +const ERROR_CODE = { + [of.errorType.helloFailed]: of.helloFailedCode, + [of.errorType.badRequest]: of.badRequestCode, + [of.errorType.badAction]: of.badActionCode, + [of.errorType.badInstruction]: of.badInstructionCode, + [of.errorType.badMatch]: of.badMatchCode, + [of.errorType.flowModFailed]: of.flowModFailedCode, + [of.errorType.groupModFailed]: of.groupModFailedCode, + [of.errorType.portModFailed]: of.portModFailedCode, + [of.errorType.tableModFailed]: of.tableModFailedCode, + [of.errorType.queueOpFailed]: of.queueOpFailedCode, + [of.errorType.switchConfigFailed]: of.switchConfigFailedCode, +} + +// ----------------------------------------------------------------------------- + +const OFFSETS = of.offsets.errorMsg + +// ============================================================================= + +export default { + pack: (object, buffer = undefined, offset = 0) => { + const { header, type, code, data } = object + assert(header.type === of.type.error) + const errorCodes = get(ERROR_CODE, type, `Invalid error type: ${type}`) + assert(Object.values(errorCodes).includes(code)) + + object.length = of.sizes.errorMsg + if (data !== undefined) { + object.length += data.length + } + + buffer = buffer !== undefined ? buffer : Buffer.alloc(object.length) + + ofHeader.pack(header, buffer, offset + OFFSETS.header) + buffer.writeUInt16BE(type, offset + OFFSETS.type) + buffer.writeUInt16BE(code, offset + OFFSETS.code) + + if (data !== undefined) { + data.copy(buffer, offset + OFFSETS.data, 0, data.length) + } + + return buffer + }, + + unpack: (buffer, offset = 0) => { + const header = ofHeader.unpack(buffer, offset + OFFSETS.header) + assert(header.type === of.type.error) + + const type = buffer.readUInt16BE(offset + OFFSETS.type) + const errorCodes = get(ERROR_CODE, type, `Invalid error type: ${type}`) + + const code = buffer.readUInt16BE(offset + OFFSETS.code) + assert(Object.values(errorCodes).includes(code)) + + const object = { header, type, code } + const dataSize = header.length - of.sizes.errorMsg + if (dataSize > 0) { + object.data = Buffer.alloc(dataSize) + buffer.copy( + object.data, + 0, + offset + OFFSETS.data, + offset + OFFSETS.data + dataSize + ) + } + + return object + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/message/features-reply.js b/@xen-orchestra/openflow/src/openflow-11/message/features-reply.js new file mode 100644 index 000000000..ef6eb6758 --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/message/features-reply.js @@ -0,0 +1,73 @@ +import assert from 'assert' + +import ofHeader from './header' +import ofPort from '../struct/port' +import of from '../openflow-11' + +// ============================================================================= + +const OFFSETS = of.offsets.switchFeatures +const PAD_LENGTH = 3 + +// ============================================================================= + +export default { + pack: object => { + const { + header, + datapath_id: did, + n_buffers: nBufs, + n_tables: nTables, + capabilities, + reserved, + ports, + } = object + assert(header.type === of.type.featuresReply) + + header.length = of.sizes.switchFeatures + ports.length * of.sizes.port + + const buffer = Buffer.alloc(header.length) + ofHeader.pack(header, buffer, OFFSETS.header) + + buffer.writeBigUInt64BE(did, OFFSETS.datapathId) + buffer.writeUInt32BE(nBufs, OFFSETS.nBuffers) + buffer.writeUInt8(nTables, OFFSETS.nTables) + buffer.fill(0, OFFSETS.pad, OFFSETS.pad + PAD_LENGTH) + buffer.writeUInt32BE(capabilities, OFFSETS.capabilities) + buffer.writeUInt32BE(reserved, OFFSETS.reserved) + + let portsOffset = 0 + ports.forEach(port => { + ofPort.pack(port, buffer, OFFSETS.ports + portsOffset++ * of.sizes.port) + }) + + return buffer + }, + + unpack: (buffer, offset = 0) => { + const header = ofHeader.unpack(buffer, offset + OFFSETS.header) + assert(header.type === of.type.featuresReply) + + const object = { header } + object.datapath_id = buffer.toString( + 'hex', + offset + OFFSETS.datapathId, + offset + OFFSETS.datapathId + 8 + ) + object.n_buffers = buffer.readUInt32BE(offset + OFFSETS.nBuffers) + object.n_tables = buffer.readUInt8(offset + OFFSETS.nTables) + + object.capabilities = buffer.readUInt32BE(offset + OFFSETS.capabilities) + object.reserved = buffer.readUInt32BE(offset + OFFSETS.reserved) + + object.ports = [] + const nPorts = (header.length - of.sizes.switchFeatures) / of.sizes.port + for (let i = 0; i < nPorts; ++i) { + object.ports.push( + ofPort.unpack(buffer, offset + OFFSETS.ports + i * of.sizes.port) + ) + } + + return object + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/message/features-request.js b/@xen-orchestra/openflow/src/openflow-11/message/features-request.js new file mode 100644 index 000000000..ec80955fa --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/message/features-request.js @@ -0,0 +1,24 @@ +import assert from 'assert' + +import ofHeader from './header' +import of from '../openflow-11' + +// ============================================================================= + +export default { + pack: object => { + const { header } = object + assert(header.type === of.type.featuresRequest) + header.length = of.sizes.featuresRequest + + return ofHeader.pack(header) + }, + + unpack: (buffer, offset = 0) => { + const header = ofHeader.unpack(buffer, offset) + assert(header.type === of.type.featuresRequest) + assert(header.length === of.sizes.featuresRequest) + + return { header } + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/message/flow-mod.js b/@xen-orchestra/openflow/src/openflow-11/message/flow-mod.js new file mode 100644 index 000000000..9f188dbb3 --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/message/flow-mod.js @@ -0,0 +1,197 @@ +import assert from 'assert' + +import get from '../../util/get-from-map' +import ofInstruction from '../instruction/instruction' +import uIntHelper from '../../util/uint-helper' + +import ofHeader from './header' +import of from '../openflow-11' +import ofMatch from '../struct/match/match' + +// ============================================================================= + +const INSTRUCTION_SIZE = { + [of.instructionType.goToTable]: of.sizes.instructionWriteMetadata, + [of.instructionType.writeMetadata]: of.sizes.instructionGotoTable, + [of.instructionType.clearActions]: of.sizes.instructionActions, + [of.instructionType.writeActions]: of.sizes.instructionActions, + [of.instructionType.applyActions]: of.sizes.instructionActions, +} + +const ACTION_SIZE = { + [of.actionType.output]: of.sizes.actionOutput, + [of.actionType.group]: of.sizes.actionGroup, + [of.actionType.setVlanId]: of.sizes.actionVlanId, + [of.actionType.setVlanPcp]: of.sizes.actionVlanPcp, + [of.actionType.setDlSrc]: of.sizes.actionDlAddr, + [of.actionType.setDlDst]: of.sizes.actionDlAddr, + [of.actionType.setNwSrc]: of.sizes.actionNwAddr, + [of.actionType.setNwDst]: of.sizes.actionNwAddr, + [of.actionType.setNwTos]: of.sizes.actionNwTos, + [of.actionType.setNwEcn]: of.sizes.actionNwEcn, + [of.actionType.setTpSrc]: of.sizes.actionTpPort, + [of.actionType.setTpDst]: of.sizes.actionTpPort, + [of.actionType.copyTtlOut]: of.sizes.actionHeader, + [of.actionType.copyTtlIn]: of.sizes.actionHeader, + [of.actionType.setMplsLabel]: of.sizes.actionMplsLabel, + [of.actionType.setMplsTc]: of.sizes.actionMplsTc, + [of.actionType.setMplsTtl]: of.sizes.actionMplsTtl, + [of.actionType.decMplsTtl]: of.sizes.actionMplsTtl, + [of.actionType.pushVlan]: of.sizes.actionPush, + [of.actionType.popVlan]: of.sizes.actionHeader, + [of.actionType.pushMpls]: of.sizes.actionPush, + [of.actionType.popMpls]: of.sizes.actionPopMpls, + [of.actionType.setQueue]: of.sizes.actionSetQueue, + [of.actionType.setNwTtl]: of.sizes.actionNwTtl, + [of.actionType.decNwTtl]: of.sizes.actionNwTtl, +} + +// ----------------------------------------------------------------------------- + +const OFFSETS = of.offsets.flowMod + +const COOKIE_LENGTH = 8 +const PAD_LENGTH = 2 + +// ============================================================================= + +export default { + pack: (object, buffer = undefined, offset = 0) => { + const { + header, + cookie, + cookie_mask, + table_id = 0, + command, + idle_timeout = 0, + hard_timeout = 0, + priority = of.defaultPriority, + buffer_id = 0xffffffff, + out_port = of.port.any, + out_group = of.group.any, + flags = 0, + match, + instructions = [], + } = object + // fill header length + header.length = of.sizes.flowMod + instructions.forEach(instruction => { + header.length += get( + INSTRUCTION_SIZE, + instruction.type, + `Invalid instruction type: ${instruction.type}` + ) + const { actions = [] } = instruction + actions.forEach(action => { + header.length += get( + ACTION_SIZE, + action.type, + `Invalid instruction type: ${action.type}` + ) + }) + }) + + buffer = buffer !== undefined ? buffer : Buffer.alloc(header.length) + + ofHeader.pack(header, buffer, offset + OFFSETS.header) + + if (cookie !== undefined) { + if (cookie_mask !== undefined) { + cookie_mask.copy(buffer, offset + OFFSETS.cookieMask) + } else { + buffer.fill( + 0x00, + offset + OFFSETS.cookie_mask, + offset + OFFSETS.cookieMask + COOKIE_LENGTH + ) + } + cookie.copy(buffer, offset + OFFSETS.cookie) + } else { + buffer.fill( + 0x00, + offset + OFFSETS.cookie, + offset + OFFSETS.cookie + COOKIE_LENGTH + ) + buffer.fill( + 0xff, + offset + OFFSETS.cookieMask, + offset + OFFSETS.cookieMask + COOKIE_LENGTH + ) + } + + buffer.writeUInt8(table_id, offset + OFFSETS.tableId) + assert(Object.values(of.flowModCommand).includes(command)) + buffer.writeUInt8(command, offset + OFFSETS.command) + buffer.writeUInt16BE(idle_timeout, offset + OFFSETS.idleTimeout) + buffer.writeUInt16BE(hard_timeout, offset + OFFSETS.hardTimeout) + buffer.writeUInt16BE(priority, offset + OFFSETS.priority) + buffer.writeUInt32BE(buffer_id, offset + OFFSETS.bufferId) + buffer.writeUInt32BE(out_port, offset + OFFSETS.outPort) + buffer.writeUInt32BE(out_group, offset + OFFSETS.outGroup) + buffer.writeUInt16BE(flags, offset + OFFSETS.flags) + buffer.fill(0, offset + OFFSETS.pad, offset + OFFSETS.pad + PAD_LENGTH) + + ofMatch.pack(match, buffer, offset + OFFSETS.match) + + let instructionOffset = offset + OFFSETS.instructions + instructions.forEach(instruction => { + ofInstruction.pack(instruction, buffer, instructionOffset) + instructionOffset += instruction.len + }) + + return buffer + }, + + unpack: (buffer, offset = 0) => { + const header = ofHeader.unpack(buffer, offset + OFFSETS.header) + assert(header.type === of.type.flowMod) + + const object = { header } + + object.cookie = Buffer.alloc(COOKIE_LENGTH) + buffer.copy( + object.cookie, + 0, + offset + OFFSETS.cookie, + offset + OFFSETS.cookie + COOKIE_LENGTH + ) + if ( + !uIntHelper.isUInt64None([ + buffer.readUInt32BE(offset + OFFSETS.cookieMask), + buffer.readUInt32BE(offset + OFFSETS.cookieMask + COOKIE_LENGTH / 2), + ]) + ) { + object.cookie_mask = Buffer.alloc(COOKIE_LENGTH) + buffer.copy( + object.cookie_mask, + 0, + offset + OFFSETS.cookieMask, + offset + OFFSETS.cookieMask + COOKIE_LENGTH + ) + } + + object.table_id = buffer.readUInt8(offset + OFFSETS.tableId) + object.command = buffer.readUInt8(offset + OFFSETS.command) + assert(Object.values(of.flowModCommand).includes(object.command)) + + object.idle_timeout = buffer.readUInt16BE(offset + OFFSETS.idleTimeout) + object.hard_timeout = buffer.readUInt16BE(offset + OFFSETS.hardTimeout) + object.priority = buffer.readUInt16BE(offset + OFFSETS.priority) + object.buffer_id = buffer.readUInt32BE(offset + OFFSETS.bufferId) + object.out_port = buffer.readUInt32BE(offset + OFFSETS.outPort) + object.out_group = buffer.readUInt32BE(offset + OFFSETS.outGroup) + object.flags = buffer.readUInt16BE(offset + OFFSETS.flags) + + object.match = ofMatch.unpack(buffer, offset + OFFSETS.match) + + object.instructions = [] + let instructionOffset = offset + OFFSETS.instructions + while (instructionOffset < header.length) { + const instruction = ofInstruction.unpack(buffer, instructionOffset) + object.instructions.push(instruction) + instructionOffset += instruction.len + } + + return object + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/message/get-config-request.js b/@xen-orchestra/openflow/src/openflow-11/message/get-config-request.js new file mode 100644 index 000000000..76873ec05 --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/message/get-config-request.js @@ -0,0 +1,24 @@ +import assert from 'assert' + +import ofHeader from './header' +import of from '../openflow-11' + +// ============================================================================= + +export default { + pack: object => { + const { header } = object + assert(header.type === of.type.getConfigRequest) + header.length = of.sizes.header + + return ofHeader.pack(header) + }, + + unpack: (buffer, offset = 0) => { + const header = ofHeader.unpack(buffer, offset) + assert(header.type === of.type.getConfigRequest) + assert(header.length === of.sizes.header) + + return { header } + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/message/header.js b/@xen-orchestra/openflow/src/openflow-11/message/header.js new file mode 100644 index 000000000..a2ecc9341 --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/message/header.js @@ -0,0 +1,39 @@ +import assert from 'assert' + +import of from '../openflow-11' + +// ============================================================================= + +const OFFSETS = of.offsets.header + +// ============================================================================= + +export default { + pack: (object, buffer = undefined, offset = 0) => { + buffer = buffer !== undefined ? buffer : Buffer.alloc(of.sizes.header) + const { version, type, length, xid } = object + + assert(version === of.version) + assert(Object.values(of.type).includes(type)) + + buffer.writeUInt8(version, offset + OFFSETS.version) + buffer.writeUInt8(type, offset + OFFSETS.type) + buffer.writeUInt16BE(length, offset + OFFSETS.length) + buffer.writeUInt32BE(xid, offset + OFFSETS.xid) + + return buffer + }, + + unpack: (buffer, offset = 0) => { + const version = buffer.readUInt8(offset + OFFSETS.version) + assert(version === of.version) + + const type = buffer.readUInt8(offset + OFFSETS.type) + assert(Object.values(of.type).includes(type)) + + const length = buffer.readUInt16BE(offset + OFFSETS.length) + const xid = buffer.readUInt32BE(offset + OFFSETS.xid) + + return { version, type, length, xid } + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/message/hello.js b/@xen-orchestra/openflow/src/openflow-11/message/hello.js new file mode 100644 index 000000000..10598b85f --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/message/hello.js @@ -0,0 +1,27 @@ +import assert from 'assert' + +import ofHeader from './header' +import of from '../openflow-11' + +// ============================================================================= + +const OFFSETS = of.offsets.hello + +// ============================================================================= + +export default { + pack: object => { + const { header } = object + assert(header.type === of.type.hello) + header.length = of.sizes.hello + + return ofHeader.pack(header) + }, + + unpack: (buffer, offset = 0) => { + const header = ofHeader.unpack(buffer, offset + OFFSETS.header) + assert(header.type === of.type.hello) + + return { header } + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/message/switch-config.js b/@xen-orchestra/openflow/src/openflow-11/message/switch-config.js new file mode 100644 index 000000000..e61ddb10a --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/message/switch-config.js @@ -0,0 +1,38 @@ +import assert from 'assert' + +import ofHeader from './header' +import of from '../openflow-11' + +// ============================================================================= + +const OFFSETS = of.offsets.switchConfig +const TYPES = [of.type.getConfigReply, of.type.setConfig] + +// ============================================================================= + +export default { + pack: object => { + const { header, flags, miss_send_len } = object + assert(TYPES.includes(header.type)) + header.length = of.sizes.switchConfig + + const buffer = Buffer.alloc(header.length) + ofHeader.pack(header, buffer, OFFSETS.header) + + buffer.writeUInt16BE(flags, OFFSETS.flags) + buffer.writeUInt16BE(miss_send_len, OFFSETS.missSendLen) + + return buffer + }, + + unpack: (buffer, offset = 0) => { + const header = ofHeader.unpack(buffer, offset + OFFSETS.header) + assert(TYPES.includes(header.type)) + assert(header.length === of.sizes.switchConfig) + + const flags = buffer.readUInt16BE(offset + OFFSETS.flags) + const miss_send_len = buffer.readUInt16BE(offset + OFFSETS.missSendLen) + + return { header, flags, miss_send_len } + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/openflow-11.js b/@xen-orchestra/openflow/src/openflow-11/openflow-11.js new file mode 100644 index 000000000..d94131768 --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/openflow-11.js @@ -0,0 +1,1170 @@ +import ofVersion from '../version' + +// ============================================================================= + +// See OpenFlow 1.1 spec: https://www.opennetworking.org/wp-content/uploads/2014/10/openflow-spec-v1.1.0.pdf +export default { + version: ofVersion.openFlow11, + + maxTableNameLen: 32, + maxPortNameLen: 16, + + tcpPort: 6653, + sslPort: 6653, + + ethAddrLen: 6, // Number of bytes in an Ethernet address + + // Port numbering. Ports are numbered starting from 1 + port: { + max: 0xffffff00, // Maximum number of physical switch ports + + // Fake output "ports" + inPort: 0xfffffff8, + /* Send the packet out the input port. + * This virtual port must be explicitly used + * in order to send back out of the input port. + */ + table: 0xfffffff9, + /* Submit the packet to the first flow table. + * NB: This destination port can only be + * used in packet-out messages. + */ + normal: 0xfffffffa, // Process with normal L2/L3 switching. + flood: 0xfffffffb, + /* All physical ports in VLAN, except input + * port and those blocked or link down. + */ + all: 0xfffffffc, // All physical ports except input port + contorller: 0xfffffffd, // Send to controller + local: 0xfffffffe, // Local openflow "port" + any: 0xffffffff, + /* Wildcard port used only for flow mod + * (delete) and flow stats requests. Selects + * all flows regardless of output port + * (including flows with no output port). + */ + }, + + type: { + // Symmetric message + hello: 0, + error: 1, + echoRequest: 2, + echoReply: 3, + experimenter: 4, + + // Switch config messages + featuresRequest: 5, + featuresReply: 6, + getConfigRequest: 7, + getConfigReply: 8, + setConfig: 9, + + // Asynchronous messages + packetIn: 10, + flowRemoved: 11, + portStatus: 12, + + // Controller command messages + packetOut: 13, + flowMod: 14, + groupMod: 15, + portMod: 16, + tableMod: 17, + + // Stats messages + statsRequest: 18, + statsReply: 19, + + // Barrier messages + barrierRequest: 20, + barrierReply: 21, + + // Queue Config messages + queueGetConfigRequest: 22, + queueGetConfigReply: 23, + }, + + defaultMissSendLen: 128, + + configFlags: { + // Handling of IP fragments + fragNormal: 0, // Normal handling + fragDrop: 1 << 0, // Drop fragments + fragReasm: 1 << 1, // Reassemble (only if OFPC_IP_REASM set) + fragMask: 3, + + // TTL processing - applicable for IP and MPLS packets */ + invalidTtlToController: 1 << 2, + /* Send packets with invalid TTL + * ie. 0 or 1 to controller + */ + }, + + /* Flags to indicate behavior of the flow table for unmatched packets. + * These flags are used in ofp_table_stats messages to describe the current + * configuration and in ofp_table_mod messages to configure table behavior. + */ + tableConfig: { + controller: 0, + continue: 1 << 0, + /* Continue to the next table in the + * pipeline (OpenFlow 1.0 behavior). + */ + drop: 1 << 1, + mask: 3, + }, + + // Capabilities supported by the datapath + capabilities: { + flowStats: 1 << 0, + tableStats: 1 << 1, + portStats: 1 << 2, + groupStats: 1 << 3, + ipReasm: 1 << 5, + queueStats: 1 << 6, + arpMatchIp: 1 << 7, + }, + + /* Flags to indicate behavior of the physical port. These flags are + * used in ofp_port to describe the current configuration. They are + * used in the ofp_port_mod message to configure the port's behavior. + */ + portConfig: { + down: 1 << 0, + + noRecv: 1 << 2, + noFwd: 1 << 5, + noPacketIn: 1 << 6, + }, + + /* Current state of the physical port. + * These are not configurable from the controller. + */ + portState: { + linkDown: 1 << 0, + blocked: 1 << 1, + live: 1 << 2, + }, + + // Features of ports available in a datapath + portFeatures: { + f10MbHd: 1 << 0, + f10MbFd: 1 << 1, + f100MbHd: 1 << 2, + f100MbFd: 1 << 3, + f1GbHd: 1 << 4, + f1GbFd: 1 << 5, + f10GbFd: 1 << 6, + f40GbFd: 1 << 7, + f100GbFd: 1 << 8, + f1TbFd: 1 << 9, + other: 1 << 10, + + copper: 1 << 11, + fiber: 1 << 12, + autoneg: 1 << 13, + pause: 1 << 14, + pauseAsym: 1 << 15, + }, + + // What has changed about the physical port + portReason: { + add: 0, + delete: 1, + modify: 2, + }, + + // Why is this packet being sent to the controller? + packetInReason: { + noMatch: 0, // No matching flow + action: 1, // Action explicitly output to controller + }, + + actionType: { + output: 0, // Output to switch port + setVlanId: 1, // Set the 802.1q VLAN id + setVlanPcp: 2, // Set the 802.1q priority + setDlSrc: 3, // Ethernet source address + setDlDst: 4, // Ethernet destination address + setNwSrc: 5, // IP source address + setNwDst: 6, // IP destination address + setNwTos: 7, // IP ToS (DSCP field, 6 bits) + setNwEcn: 8, // IP ECN (2 bits) + setTpSrc: 9, // TCP/UDP/SCTP source port + setTpDst: 10, // TCP/UDP/SCTP destination port + copyTtlOut: 11, // Copy TTL "outwards" - from next-to-outermost to outermost + copyTtlIn: 12, // Copy TTL "inwards" - from outermost to next-to-outermost + setMplsLabel: 13, + setMplsTc: 14, + setMplsTtl: 15, + decMplsTtl: 16, // Decrement MPLS TTL + + pushVlan: 17, // Push a new VLAN tag + popVlan: 18, // Pop the outer VLAN tag + pushMpls: 19, // Push a new MPLS tag + popMpls: 20, // Pop the outer MPLS tag + setQueue: 21, // Set queue id when outputting to a port + group: 22, + setNwTtl: 23, + decNwTtl: 24 /* Decrement IP TTL. */, + experimenter: 0xffff, + }, + + flowModCommand: { + add: 0, // New flow + modify: 1, // Modify all matching flows + modifyStrict: 2, // Modify entry strictly matching wildcards and priority + delete: 3, // Delete all matching flows + deleteStrict: 4, // Delete entry strictly matching wildcards and priority + }, + + groupModCommand: { + add: 0, // New group + modify: 1, // Modify all matching groups + delete: 2, // Delete all matching groups + }, + + flowWildcards: { + inPort: 1 << 0, + dlVlan: 1 << 1, + dlVlanPcp: 1 << 2, + dlType: 1 << 3, // Ethernet frame type + nwTos: 1 << 4, // IP ToS (DSCP field, 6 bits) + nwProto: 1 << 5, // IP protocol + tpSrc: 1 << 6, + tpDst: 1 << 7, + mplsLabel: 1 << 8, + mplsTc: 1 << 9, + + all: (1 << 10) - 1, // Wildcards all fields + }, + + dlType: { + ip: 0x0800, + arp: 0x0806, + + /* Values below this cutoff are 802.3 packets and the two bytes + * following MAC addresses are used as a frame length. Otherwise, the + * two bytes are used as the Ethernet type. + */ + eth2Cutoff: 0x0600, + + /* Value of dl_type to indicate that the frame does not include an + * Ethernet type. + */ + notEthType: 0x05ff, + }, + + nwProto: { + icmp: 1, + tcp: 6, + udp: 17, + }, + + /* The VLAN id is 12-bits, so we can use the entire 16 bits to indicate + * special conditions. + */ + vlanId: { + any: 0xfffe, + /* Indicate that a VLAN id is set but don't care about it's value. + * Note: only valid when specifying the VLAN id in a match + */ + none: 0xffff, // No VLAN id was set + }, + + /* The match type indicates the match structure (set of fields that compose the + * match) in use. The match type is placed in the type field at the beginning + * of all match structures. The "standard" type corresponds to ofp_match and + * must be supported by all OpenFlow switches. Extensions that define other + * match types may be published on the OpenFlow wiki. Support for extensions is + * optional. + */ + matchType: { + standard: 0, // The match fields defined in the ofp_match structure apply + }, + + /* Value used in "idle_timeout" and "hard_timeout" to indicate that the entry + * is permanent. */ + flowPermanent: 0, + + // By default, choose a priority in the middle + defaultPriority: 0x8000, + + instructionType: { + goToTable: 1, // Setup the next table in the lookup pipeline + writeMetadata: 2, // Setup the metadata field for use later in pipeline + writeActions: 3, // Write the action(s) onto the datapath action set + applyActions: 4, // Applies the action(s) immediately (no writing in action set) + clearActions: 5, // Clears all actions from the datapath action set + + experimenter: 0xffff, // Experimenter instruction + }, + + flowModFlags: { + sendFlowRem: 1 << 0, // Send flow removed message when flow expires or is deleted + checkOverlap: 1 << 1, // Check for overlapping entries first + }, + + // Group numbering. Groups can use any number up to OFPG_MAX + group: { + // Last usable group number + max: 0xffffff00, + + // Fake groups + all: 0xfffffffc, // Represents all groups for group delete commands + any: 0xffffffff, + /* Wildcard group used only for flow stats requests. + * Selects all flows regardless of group (including flows with no group). + */ + }, + + // Values in the range [128, 255] are reserved for experimental use. + groupType: { + all: 0, // All (multicast/broadcast) group + select: 1, // Select group + indirect: 2, // Indirect group + ff: 3, // Fast failover group + }, + + flowRemovedReason: { + idleTimeout: 0, // Flow idle time exceeded idle_timeout + hardTimeout: 1, // Time exceeded hard_timeout + delete: 2, // Evicted by a DELETE flow mod + groupDelete: 3, // Group was removed + }, + + /* Values for 'type' in ofp_error_message. These values are immutable: they + * will not change in future versions of the protocol (although new values may + * be added). */ + errorType: { + helloFailed: 0, // Hello protocol failed + badRequest: 1, // Request was not understood + badAction: 2, // Error in action description + badInstruction: 3, // Error in instruction list + badMatch: 4, // Error in match + flowModFailed: 5, // Problem modifying flow entry + groupModFailed: 6, // Problem modifying group entry + portModFailed: 7, // Port mod request failed + tableModFailed: 8, // Table mod request failed + queueOpFailed: 9, // Queue operation failed + switchConfigFailed: 10, // Switch config request failed + }, + + /* ofp_error_msg 'code' values for OFPET_HELLO_FAILED. 'data' contains an + * ASCII text string that may give failure details. */ + helloFailedCode: { + incompatible: 0, // No compatible version + eperm: 1, // Permissions error + }, + + /* ofp_error_msg 'code' values for OFPET_BAD_REQUEST. 'data' contains at least + * the first 64 bytes of the failed request. */ + badRequestCode: { + badVersion: 0, // ofp_header.version not supported + badType: 1, // ofp_header.type not supported + badStat: 2, // ofp_stats_request.type not supported + badExperimenter: 3, // Experimenter id not supported (in ofp_experimenter_header or ofp_stats_request or ofp_stats_reply) + badSubtype: 4, // Experimenter subtype not supported + eperm: 5, // Permissions error + badLen: 6, // Wrong request length for type + bufferEmpty: 7, // Specified buffer has already been used + bufferUnknown: 8, // Specified buffer does not exist + badTableId: 9, // Specified table-id invalid or does not exist + }, + + /* ofp_error_msg 'code' values for OFPET_BAD_ACTION. 'data' contains at least + * the first 64 bytes of the failed request. */ + badActionCode: { + badType: 0, // Unknown action type + badLen: 1, // Length problem in actions + badExperimenter: 2, // Unknown experimenter id specified + badExperimenterType: 3, // Unknown action type for experimenter id + badOutPort: 4, // Problem validating output port + badArgument: 5, // Bad action argument + eperm: 6, // Permissions error + tooMany: 7, // Can't handle this many actions + badQueue: 8, // Problem validating output queue + badOutGroup: 9, // Invalid group id in forward action + matchInconsistent: 10, // Action can't apply for this match + unsuportedOrder: 11, // Action order is unsupported for the action list in an Apply-Actions instruction + badTag: 12, // Actions uses an unsupported tag/encap + }, + + /* ofp_error_msg 'code' values for OFPET_BAD_INSTRUCTION. 'data' contains at least + * the first 64 bytes of the failed request. */ + badInstructionCode: { + unknownInst: 0, // Unknown instruction + unsupInst: 1, // Switch or table does not support the instruction + badTableId: 2, // Invalid Table-ID specified + unsupMetadata: 3, // Metadata value unsupported by datapath + unsupMetadataMask: 4, // Metadata mask value unsupported by datapath + unsupExpInst: 5, // Specific experimenter instruction unsupported + }, + + /* ofp_error_msg 'code' values for OFPET_BAD_MATCH. 'data' contains at least + * the first 64 bytes of the failed request. */ + badMatchCode: { + badType: 0, // Unsupported match type specified by the match/ + badLen: 1, // Length problem in match + badTag: 2, // Match uses an unsupported tag/encap + badDlAddrMask: 3, + /* Unsupported datalink addr mask - switch does + * not support arbitrary datalink address mask + */ + badNwAddrMask: 4, + /* Unsupported network addr mask - switch does + * not support arbitrary network address mask + */ + badWildcards: 5, // Unsupported wildcard specified in the match + badField: 6, // Unsupported field in the match + badValue: 7, // Unsupported value in a match field + }, + + /* ofp_error_msg 'code' values for OFPET_FLOW_MOD_FAILED. 'data' contains + * at least the first 64 bytes of the failed request. */ + flowModFailedCode: { + unknown: 0, // Unspecified error + tableFull: 1, // Flow not added because table was full + badTableId: 2, // Table does not exist + overlap: 3, // Attempted to add overlapping flow with CHECK_OVERLAP flag set + eperm: 4, // Permissions error + badTimeout: 5, // Flow not added because of unsupported idle/hard timeout + badCommand: 6, // Unsupported or unknown command + }, + + /* ofp_error_msg 'code' values for OFPET_GROUP_MOD_FAILED. 'data' contains + * at least the first 64 bytes of the failed request. */ + groupModFailedCode: { + groupExists: 0, + /* Group not added because a group ADD + * attempted to replace an already-present group. + */ + invalidGroup: 1, // Group not added because Group specified is invalid + weightUnsupported: 2, // Switch does not support unequal load sharing with select groups + outOfGroups: 3, // The group table is full + outOfBuckets: 4, // The maximum number of action buckets for a group has been exceeded + chainingUnsupported: 5, // Switch does not support groups that forward to groups + watchUnsupported: 6, // This group cannot watch the watch_port or watch_group specified + loop: 7, // Group entry would cause a loop + unknownGroup: 8, + /* Group not modified because a group + * MODIFY attempted to modify a non-existent group. + */ + }, + + /* ofp_error_msg 'code' values for OFPET_PORT_MOD_FAILED. 'data' contains + * at least the first 64 bytes of the failed request. */ + portModFailedCode: { + badPort: 0, // Specified port number does not exist + badHwAddr: 1, // Specified hardware address does not match the port number + badConfig: 2, // Specified config is invalid + badAdvertise: 3, // Specified advertise is invalid + }, + + /* ofp_error_msg 'code' values for OFPET_TABLE_MOD_FAILED. 'data' contains + * at least the first 64 bytes of the failed request. */ + tableModFailedCode: { + badTable: 0, // Specified table does not exist + badConfig: 1, // Specified config is invalid + }, + + /* ofp_error msg 'code' values for OFPET_QUEUE_OP_FAILED. 'data' contains + * at least the first 64 bytes of the failed request */ + queueOpFailedCode: { + badPort: 0, // Invalid port (or port does not exist) + badQueue: 1, // Queue does not exist + eperm: 2, // Permissions error + }, + + /* ofp_error_msg 'code' values for OFPET_SWITCH_CONFIG_FAILED. 'data' contains + * at least the first 64 bytes of the failed request. */ + switchConfigFailedCode: { + badFlags: 0, // Specified flags is invalid + badLen: 1, // Specified len is invalid + }, + + statsType: { + /* Description of this OpenFlow switch. + * The request body is empty. + * The reply body is struct ofp_desc_stats. */ + descr: 0, + + /* Individual flow statistics. + * The request body is struct ofp_flow_stats_request. + * The reply body is an array of struct ofp_flow_stats. */ + flow: 1, + + /* Aggregate flow statistics. + * The request body is struct ofp_aggregate_stats_request. + * The reply body is struct ofp_aggregate_stats_reply. */ + aggregate: 2, + + /* Flow table statistics. + * The request body is empty. + * The reply body is an array of struct ofp_table_stats. */ + table: 3, + + /* Port statistics. + * The request body is struct ofp_port_stats_request. + * The reply body is an array of struct ofp_port_stats. */ + port: 4, + + /* Queue statistics for a port + * The request body defines the port + * The reply body is an array of struct ofp_queue_stats */ + queue: 5, + + /* Group counter statistics. + * The request body is empty. + * The reply is struct ofp_group_stats. */ + group: 6, + + /* Group description statistics. + * The request body is empty. + * The reply body is struct ofp_group_desc_stats. */ + groupDesc: 7, + + /* Experimenter extension. + * The request and reply bodies begin with a 32-bit experimenter ID, + * which takes the same form as in struct "ofp_experimenter_header". + * The request and reply bodies are otherwise experimenter-defined. */ + experimenter: 0xffff, + }, + + statsReplyFlags: { + replyMore: 1 << 0, // More replies to follow + }, + + descStrLen: 256, + serialNumLen: 32, + + // Flow match fields + flowMatchFields: { + inPort: 1 << 0, // Switch input port + dlVlan: 1 << 1, // VLAN id + dlVlanPcp: 1 << 2, // VLAN priority + dlType: 1 << 3, // Ethernet frame type + nwTos: 1 << 4, // IP ToS (DSCP field, 6 bits) + nwProto: 1 << 5, // IP protocol + tpSrc: 1 << 6, // TCP/UDP/SCTP source port + tpDst: 1 << 7, // TCP/UDP/SCTP destination port + mplsLabel: 1 << 8, // MPLS label + mplsTc: 1 << 9, // MPLS TC + type: 1 << 10, // Match type + dlSrc: 1 << 11, // Ethernet source address + dlDst: 1 << 12, // Ethernet destination address + nwSrc: 1 << 13, // IP source address + nwDst: 1 << 14, // IP destination address + metadata: 1 << 15, // Metadata passed between tables + }, + + // All ones is used to indicate all queues in a port (for stats retrieval) + queueAll: 0xffffffff, + + // Min rate > 1000 means not configured + queueMinRateUncfg: 0xffff, + + queueProperties: { + none: 0, // No property defined for queue (default) + minRate: 1, // Minimum datarate guaranteed + /* Other types should be added here + * (i.e. max rate, precedence, etc). */ + }, + + table: { + max: 254, + all: 255, + }, + + sizes: { + header: 8, + hello: 8, + featuresRequest: 8, + switchConfig: 12, + tableMod: 16, + port: 64, + switchFeatures: 32, + portStatus: 80, + portMod: 40, + packetIn: 24, + actionOutput: 16, + actionVlanId: 8, + actionVlanPcp: 8, + actionDlAddr: 16, + actionNwAddr: 8, + actionTpPort: 8, + actionNwTos: 8, + actionNwEcn: 8, + actionMplsLabel: 8, + actionMplsTc: 8, + actionMplsTtl: 8, + actionPush: 8, + actionPopMpls: 8, + actionGroup: 8, + actionNwTtl: 8, + actionExperimenterHeader: 8, + actionHeader: 8, + packetOut: 24, + match: 88, + instruction: 8, + instructionGotoTable: 8, + instructionWriteMetadata: 24, + instructionActions: 8, + instructionExperimenter: 8, + flowMod: 136, + bucket: 16, + groupMod: 16, + flowRemoved: 136, + errorMsg: 12, + statsRequest: 16, + statsReply: 16, + descStats: 1056, + flowStatsRequest: 120, + flowStats: 136, + aggregateStatsRequest: 120, + aggregateStatsReply: 24, + tableStats: 88, + portStatsRequest: 8, + portStats: 104, + groupStatsRequest: 8, + bucketCounter: 16, + groupStats: 32, + groupDescStats: 8, + experimenterHeader: 16, + queuePropHeader: 8, + queuePropMinRate: 16, + packetQueue: 8, + queueGetConfigRequest: 16, + queueGetConfigReply: 16, + actionSetQueue: 8, + queueStatsRequest: 8, + queueStats: 32, + }, + + offsets: { + header: { + version: 0, + type: 1, + length: 2, + xid: 4, + }, + + hello: { + header: 0, + }, + + echo: { + header: 0, + data: 8, + }, + + switchConfig: { + header: 0, + flags: 8, + missSendLen: 10, + }, + + tableMod: { + header: 0, + tableId: 8, + pad: 9, + config: 12, + }, + + port: { + portNo: 0, + pad: 4, + hwAddr: 8, + pad2: 14, + name: 16, + config: 32, + state: 36, + curr: 40, + advertised: 44, + supported: 48, + peer: 52, + currSpeed: 56, + maxSpeed: 60, + }, + + switchFeatures: { + header: 0, + datapathId: 8, + nBuffers: 16, + nTables: 20, + pad: 21, + capabilities: 24, + reserved: 28, + ports: 32, + }, + + portStatus: { + header: 0, + reason: 8, + pad: 9, + desc: 16, + }, + + portMod: { + header: 0, + portNo: 8, + pad: 12, + hwAddr: 16, + pad2: 22, + config: 24, + mask: 28, + advertise: 32, + pad3: 36, + }, + + packetIn: { + header: 0, + bufferId: 8, + inPort: 12, + inPhyPort: 16, + totalLen: 20, + reason: 22, + tableId: 23, + data: 24, + }, + + actionOutput: { + type: 0, + len: 2, + port: 4, + maxLen: 8, + pad: 10, + }, + + actionVlanId: { + type: 0, + len: 2, + vlanId: 4, + pad: 6, + }, + + actionVlanPcp: { + type: 0, + len: 2, + vlanPcp: 4, + pad: 5, + }, + + actionDlAddr: { + type: 0, + len: 2, + dlAddr: 4, + pad: 10, + }, + + actionNwAddr: { + type: 0, + len: 2, + nwAddr: 4, + }, + + actionTpPort: { + type: 0, + len: 2, + tpPort: 4, + pad: 6, + }, + + actionNwTos: { + type: 0, + len: 2, + nwTos: 4, + pad: 5, + }, + + actionNwEcn: { + type: 0, + len: 2, + nwEcn: 4, + pad: 5, + }, + + actionMplsLabel: { + type: 0, + len: 2, + mplsLabel: 4, + }, + + actionMplsTc: { + type: 0, + len: 2, + mplsTc: 4, + pad: 5, + }, + + actionMplsTtl: { + type: 0, + len: 2, + mplsTtl: 4, + pad: 5, + }, + + actionPush: { + type: 0, + len: 2, + ethertype: 4, + pad: 6, + }, + + actionPopMpls: { + type: 0, + len: 2, + ethertype: 4, + pad: 6, + }, + + actionGroup: { + type: 0, + len: 2, + groupId: 4, + }, + + actionNwTtl: { + type: 0, + len: 2, + nwTtl: 4, + pad: 5, + }, + + actionExperimenterHeader: { + type: 0, + len: 2, + experimenter: 4, + }, + + actionHeader: { + type: 0, + len: 2, + pad: 4, + }, + + packetOut: { + header: 0, + bufferId: 8, + inPort: 12, + actionsLen: 16, + pad: 18, + actions: 24, + }, + + match: { + type: 0, + length: 2, + inPort: 4, + wildcards: 8, + dlSrc: 12, + dlSrcMask: 18, + dlDst: 24, + dlDstMask: 30, + dlVlan: 36, + dlVlanPcp: 38, + pad1: 39, + dlType: 40, + nwTos: 42, + nwProto: 43, + nwSrc: 44, + nwSrcMask: 48, + nwDst: 52, + nwDstMask: 56, + tpSrc: 60, + tpDst: 62, + mplsLabel: 64, + mplsTc: 68, + pad2: 69, + metadata: 72, + metadataMask: 80, + }, + + instruction: { + type: 0, + len: 2, + pad: 4, + }, + + instructionGotoTable: { + type: 0, + len: 2, + tableId: 4, + pad: 5, + }, + + instructionWriteMetadata: { + type: 0, + len: 2, + pad: 4, + metadata: 8, + metadataMask: 16, + }, + + instructionActions: { + type: 0, + len: 2, + pad: 4, + actions: 8, + }, + + instructionExperimenter: { + type: 0, + len: 2, + experimenter: 4, + }, + + flowMod: { + header: 0, + cookie: 8, + cookieMask: 16, + tableId: 24, + command: 25, + idleTimeout: 26, + hardTimeout: 28, + priority: 30, + bufferId: 32, + outPort: 36, + outGroup: 40, + flags: 44, + pad: 46, + match: 48, + instructions: 136, + }, + + bucket: { + len: 0, + weight: 2, + watchPort: 4, + watchGroup: 8, + pad: 12, + actions: 16, + }, + + groupMod: { + header: 0, + command: 8, + type: 10, + pad: 11, + groupId: 12, + buckets: 16, + }, + + flowRemoved: { + header: 0, + cookie: 8, + priority: 16, + reason: 18, + tableId: 19, + durationSec: 20, + durationNsec: 24, + idleTimeout: 28, + pad2: 30, + packetCount: 32, + byteCount: 40, + match: 48, + }, + + errorMsg: { + header: 0, + type: 8, + code: 10, + data: 12, + }, + + statsRequest: { + header: 0, + type: 8, + flags: 10, + pad: 12, + body: 16, + }, + + statsReply: { + header: 0, + type: 8, + flags: 10, + pad: 12, + body: 16, + }, + + descStats: { + mfrDesc: 0, + hwDesc: 256, + swDesc: 512, + serialNum: 768, + dpDesc: 800, + }, + + flowStatsRequest: { + tableId: 0, + pad: 1, + outPort: 4, + outGroup: 8, + pad2: 12, + cookie: 16, + cookieMask: 24, + match: 32, + }, + + flowStats: { + length: 0, + tableId: 2, + pad: 3, + durationSec: 4, + durationNsec: 8, + priority: 12, + idleTimeout: 14, + hardTimeout: 16, + pad2: 18, + cookie: 24, + packetCount: 32, + byteCount: 40, + match: 48, + instructions: 136, + }, + + aggregateStatsRequest: { + tableId: 0, + pad: 1, + outPort: 4, + outGroup: 8, + pad2: 12, + cookie: 16, + cookieMask: 24, + match: 32, + }, + + aggregateStatsReply: { + packetCount: 0, + byteCount: 8, + flowCount: 16, + pad: 20, + }, + + tableStats: { + tableId: 0, + pad: 1, + name: 8, + wildcards: 40, + match: 44, + instructions: 48, + writeActions: 52, + applyActions: 56, + config: 60, + maxEntries: 64, + activeCount: 68, + lookupCount: 72, + matchedCount: 80, + }, + + portStatsRequest: { + portNo: 0, + pad: 4, + }, + + portStats: { + portNo: 0, + pad: 4, + rxPackets: 8, + txPackets: 16, + rxBytes: 24, + txBytes: 32, + rxDropped: 40, + txDropped: 48, + rxErrors: 56, + txErrors: 64, + rxFrameErr: 72, + rxOverErr: 80, + rxCrcErr: 88, + collisions: 96, + }, + + groupStatsRequest: { + groupId: 0, + pad: 4, + }, + + bucketCounter: { + packetCount: 0, + byteCount: 8, + }, + + groupStats: { + length: 0, + pad: 2, + groupId: 4, + refCount: 8, + pad2: 12, + packetCount: 16, + byteCount: 24, + bucketStats: 32, + }, + + groupDescStats: { + length: 0, + type: 2, + pad: 3, + groupId: 4, + buckets: 8, + }, + + experimenterHeader: { + header: 0, + experimenter: 8, + pad: 12, + }, + + queuePropHeader: { + property: 0, + len: 2, + pad: 4, + }, + + queuePropMinRate: { + propHeader: 0, + rate: 8, + pad: 10, + }, + + packetQueue: { + queueId: 0, + len: 4, + pad: 6, + properties: 8, + }, + + queueGetConfigRequest: { + header: 0, + port: 8, + pad: 12, + }, + + queueGetConfigReply: { + header: 0, + port: 8, + pad: 12, + queues: 16, + }, + + actionSetQueue: { + type: 0, + len: 2, + queueId: 4, + }, + + queueStatsRequest: { + portNo: 0, + queueId: 4, + }, + + queueStats: { + portNo: 0, + queueId: 4, + txBytes: 8, + txPackets: 16, + txErrors: 24, + }, + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/struct/match/match.js b/@xen-orchestra/openflow/src/openflow-11/struct/match/match.js new file mode 100644 index 000000000..367e97395 --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/struct/match/match.js @@ -0,0 +1,374 @@ +import assert from 'assert' +import addressParser from '../../../util/addrress-parser' +import uIntHelper from '../../../util/uint-helper' +import of from '../../openflow-11' + +// ============================================================================= + +const OFFSETS = of.offsets.match +const WILDCARDS = of.flowWildcards + +const IP4_ADDR_LEN = 4 +const METADATA_LENGTH = 8 +const PAD_LENGTH = 1 +const PAD2_LENGTH = 3 + +// ============================================================================= + +export default { + pack: (object, buffer = undefined, offset = 0) => { + assert(object.type === of.matchType.standard) + object.length = of.sizes.match + buffer = buffer !== undefined ? buffer : Buffer.alloc(object.length) + + buffer.writeUInt16BE(object.type, offset + OFFSETS.type) + buffer.writeUInt16BE(object.length, offset + OFFSETS.length) + + let wildcards = 0 + let inPort = 0 + if (object.in_port !== undefined) { + inPort = object.in_port + } else { + wildcards |= WILDCARDS.inPort + } + buffer.writeUInt32BE(inPort, offset + OFFSETS.inPort) + + if (object.dl_src !== undefined) { + if (object.dl_src_mask !== undefined) { + addressParser.stringToEth( + object.dl_src_mask, + buffer, + offset + OFFSETS.dlSrcMask + ) + } else { + buffer.fill( + 0x00, + offset + OFFSETS.dlSrcMask, + offset + OFFSETS.dlSrcMask + of.ethAddrLen + ) + } + addressParser.stringToEth(object.dl_src, buffer, offset + OFFSETS.dlSrc) + } else { + buffer.fill( + 0x00, + offset + OFFSETS.dlSrc, + offset + OFFSETS.dlSrc + of.ethAddrLen + ) + buffer.fill( + 0xff, + offset + OFFSETS.dlSrcMask, + offset + OFFSETS.dlSrcMask + of.ethAddrLen + ) + } + + if (object.dl_dst !== undefined) { + if (object.dl_dst_mask !== undefined) { + addressParser.stringToEth( + object.dl_dst_mask, + buffer, + offset + OFFSETS.dlDstMask + ) + } else { + buffer.fill( + 0x00, + offset + OFFSETS.dlDstMask, + offset + OFFSETS.dlDstMask + of.ethAddrLen + ) + } + addressParser.stringToEth(object.dl_dst, buffer, offset + OFFSETS.dlDst) + } else { + buffer.fill( + 0x00, + offset + OFFSETS.dlDst, + offset + OFFSETS.dlDst + of.ethAddrLen + ) + buffer.fill( + 0xff, + offset + OFFSETS.dlDstMask, + offset + OFFSETS.dlDstMask + of.ethAddrLen + ) + } + + let dlVlan = 0 + if (object.dl_vlan !== undefined) { + dlVlan = object.dl_vlan + } else { + wildcards |= WILDCARDS.dlVlan + } + buffer.writeUInt16BE(dlVlan, offset + OFFSETS.dlVlan) + + let dlVlanPcp = 0 + if (object.dl_vlan_pcp !== undefined) { + dlVlanPcp = object.dl_vlan_pcp + } else { + wildcards |= WILDCARDS.dlVlanPcp + } + buffer.writeUInt8(dlVlanPcp, offset + OFFSETS.dlVlanPcp) + + buffer.fill(0, offset + OFFSETS.pad1, offset + OFFSETS.pad1 + PAD_LENGTH) + + let dlType = 0 + if (object.dl_type !== undefined) { + dlType = object.dl_type + } else { + wildcards |= WILDCARDS.dlType + } + buffer.writeUInt16BE(dlType, offset + OFFSETS.dlType) + + let nwTos = 0 + if (object.nw_tos !== undefined) { + nwTos = object.nw_tos + } else { + wildcards |= WILDCARDS.nwTos + } + buffer.writeUInt8(nwTos, offset + OFFSETS.nwTos) + + let nwProto = 0 + if (object.nw_proto !== undefined) { + nwProto = object.nw_proto + } else { + wildcards |= WILDCARDS.nwProto + } + buffer.writeUInt8(nwProto, offset + OFFSETS.nwProto) + + if (object.nw_src !== undefined) { + if (object.nw_src_mask !== undefined) { + addressParser.stringToip4( + object.nw_src_mask, + buffer, + offset + OFFSETS.nwSrcMask + ) + } else { + buffer.fill( + 0x00, + offset + OFFSETS.nwSrcMask, + offset + OFFSETS.nwSrcMask + IP4_ADDR_LEN + ) + } + addressParser.stringToip4(object.nw_src, buffer, offset + OFFSETS.nwSrc) + } else { + buffer.fill( + 0x00, + offset + OFFSETS.nwSrc, + offset + OFFSETS.nwSrc + IP4_ADDR_LEN + ) + buffer.fill( + 0xff, + offset + OFFSETS.nwSrcMask, + offset + OFFSETS.nwSrcMask + IP4_ADDR_LEN + ) + } + + if (object.nw_dst !== undefined) { + if (object.nw_dst_mask !== undefined) { + addressParser.stringToip4( + object.nw_dst_mask, + buffer, + offset + OFFSETS.nwDstMask + ) + } else { + buffer.fill( + 0x00, + offset + OFFSETS.nwDstMask, + offset + OFFSETS.nwDstMask + IP4_ADDR_LEN + ) + } + addressParser.stringToip4(object.nw_dst, buffer, offset + OFFSETS.nwDst) + } else { + buffer.fill( + 0x00, + offset + OFFSETS.nwDst, + offset + OFFSETS.nwDst + IP4_ADDR_LEN + ) + buffer.fill( + 0xff, + offset + OFFSETS.nwDstMask, + offset + OFFSETS.nwDstMask + IP4_ADDR_LEN + ) + } + + let tpSrc = 0 + if (object.tp_src !== undefined) { + tpSrc = object.tp_src + } else { + wildcards |= WILDCARDS.tpSrc + } + buffer.writeUInt16BE(tpSrc, offset + OFFSETS.tpSrc) + + let tpDst = 0 + if (object.tp_dst !== undefined) { + tpDst = object.tp_dst + } else { + wildcards |= WILDCARDS.tpDst + } + buffer.writeUInt16BE(tpDst, offset + OFFSETS.tpDst) + + let mplsLabel = 0 + if (object.mpls_label !== undefined) { + mplsLabel = object.mpls_label + } else { + wildcards |= WILDCARDS.mplsLabel + } + buffer.writeUInt32BE(mplsLabel, offset + OFFSETS.mplsLabel) + + let mplsTc = 0 + if (object.mpls_tc !== undefined) { + mplsTc = object.mpls_tc + } else { + wildcards |= WILDCARDS.mplsTc + } + buffer.writeUInt8(mplsTc, offset + OFFSETS.mplsTc) + + buffer.fill(0, offset + OFFSETS.pad2, offset + OFFSETS.pad2 + PAD2_LENGTH) + + if (object.metadata !== undefined) { + if (object.metadata_mask !== undefined) { + buffer.copy( + object.metadata_mask, + 0, + offset + OFFSETS.metadataMask, + offset + OFFSETS.metadataMask + METADATA_LENGTH + ) + } else { + buffer.fill( + 0x00, + offset + OFFSETS.metadataMask, + offset + OFFSETS.metadataMask + METADATA_LENGTH + ) + } + buffer.copy( + object.metadata, + 0, + offset + OFFSETS.metadata, + offset + OFFSETS.metadata + METADATA_LENGTH + ) + } else { + buffer.fill( + 0x00, + offset + OFFSETS.metadata, + offset + OFFSETS.metadata + METADATA_LENGTH + ) + buffer.fill( + 0xff, + offset + OFFSETS.metadataMask, + offset + OFFSETS.metadataMask + METADATA_LENGTH + ) + } + + buffer.writeUInt32BE(wildcards, offset + OFFSETS.wildcards) + + return buffer + }, + + unpack: (buffer, offset = 0) => { + const object = {} + object.type = buffer.readUInt16BE(offset + OFFSETS.type) + assert(object.type === of.matchType.standard) + + object.length = buffer.readUInt16BE(offset + OFFSETS.length) + assert(object.length === of.sizes.match) + + // Wildcards indicate which value to use for the match. + // if `wildcards & of.wildcards.` === 0 then `value` is not wildcarded and must be used. + const wildcards = (object.wildcards = buffer.readUInt32BE( + offset + OFFSETS.wildcards + )) + if ((wildcards & WILDCARDS.inPort) === 0) { + object.in_port = buffer.readUInt32BE(offset + OFFSETS.inPort) + } + + if (!addressParser.isEthMaskAll(buffer, offset + OFFSETS.dlSrcMask)) { + if (!addressParser.isEthMaskNone(buffer, offset + OFFSETS.dlSrcMask)) { + object.dl_src_mask = addressParser.ethToString( + buffer, + offset + OFFSETS.dlSrcMask + ) + } + object.dl_src = addressParser.ethToString(buffer, offset + OFFSETS.dlSrc) + } + if (!addressParser.isEthMaskAll(buffer, offset + OFFSETS.dlDstMask)) { + if (!addressParser.isEthMaskNone(buffer, offset + OFFSETS.dlDstMask)) { + object.dl_dst_mask = addressParser.ethToString( + buffer, + offset + OFFSETS.dlDstMask + ) + } + object.dl_dst = addressParser.ethToString(buffer, offset + OFFSETS.dlDst) + } + + if ((wildcards & WILDCARDS.dlVlan) === 0) { + object.dl_vlan = buffer.readUInt16BE(offset + OFFSETS.dlVlan) + } + if ((wildcards & WILDCARDS.dlVlanPcp) === 0) { + object.dl_vlan_pcp = buffer.readUInt16BE(offset + OFFSETS.dlVlanPcp) + } + if ((wildcards & WILDCARDS.dlType) === 0) { + object.dl_type = buffer.readUInt16BE(offset + OFFSETS.dlType) + } + + if ((wildcards & WILDCARDS.nwTos) === 0) { + object.nw_tos = buffer.readUInt8(offset + OFFSETS.nwTos) + } + if ((wildcards & WILDCARDS.nwProto) === 0) { + object.nw_proto = buffer.readUInt8(offset + OFFSETS.nwProto) + } + + if (!addressParser.isIp4MaskAll(buffer, offset + OFFSETS.nwSrcMask)) { + if (!addressParser.isIp4MaskNone(buffer, offset + OFFSETS.nwSrcMask)) { + object.nw_src_mask = addressParser.ip4ToString( + buffer, + offset + OFFSETS.nwSrcMask + ) + } + object.nw_src = addressParser.ip4ToString(buffer, offset + OFFSETS.nwSrc) + } + if (!addressParser.isIp4MaskAll(buffer, offset + OFFSETS.nwDstMask)) { + if (!addressParser.isIp4MaskNone(buffer, offset + OFFSETS.nwDstMask)) { + object.nw_dst_mask = addressParser.ip4ToString( + buffer, + offset + OFFSETS.nwDstMask + ) + } + object.nw_dst = addressParser.ip4ToString(buffer, offset + OFFSETS.nwDst) + } + + if ((wildcards & WILDCARDS.tpSrc) === 0) { + object.tp_src = buffer.readUInt16BE(offset + OFFSETS.tpSrc) + } + if ((wildcards & WILDCARDS.tpDst) === 0) { + object.tp_dst = buffer.readUInt16BE(offset + OFFSETS.tpDst) + } + + if ((wildcards & WILDCARDS.mplsLabel) === 0) { + object.mpls_label = buffer.readUInt32BE(offset + OFFSETS.mplsLabel) + } + if ((wildcards & WILDCARDS.mplsTc) === 0) { + object.mpls_tc = buffer.readUInt32BE(offset + OFFSETS.mplsTc) + } + + const metadataMask = [ + buffer.readUInt32BE(offset + OFFSETS.metadataMask), + buffer.readUInt32BE(offset + OFFSETS.metadataMask + METADATA_LENGTH / 2), + ] + if (!uIntHelper.isUInt64All(metadataMask)) { + if (!uIntHelper.isUInt64None(metadataMask)) { + object.metadata_mask = Buffer.alloc(METADATA_LENGTH) + buffer.copy( + object.metadata_mask, + 0, + offset + OFFSETS.metadataMask, + offset + OFFSETS.metadataMask + METADATA_LENGTH + ) + } + object.metadata = Buffer.alloc(METADATA_LENGTH) + buffer.copy( + object.metadata, + 0, + offset + OFFSETS.metadata, + offset + OFFSETS.metadata + METADATA_LENGTH + ) + } + + return object + }, +} diff --git a/@xen-orchestra/openflow/src/openflow-11/struct/port.js b/@xen-orchestra/openflow/src/openflow-11/struct/port.js new file mode 100644 index 000000000..fec44e4cd --- /dev/null +++ b/@xen-orchestra/openflow/src/openflow-11/struct/port.js @@ -0,0 +1,79 @@ +import of from '../openflow-11' +import addressParser from '../../util/addrress-parser' + +// ============================================================================= + +const OFFSETS = of.offsets.port +const PAD_LENGTH = 4 +const PAD2_LENGTH = 2 + +// ============================================================================= + +export default { + pack: (object, buffer = undefined, offset = 0) => { + buffer = buffer !== undefined ? buffer : Buffer.alloc(of.sizes.port) + const { + port_no: portNo, + hw_addr: hwAddr, + name, + config, + state, + curr, + advertised, + supported, + peer, + curr_speed: currSpeed, + max_speed: maxSpeed, + } = object + + buffer.writeUInt32BE(portNo, offset + OFFSETS.portNo) + buffer.fill(0, offset + OFFSETS.pad, offset + OFFSETS.pad + PAD_LENGTH) + addressParser.stringToEth(hwAddr, buffer, offset + OFFSETS.hwAddr) + buffer.fill(0, offset + OFFSETS.pad, offset + OFFSETS.pad + PAD2_LENGTH) + buffer.write(name, offset + OFFSETS.name, of.maxPortNameLen) + if (name.length < of.maxPortNameLen) { + buffer.fill( + 0, + offset + OFFSETS.name + name.length, + offset + OFFSETS.name + of.maxPortNameLen + ) + } + + buffer.writeUInt32BE(config, offset + OFFSETS.config) + buffer.writeUInt32BE(state, offset + OFFSETS.state) + buffer.writeUInt32BE(curr, offset + OFFSETS.curr) + buffer.writeUInt32BE(advertised, offset + OFFSETS.advertised) + buffer.writeUInt32BE(supported, offset + OFFSETS.supported) + buffer.writeUInt32BE(peer, offset + OFFSETS.peer) + buffer.writeUInt32BE(currSpeed, offset + OFFSETS.currSpeed) + buffer.writeUInt32BE(maxSpeed, offset + OFFSETS.maxSpeed) + return buffer + }, + + unpack: (buffer, offset = 0) => { + const body = {} + + body.port_no = buffer.readUInt32BE(offset + OFFSETS.portNo) + body.hw_addr = addressParser.ethToString(buffer, offset + OFFSETS.hwAddr) + + const name = buffer.toString( + 'utf8', + offset + OFFSETS.name, + offset + OFFSETS.name + of.maxPortNameLen + ) + body.name = name.substr(0, name.indexOf('\0')) // Remove useless 0 if name.length < of.maxPortNameLen + + body.config = buffer.readUInt32BE(offset + OFFSETS.config) + body.state = buffer.readUInt32BE(offset + OFFSETS.state) + + body.curr = buffer.readUInt32BE(offset + OFFSETS.curr) + body.advertised = buffer.readUInt32BE(offset + OFFSETS.advertised) + body.supported = buffer.readUInt32BE(offset + OFFSETS.supported) + body.peer = buffer.readUInt32BE(offset + OFFSETS.peer) + + body.curr_speed = buffer.readUInt32BE(offset + OFFSETS.currSpeed) + body.max_speed = buffer.readUInt32BE(offset + OFFSETS.maxSpeed) + + return body + }, +} diff --git a/@xen-orchestra/openflow/src/parse-socket.js b/@xen-orchestra/openflow/src/parse-socket.js new file mode 100644 index 000000000..9bcb909cc --- /dev/null +++ b/@xen-orchestra/openflow/src/parse-socket.js @@ -0,0 +1,45 @@ +import assert from 'assert' + +import of from './index' +import scheme from './default-header-scheme' +import { readChunk } from '@vates/read-chunk' + +// ============================================================================= + +export default async function* parse(socket) { + let buffer = Buffer.alloc(1024) + let data + + // Read the header + while ((data = await readChunk(socket, scheme.size)) !== null) { + // Read OpenFlow message size from its header + const msgSize = data.readUInt16BE(scheme.offsets.length) + data.copy(buffer, 0, 0, scheme.size) + + if (buffer.length < msgSize) { + buffer = resize(buffer, msgSize) + } + + // Read the rest of the openflow message + if (msgSize > scheme.size) { + data = await readChunk(socket, msgSize - scheme.size) + assert.notStrictEqual(data, null) + data.copy(buffer, scheme.size, 0, msgSize - scheme.size) + } + + yield of.unpack(buffer) + } +} + +// ----------------------------------------------------------------------------- + +function resize(buffer, size) { + let newLength = buffer.length + do { + newLength *= 2 + } while (newLength < size) + + const newBuffer = Buffer.alloc(newLength) + buffer.copy(newBuffer) + return newBuffer +} diff --git a/@xen-orchestra/openflow/src/util/addrress-parser.js b/@xen-orchestra/openflow/src/util/addrress-parser.js new file mode 100644 index 000000000..06df2a7c1 --- /dev/null +++ b/@xen-orchestra/openflow/src/util/addrress-parser.js @@ -0,0 +1,64 @@ +import assert from 'assert' +import util from 'util' + +// ============================================================================= + +export default { + isEthMaskNone: (buffer, offset) => + buffer.readUInt32BE(offset) === 0x00000000 && + buffer.readUInt16BE(offset + 4) === 0x0000, + + isEthMaskAll: (buffer, offset) => + buffer.readUInt32BE(offset) === 0xffffffff && + buffer.readUInt16BE(offset + 4) === 0xffff, + + isIp4MaskNone: (buffer, offset) => buffer.readUInt32BE(offset) === 0x00000000, + + isIp4MaskAll: (buffer, offset) => buffer.readUInt32BE(offset) === 0xffffffff, + + ethToString: (buffer, offset) => + buffer.toString('hex', offset, offset + 1) + + ':' + + buffer.toString('hex', offset + 1, offset + 2) + + ':' + + buffer.toString('hex', offset + 2, offset + 3) + + ':' + + buffer.toString('hex', offset + 3, offset + 4) + + ':' + + buffer.toString('hex', offset + 4, offset + 5) + + ':' + + buffer.toString('hex', offset + 5, offset + 6), + + stringToEth: (string, buffer, offset) => { + const eth = /^([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2})$/.exec( + string + ) + assert(eth !== null) + buffer.writeUInt8(parseInt(eth[1], 16), offset) + buffer.writeUInt8(parseInt(eth[2], 16), offset + 1) + buffer.writeUInt8(parseInt(eth[3], 16), offset + 2) + buffer.writeUInt8(parseInt(eth[4], 16), offset + 3) + buffer.writeUInt8(parseInt(eth[5], 16), offset + 4) + buffer.writeUInt8(parseInt(eth[6], 16), offset + 5) + }, + + ip4ToString: (buffer, offset) => + util.format( + '%d.%d.%d.%d', + buffer.readUInt8(offset), + buffer.readUInt8(offset + 1), + buffer.readUInt8(offset + 2), + buffer.readUInt8(offset + 3) + ), + + stringToip4: (string, buffer, offset) => { + const ip = /^([1-9]?\d|1\d\d|2[0-4]\d|25[0-5])\.([1-9]?\d|1\d\d|2[0-4]\d|25[0-5])\.([1-9]?\d|1\d\d|2[0-4]\d|25[0-5])\.([1-9]?\d|1\d\d|2[0-4]\d|25[0-5])$/.exec( + string + ) + assert(ip !== null) + buffer.writeUInt8(parseInt(ip[1], 10), offset) + buffer.writeUInt8(parseInt(ip[2], 10), offset + 1) + buffer.writeUInt8(parseInt(ip[3], 10), offset + 2) + buffer.writeUInt8(parseInt(ip[4], 10), offset + 3) + }, +} diff --git a/@xen-orchestra/openflow/src/util/get-from-map.js b/@xen-orchestra/openflow/src/util/get-from-map.js new file mode 100644 index 000000000..c037edc9d --- /dev/null +++ b/@xen-orchestra/openflow/src/util/get-from-map.js @@ -0,0 +1,11 @@ +import assert from 'assert' + +export default function get(map, key, errorMsg = undefined) { + const value = map[String(key)] + assert.notStrictEqual( + value, + undefined, + errorMsg !== undefined ? errorMsg : `${key} is invalid` + ) + return value +} diff --git a/@xen-orchestra/openflow/src/util/uint-helper.js b/@xen-orchestra/openflow/src/util/uint-helper.js new file mode 100644 index 000000000..942f88359 --- /dev/null +++ b/@xen-orchestra/openflow/src/util/uint-helper.js @@ -0,0 +1,10 @@ +const ZERO = 0x00000000 +const ALL = 0xffffffff + +// ============================================================================= + +export default { + isUInt64None: n => n[0] === ZERO && n[1] === ZERO, + + isUInt64All: n => n[0] === ALL && n[1] === ALL, +} diff --git a/@xen-orchestra/openflow/src/version.js b/@xen-orchestra/openflow/src/version.js new file mode 100644 index 000000000..cc56baa26 --- /dev/null +++ b/@xen-orchestra/openflow/src/version.js @@ -0,0 +1,9 @@ +export default { + // TODO: more version + // openFlow10: 0x01, + openFlow11: 0x02, + // openFlow12: 0x03, + // openFlow13: 0x04, + // openFlow14: 0x05, + // openFlow15: 0x06, +} diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 74f2abfef..d7e0587dd 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -15,6 +15,7 @@ - [Home/VM, host] Ability to filter by power state (PR [#5118](https://github.com/vatesfr/xen-orchestra/pull/5118)) - [Import/OVA] Allow for VMDK disks inside .ova files to be gzipped (PR [#5085](https://github.com/vatesfr/xen-orchestra/pull/5085)) - [Proxy] Show pending upgrades (PR [#5167](https://github.com/vatesfr/xen-orchestra/pull/5167)) +- [SDN Controller] Add/Remove netork traffic rules for a VM's VIFs (PR [#5135](https://github.com/vatesfr/xen-orchestra/pull/5135)) ### Bug fixes @@ -42,6 +43,8 @@ > > In case of conflict, the highest (lowest in previous list) `$version` wins. +- @xen-orchestra/openflow patch +- xo-server-sdn-controller major - xo-vmdk-to-vhd minor - xo-server minor - xo-web minor diff --git a/docs/assets/add-rule.png b/docs/assets/add-rule.png new file mode 100644 index 0000000000000000000000000000000000000000..80888ecf294d9dd0464ebfdde704c9bfa23bb724 GIT binary patch literal 35433 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 H=-vMT+r~-c literal 0 HcmV?d00001 diff --git a/docs/assets/show-rules.png b/docs/assets/show-rules.png new file mode 100644 index 0000000000000000000000000000000000000000..6bf42bc637c9f40206f093e1df5a079b428a3d0b GIT binary patch literal 88817 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 literal 0 HcmV?d00001 diff --git a/docs/sdn_controller.md b/docs/sdn_controller.md index 0e8cf8783..b828bc03f 100644 --- a/docs/sdn_controller.md +++ b/docs/sdn_controller.md @@ -75,6 +75,32 @@ Encryption is not available prior to XCP-ng 8.0. ## OpenFlow rules +Please see the [devblog about OpenFlow rules](https://xen-orchestra.com/blog/vms-vif-network-traffic-control/). + +This feature requires the OpenFlow port to be opened + +In the VM network tab a new column has been added: _Network rules_. + +- The _Add rule_ button display a form to add a new rule choosing to: + - enable/disable the matching traffic + - for a specific protocol (optionnal) + - on a specific port (optionnal) + - matching a specific IP or IP range (optionnal) + - coming from the VIF / going to the VIF / both +- The _Show rules_ button allow to display all rules for a VIF. +- When the rules are display a button to delete a rule is available. + +![](./assets/add-rule.png) +![](./assets/show-rules.png) + :::tip -This feature is about to be released soon. Stay tuned! -::: + +- This feature requires the OpenFlow port (TCP 6653) to be opened. (See [the requirements](#openflow)) + ::: + +### Requirements + +### Openflow + +- On XCP-ng prior to 8.2: + - To be able to use `OpenFlow`, the following line needs to be added, if not already present, in `/etc/sysconfig/iptables` of all the hosts where `OpenFlow` is wanted: `-A xapi-INPUT -p udp -m conntrack --ctstate NEW -m tcp --dport 6653 -j ACCEPT` diff --git a/packages/xo-server-sdn-controller/package.json b/packages/xo-server-sdn-controller/package.json index bf1f3aed0..6b9434778 100644 --- a/packages/xo-server-sdn-controller/package.json +++ b/packages/xo-server-sdn-controller/package.json @@ -30,6 +30,9 @@ }, "dependencies": { "@xen-orchestra/log": "^0.2.0", + "@xen-orchestra/openflow": "^0.1.0", + "@vates/coalesce-calls": "^0.1.0", + "ipaddr.js": "^1.9.1", "lodash": "^4.17.11", "node-openssl-cert": "^0.0.117", "promise-toolbox": "^0.15.0", diff --git a/packages/xo-server-sdn-controller/src/index.js b/packages/xo-server-sdn-controller/src/index.js index a4369c037..68e8cbf54 100644 --- a/packages/xo-server-sdn-controller/src/index.js +++ b/packages/xo-server-sdn-controller/src/index.js @@ -8,8 +8,10 @@ import { filter, find, forOwn, map, omitBy } from 'lodash' import { fromCallback, promisify } from 'promise-toolbox' import { join } from 'path' +import { OpenFlowChannel } from './protocol/openflow-channel' import { OvsdbClient } from './protocol/ovsdb-client' import { PrivateNetwork } from './private-network/private-network' +import { TlsHelper } from './utils/tls-helper' // ============================================================================= @@ -48,6 +50,10 @@ export const configurationSchema = { // ============================================================================= +const noop = Function.prototype + +// ----------------------------------------------------------------------------- + const fileWrite = promisify(writeFile) const fileRead = promisify(readFile) async function fileExists(path) { @@ -312,6 +318,10 @@ class SDNController extends EventEmitter { - `status`: - `active`: `true` if the corresponding OpenVSwitch bridge is correctly configured and working - `key` : Corresponding OpenVSwitch bridge name (missing if `active` is `false`) + + Attributes on VIFs (OpenFlow entries): + - `other_config`: + - `xo:sdn-controller:of-rules`: A list of openflow entries to aply to this VIF */ constructor({ xo, getDataDir }) { @@ -335,6 +345,12 @@ class SDNController extends EventEmitter { this._prevVni = 0 this.ovsdbClients = {} + this.ofChannels = {} + + this._tlsHelper = new TlsHelper() + + this._handledTasks = [] + this._managed = [] } // --------------------------------------------------------------------------- @@ -369,10 +385,11 @@ class SDNController extends EventEmitter { fileRead(join(certDirectory, CLIENT_CERT)), fileRead(join(certDirectory, CA_CERT)), ]) - - forOwn(this.ovsdbClients, client => { - client.updateCertificates(this._clientKey, this._clientCert, this._caCert) - }) + this._tlsHelper.updateCertificates( + this._clientKey, + this._clientCert, + this._caCert + ) const updatedPools = [] await Promise.all( map(this.privateNetworks, async privateNetworks => { @@ -390,7 +407,8 @@ class SDNController extends EventEmitter { } async load() { - // Expose method to create private network + // ---------------- Private Network method --------------------------------- + const createPrivateNetwork = params => this._createPrivateNetwork({ encrypted: false, @@ -422,9 +440,39 @@ class SDNController extends EventEmitter { preferredCenterId: { type: 'string', optional: true }, } + // ---------------- OpenFlow rules method ---------------------------------- + + const addRule = params => this._addRule(params) + addRule.description = 'Add an ACL rule to a VIF' + addRule.params = { + allow: { type: 'boolean' }, + direction: { type: 'string' }, + ipRange: { type: 'string', optional: true }, + port: { type: 'integer', optional: true }, + protocol: { type: 'string', optional: true }, + vifId: { type: 'string' }, + } + addRule.permission = 'admin' + + const deleteRule = params => this._deleteRule(params) + deleteRule.description = 'Delete an ACL rule from a VIF' + deleteRule.params = { + direction: { type: 'string' }, + ipRange: { type: 'string', optional: true }, + port: { type: 'integer', optional: true }, + protocol: { type: 'string', optional: true }, + vifId: { type: 'string' }, + } + deleteRule.permission = 'admin' + + // ------------------------------------------------------------------------- + this._unsetApiMethods = this._xo.addApiMethods({ sdnController: { createPrivateNetwork, + + addRule, + deleteRule, }, }) @@ -456,6 +504,10 @@ class SDNController extends EventEmitter { this._cleaners = [] this.ovsdbClients = {} + this.ofChannels = {} + + this._handledTasks = [] + this._managed = [] this._unsetApiMethods() } @@ -483,7 +535,8 @@ class SDNController extends EventEmitter { this._cleaners.push(await this._manageXapi(xapi)) const hosts = filter(xapi.objects.all, { $type: 'host' }) for (const host of hosts) { - this._createOvsdbClient(host) + this._getOrCreateOvsdbClient(host) + this._getOrCreateOfChannel(host) } // Add already existing private networks @@ -596,6 +649,13 @@ class SDNController extends EventEmitter { await this._electNewCenter(privateNetwork) }) ) + + // ----------------------------------------------------------------------- + + const vifs = filter(xapi.objects.all, { $type: 'VIF' }) + for (const vif of vifs) { + await this._applyVifOfRules(vif) + } } catch (error) { log.error('Error while handling xapi connection', { id: xapi.pool.uuid, @@ -632,6 +692,112 @@ class SDNController extends EventEmitter { // =========================================================================== + async _addRule({ allow, direction, ipRange = '', port, protocol, vifId }) { + const vif = this._xo.getXapiObject(this._xo.getObject(vifId, 'VIF')) + try { + assert(vif.currently_attached, 'VIF needs to be plugged to add rule') + await this._setPoolControllerIfNeeded(vif.$pool) + + const client = this._getOrCreateOvsdbClient(vif.$VM.$resident_on) + const channel = this._getOrCreateOfChannel(vif.$VM.$resident_on) + const ofport = await client.getOfPortForVif(vif) + await channel.addRule( + vif, + allow, + protocol, + port, + ipRange, + direction, + ofport + ) + const vifRules = vif.other_config['xo:sdn-controller:of-rules'] + const newVifRules = vifRules !== undefined ? JSON.parse(vifRules) : [] + const stringRule = JSON.stringify({ + allow, + protocol, + port, + ipRange, + direction, + }) + if (!newVifRules.includes(stringRule)) { + newVifRules.push(stringRule) + await vif.update_other_config( + 'xo:sdn-controller:of-rules', + JSON.stringify(newVifRules) + ) + } + } catch (error) { + log.error('Error while adding OF rule', { + error, + vif: vif.uuid, + host: vif.$VM.$resident_on.uuid, + allow, + protocol, + port, + ipRange, + direction, + }) + } + } + + async _deleteRule( + { direction, ipRange = '', port, protocol, vifId }, + updateOtherConfig = true + ) { + let vif = this._xo.getXapiObject(this._xo.getObject(vifId, 'VIF')) + try { + await this._setPoolControllerIfNeeded(vif.$pool) + + const client = this._getOrCreateOvsdbClient(vif.$VM.$resident_on) + const channel = this._getOrCreateOfChannel(vif.$VM.$resident_on) + const ofport = await client.getOfPortForVif(vif) + await channel.deleteRule(vif, protocol, port, ipRange, direction, ofport) + if (!updateOtherConfig) { + return + } + + const vifRules = vif.other_config['xo:sdn-controller:of-rules'] + if (vifRules === undefined) { + // Nothing to do + return + } + + const newVifRules = JSON.parse(vifRules).filter(vifRule => { + const rule = JSON.parse(vifRule) + return ( + rule.protocol !== protocol || + rule.port !== port || + rule.ipRange !== ipRange || + rule.direction !== direction + ) + }) + + await vif.update_other_config( + 'xo:sdn-controller:of-rules', + Object.keys(newVifRules).length === 0 + ? null + : JSON.stringify(newVifRules) + ) + + vif = await vif.$xapi.barrier(vif.$ref) + + // Put back rules that could have been wrongfully deleted because delete rule too general + await this._applyVifOfRules(vif) + } catch (error) { + log.error('Error while adding OF rule', { + error, + vif: vif.uuid, + host: vif.$VM.$resident_on.uuid, + protocol, + port, + ipRange, + direction, + }) + } + } + + // --------------------------------------------------------------------------- + async _createPrivateNetwork({ poolIds, pifIds, @@ -661,8 +827,6 @@ class SDNController extends EventEmitter { const privateNetwork = new PrivateNetwork(this, uuidv4(), preferredCenter) for (const pool of pools) { - await this._setPoolControllerIfNeeded(pool) - const pifId = pifIds.find(id => { const pif = this._xo.getXapiObject(this._xo.getObject(id, 'PIF')) return pif.$pool.$ref === pool.$ref @@ -703,9 +867,11 @@ class SDNController extends EventEmitter { await Promise.all( map(hosts, async host => { await createTunnel(host, createdNetwork) - this._createOvsdbClient(host) + this._getOrCreateOvsdbClient(host) + this._getOrCreateOfChannel(host) }) ) + await this._setPoolControllerIfNeeded(pool) await privateNetwork.addNetwork(createdNetwork) this._networks.set(createdNetwork.$id, createdNetwork.$ref) @@ -721,6 +887,10 @@ class SDNController extends EventEmitter { // --------------------------------------------------------------------------- async _manageXapi(xapi) { + if (this._managed.includes(xapi.pool.uuid)) { + return noop // pushed in _cleaners + } + const { objects } = xapi const objectsRemovedXapi = this._objectsRemoved.bind(this, xapi) @@ -729,6 +899,7 @@ class SDNController extends EventEmitter { objects.on('remove', objectsRemovedXapi) await this._installCaCertificateIfNeeded(xapi) + this._managed.push(xapi.pool.uuid) return () => { objects.removeListener('add', this._objectsAdded) @@ -738,7 +909,7 @@ class SDNController extends EventEmitter { } _objectsAdded(objects) { - forOwn(objects, object => { + forOwn(objects, async object => { const { $type } = object if ($type === 'host') { @@ -750,7 +921,18 @@ class SDNController extends EventEmitter { if (!this._newHosts.some(_ => _.$ref === object.$ref)) { this._newHosts.push(object) } - this._createOvsdbClient(object) + this._getOrCreateOvsdbClient(object) + this._getOrCreateOfChannel(object) + } else if ($type === 'PIF') { + log.debug('New PIF', { + device: object.device, + host: object.$host.name_label, + network: object.$network.name_label, + pool: object.$pool.name_label, + }) + + const client = this.ovsdbClients[object.host] + client.setBridgeControllerForNetwork(object.$network) } }) } @@ -765,6 +947,10 @@ class SDNController extends EventEmitter { await this._hostUpdated(object) } else if ($type === 'host_metrics') { await this._hostMetricsUpdated(object) + } else if ($type === 'VM') { + await this._vmUpdated(object) + } else if ($type === 'VIF') { + await this._vifUpdated(object) } } catch (error) { log.error('Error in _objectsUpdated', { @@ -782,6 +968,10 @@ class SDNController extends EventEmitter { this.ovsdbClients, client => client.host.$id === id ) + this.ofChannels = omitBy( + this.ofChannels, + channel => channel.host.$id === id + ) // If a Star center host is removed: re-elect a new center where needed const starCenterRef = this._starCenters.get(id) @@ -898,6 +1088,8 @@ class SDNController extends EventEmitter { }) } + this._setBridgeControllerForHost(host) + const privateNetworks = filter( this.privateNetworks, privateNetwork => privateNetwork[host.$pool.uuid] !== undefined @@ -928,6 +1120,64 @@ class SDNController extends EventEmitter { return this._hostUnreachable(ovsdbClient.host) } + async _vmUpdated(vm) { + forOwn(vm.current_operations, async (value, key) => { + if (this._handledTasks.includes(key)) { + return + } + + this._handledTasks.push(key) + // Clean before task ends + if ( + value === 'migrate_send' || + value === 'pool_migrate' || + value === 'clean_reboot' || + value === 'hard_reboot' || + value === 'hard_shutdown' || + value === 'clean_shutdown' + ) { + await this._cleanOfRules(vm) + } + + await vm.$xapi.watchTask(key).catch(noop) + // Re-apply rules after task ended + if ( + value === 'migrate_send' || + value === 'pool_migrate' || + value === 'clean_reboot' || + value === 'hard_reboot' || + value === 'start' || + value === 'start_on' + ) { + vm = await vm.$xapi.barrier(vm.$ref) + await this._applyOfRules(vm) + } + + this._handledTasks = filter(this._handledTasks, ref => ref !== key) + }) + } + + async _vifUpdated(vif) { + await Promise.all( + map(vif.current_operations, async (value, key) => { + if (this._handledTasks.includes(key)) { + return + } + + this._handledTasks.push(key) + if (value === 'plug') { + await vif.$xapi.watchTask(key).catch(noop) + vif = await vif.$xapi.barrier(vif.$ref) + await this._applyVifOfRules(vif) + } else if (value === 'unplug' || value === 'unplug_force') { + await this._cleanVifOfRules(vif) + await vif.$xapi.watchTask(key).catch(noop) + } + this._handledTasks = filter(this._handledTasks, ref => ref !== key) + }) + ) + } + // --------------------------------------------------------------------------- async _setPoolControllerIfNeeded(pool) { @@ -948,9 +1198,20 @@ class SDNController extends EventEmitter { }) } + const hosts = filter(pool.$xapi.objects.all, { $type: 'host' }) + await Promise.all( + hosts.map(host => { + return this._setBridgeControllerForHost(host) + }) + ) this._cleaners.push(await this._manageXapi(pool.$xapi)) } + _setBridgeControllerForHost(host) { + const client = this.ovsdbClients[host.$ref] + return client.setBridgeController() + } + // --------------------------------------------------------------------------- async _installCaCertificateIfNeeded(xapi) { @@ -1116,18 +1377,60 @@ class SDNController extends EventEmitter { // --------------------------------------------------------------------------- - _createOvsdbClient(host) { - if (this.ovsdbClients[host.$ref] !== undefined) { + async _applyVifOfRules(vif) { + if (!vif.currently_attached) { return } - const client = new OvsdbClient( - host, - this._clientKey, - this._clientCert, - this._caCert - ) - this.ovsdbClients[host.$ref] = client + const vifRules = vif.other_config['xo:sdn-controller:of-rules'] + const parsedRules = vifRules !== undefined ? JSON.parse(vifRules) : [] + for (const stringRule of parsedRules) { + const rule = JSON.parse(stringRule) + await this._addRule({ ...rule, vifId: vif.$id }) + } + } + + async _cleanVifOfRules(vif) { + const vifRules = vif.other_config['xo:sdn-controller:of-rules'] + const parsedRules = vifRules !== undefined ? JSON.parse(vifRules) : [] + for (const stringRule of parsedRules) { + const rule = JSON.parse(stringRule) + await this._deleteRule({ ...rule, vifId: vif.$id }, false) + } + } + + async _cleanOfRules(vm) { + for (const vif of vm.$VIFs) { + await this._cleanVifOfRules(vif) + } + } + + async _applyOfRules(vm) { + for (const vif of vm.$VIFs) { + await this._applyVifOfRules(vif) + } + } + + // --------------------------------------------------------------------------- + + _getOrCreateOvsdbClient(host) { + let client = this.ovsdbClients[host.$ref] + if (client === undefined) { + client = new OvsdbClient(host, this._tlsHelper) + this.ovsdbClients[host.$ref] = client + } + + return client + } + + _getOrCreateOfChannel(host) { + let channel = this.ofChannels[host.$ref] + if (channel === undefined) { + channel = new OpenFlowChannel(host, this._tlsHelper) + this.ofChannels[host.$ref] = channel + } + + return channel } } diff --git a/packages/xo-server-sdn-controller/src/protocol/openflow-channel.js b/packages/xo-server-sdn-controller/src/protocol/openflow-channel.js new file mode 100644 index 000000000..a5ae36053 --- /dev/null +++ b/packages/xo-server-sdn-controller/src/protocol/openflow-channel.js @@ -0,0 +1,394 @@ +import createLogger from '@xen-orchestra/log' +import ipaddr from 'ipaddr.js' +import openflow from '@xen-orchestra/openflow' +import parse from '@xen-orchestra/openflow/parse-socket' + +import { coalesceCalls } from '@vates/coalesce-calls' +import { EventEmitter } from 'events' +import { fromEvent } from 'promise-toolbox' + +// ============================================================================= + +const log = createLogger('xo:xo-server:sdn-controller:openflow-controller') + +const version = openflow.versions.openFlow11 +const ofProtocol = openflow.protocols[version] +const OPENFLOW_PORT = ofProtocol.sslPort + +// ----------------------------------------------------------------------------- + +const parseIp = ipAddress => { + if (ipAddress === '') { + return + } + + let addr, mask + if (ipAddress.includes('/')) { + const ip = ipaddr.parseCIDR(ipAddress) + addr = ip[0].toString() + const maskOctets = ipaddr.IPv4.subnetMaskFromPrefixLength(ip[1]).octets + mask = ipaddr.fromByteArray(maskOctets.map(i => 255 - i)).toString() // Use wildcarded mask + } else { + // TODO: return ipAddress directly? + const ip = ipaddr.parse(ipAddress) + addr = ip.toString() + } + + return { addr, mask } +} + +const dlAndNwProtocolFromString = protocol => { + switch (protocol) { + case 'IP': + return { dlType: ofProtocol.dlType.ip } + case 'ICMP': + return { + dlType: ofProtocol.dlType.ip, + nwProto: ofProtocol.nwProto.icmp, + } + case 'TCP': + return { + dlType: ofProtocol.dlType.ip, + nwProto: ofProtocol.nwProto.tcp, + } + case 'UDP': + return { + dlType: ofProtocol.dlType.ip, + nwProto: ofProtocol.nwProto.udp, + } + + case 'ARP': + return { dlType: ofProtocol.dlType.arp } + default: + return {} // TODO: Error? + } +} + +// ============================================================================= + +export class OpenFlowChannel extends EventEmitter { + /* + Create an SSL connection to an XCP-ng host. + Interact with the host's OpenVSwitch (OVS) daemon to manage its flows with OpenFlow11. + See: + - OpenFlow11 spec: https://www.opennetworking.org/wp-content/uploads/2014/10/openflow-spec-v1.1.0.pdf + */ + + constructor(host, tlsHelper) { + super() + + this.host = host + this._tlsHelper = tlsHelper + this._coalesceConnect = coalesceCalls(this._connect) + this._socket = undefined + + log.debug('New OpenFlow channel', { + host: this.host.name_label, + }) + } + + // --------------------------------------------------------------------------- + + async addRule(vif, allow, protocol, port, ipRange, direction, ofport) { + log.info('Adding OF rule', { + allow, + protocol, + port, + ipRange, + direction, + vif: vif.uuid, + }) + const instructions = [ + { + type: ofProtocol.instructionType.applyActions, + actions: allow + ? [ + { + type: ofProtocol.actionType.output, + port: ofProtocol.port.normal, + }, + ] + : [], + }, + ] + + const ip = parseIp(ipRange) + const { dlType, nwProto } = dlAndNwProtocolFromString(protocol) + const mac = vif.MAC + + await this._coalesceConnect() + if (direction.includes('from')) { + this._addFlow( + { + type: ofProtocol.matchType.standard, + dl_type: dlType, + dl_src: mac, + nw_proto: nwProto, + nw_dst: ip?.addr, + nw_dst_mask: ip?.mask, + tp_src: port, + in_port: ofport, + }, + instructions + ) + + if (nwProto !== undefined) { + this._addFlow( + { + type: ofProtocol.matchType.standard, + dl_type: dlType, + dl_dst: mac, + nw_proto: nwProto, + nw_src: ip?.addr, + nw_src_mask: ip?.mask, + tp_dst: port, + }, + instructions + ) + } + } + if (direction.includes('to')) { + if (nwProto !== undefined) { + this._addFlow( + { + type: ofProtocol.matchType.standard, + dl_type: dlType, + dl_src: mac, + nw_proto: nwProto, + nw_dst: ip?.addr, + nw_dst_mask: ip?.mask, + tp_dst: port, + in_port: ofport, + }, + instructions + ) + } + this._addFlow( + { + type: ofProtocol.matchType.standard, + dl_type: dlType, + dl_dst: mac, + nw_proto: nwProto, + nw_src: ip?.addr, + nw_src_mask: ip?.mask, + tp_src: port, + }, + instructions + ) + } + } + + async deleteRule(vif, protocol, port, ipRange, direction, ofport) { + log.info('Deleting OF rule', { + protocol, + port, + ipRange, + direction, + vif: vif.uuid, + }) + const ip = parseIp(ipRange) + const { dlType, nwProto } = dlAndNwProtocolFromString(protocol) + const mac = vif.MAC + + await this._coalesceConnect() + if (direction.includes('from')) { + this._removeFlows({ + type: ofProtocol.matchType.standard, + dl_type: dlType, + dl_src: mac, + nw_proto: nwProto, + nw_dst: ip?.addr, + nw_dst_mask: ip?.mask, + tp_src: port, + }) + if (nwProto !== undefined) { + this._removeFlows({ + type: ofProtocol.matchType.standard, + dl_type: dlType, + dl_dst: mac, + nw_proto: nwProto, + nw_src: ip?.addr, + nw_src_mask: ip?.mask, + tp_dst: port, + }) + } + } + if (direction.includes('to')) { + if (nwProto !== undefined) { + this._removeFlows({ + type: ofProtocol.matchType.standard, + dl_type: dlType, + dl_src: mac, + nw_proto: nwProto, + nw_dst: ip?.addr, + nw_dst_mask: ip?.mask, + tp_dst: port, + }) + } + this._removeFlows({ + type: ofProtocol.matchType.standard, + dl_type: dlType, + dl_dst: mac, + nw_proto: nwProto, + nw_src: ip?.addr, + nw_src_mask: ip?.mask, + tp_src: port, + }) + } + } + + // =========================================================================== + + _processMessage(message) { + if (message.header === undefined) { + log.error('Failed to get header while processing message', { + message, + }) + return + } + + const ofType = message.header.type + switch (ofType) { + case ofProtocol.type.hello: + this._sendPacket( + this._syncMessage(ofProtocol.type.hello, message.header.xid) + ) + this._sendPacket( + this._syncMessage(ofProtocol.type.featuresRequest, message.header.xid) + ) + break + case ofProtocol.type.error: + { + const { code, type } = message + log.error('OpenFlow error', { + code, + type, + // data: openflow.unpack(data), + }) + } + break + case ofProtocol.type.echoRequest: + this._sendPacket( + this._syncMessage(ofProtocol.type.echoReply, message.header.xid) + ) + break + case ofProtocol.type.packetIn: + log.debug('PACKET_IN') + break + case ofProtocol.type.featuresReply: + { + const { datapath_id: dpid, capabilities, ports } = message + log.debug('FEATURES_REPLY', { dpid, capabilities, ports }) + this._sendPacket( + this._syncMessage( + ofProtocol.type.getConfigRequest, + message.header.xid + ) + ) + } + break + case ofProtocol.type.getConfigReply: + { + const { flags } = message + log.debug('CONFIG_REPLY', { flags }) + this.emit('ofConnected') + } + break + case ofProtocol.type.portStatus: + log.debug('PORT_STATUS') + break + case ofProtocol.type.flowRemoved: + log.debug('FLOW_REMOVED') + break + default: + log.error('Unknown OpenFlow type', { ofType }) + break + } + } + + _addFlow(match, instructions) { + const packet = this._flowModMessage( + ofProtocol.flowModCommand.add, + match, + instructions + ) + this._sendPacket(packet) + } + + _removeFlows(match) { + const packet = this._flowModMessage(ofProtocol.flowModCommand.delete, match) + this._sendPacket(packet) + } + + // --------------------------------------------------------------------------- + + _syncMessage(type, xid = 1) { + return { + header: { + version, + type, + xid, + }, + } + } + + _flowModMessage(command, match, instructions = []) { + // TODO: Do not use default priority? + return { + ...this._syncMessage(ofProtocol.type.flowMod), + command, + flags: ofProtocol.flowModFlags.sendFlowRem, + match, + instructions, + } + } + + // --------------------------------------------------------------------------- + + _sendPacket(packet) { + const buf = openflow.pack(packet) + try { + this._socket.write(buf) + } catch (error) { + log.error('Error while writing into socket', { + error, + host: this.host.name_label, + }) + } + } + + // --------------------------------------------------------------------------- + + async _parseMessages() { + for await (const msg of parse(this._socket)) { + if (msg.header !== undefined) { + this._processMessage(msg) + } else { + log.error('Error: Message is unparseable', { msg }) + } + } + } + + async _connect() { + if (this._socket !== undefined) { + return + } + + this._socket = await this._tlsHelper.connect( + this.host.address, + OPENFLOW_PORT + ) + + const deleteSocket = () => { + this._socket = undefined + } + this._socket.on('error', deleteSocket) + this._socket.on('end', deleteSocket) + + this._parseMessages().catch(error => { + log.error('Error while parsing OF messages', error) + }) + + await fromEvent(this, 'ofConnected') + } +} diff --git a/packages/xo-server-sdn-controller/src/protocol/ovsdb-client.js b/packages/xo-server-sdn-controller/src/protocol/ovsdb-client.js index 7ce2b379a..f661ba22d 100644 --- a/packages/xo-server-sdn-controller/src/protocol/ovsdb-client.js +++ b/packages/xo-server-sdn-controller/src/protocol/ovsdb-client.js @@ -1,12 +1,15 @@ import assert from 'assert' import createLogger from '@xen-orchestra/log' import fromEvent from 'promise-toolbox/fromEvent' -import { connect } from 'tls' import { forOwn, toPairs } from 'lodash' +// ============================================================================= + const log = createLogger('xo:xo-server:sdn-controller:ovsdb-client') const OVSDB_PORT = 6640 +const PROTOCOLS = 'OpenFlow11' // Supported OpenFlow versions +const TARGET = 'pssl:' // OpenFlow Controller target // ============================================================================= @@ -14,6 +17,10 @@ function toMap(object) { return ['map', toPairs(object)] } +function setFromSelect(object) { + return object[0] === 'set' ? object[1] : [object] +} + // ============================================================================= export class OvsdbClient { @@ -36,7 +43,7 @@ export class OvsdbClient { - `remote_ip`: Remote IP of the tunnel */ - constructor(host, clientKey, clientCert, caCert) { + constructor(host, tlsHelper) { this._numberOfPortAndInterface = 0 this._requestId = 0 @@ -44,7 +51,7 @@ export class OvsdbClient { this.host = host - this.updateCertificates(clientKey, clientCert, caCert) + this._tlsHelper = tlsHelper log.debug('New OVSDB client', { host: this.host.name_label, @@ -53,18 +60,6 @@ export class OvsdbClient { // --------------------------------------------------------------------------- - updateCertificates(clientKey, clientCert, caCert) { - this._clientKey = clientKey - this._clientCert = clientCert - this._caCert = caCert - - log.debug('Certificates have been updated', { - host: this.host.name_label, - }) - } - - // --------------------------------------------------------------------------- - async addInterfaceAndPort( network, remoteAddress, @@ -286,6 +281,154 @@ export class OvsdbClient { socket.destroy() } + async setBridgeController() { + const socket = await this._connect() + // Add controller to openvswitch table if needed + const params = ['Open_vSwitch'] + + params.push({ + op: 'insert', + table: 'Controller', + row: { + target: TARGET, + }, + 'uuid-name': 'new_controller', + }) + + const networks = this.host.$PIFs.map(pif => pif.$network) + for (const network of networks) { + const bridge = await this._getBridgeForNetwork(network, socket) + if (bridge.uuid === undefined) { + continue + } + + if (await this._bridgeAlreadyControlled(bridge, socket)) { + continue + } + + params.push({ + op: 'mutate', + table: 'Bridge', + where: [['_uuid', '==', ['uuid', bridge.uuid]]], + mutations: [ + ['controller', 'insert', ['named-uuid', 'new_controller']], + ['protocols', 'insert', PROTOCOLS], + ], + }) + } + + const jsonObjects = await this._sendOvsdbTransaction(params, socket) + if (jsonObjects === undefined) { + socket.destroy() + return + } + if (jsonObjects[0].error !== null) { + log.error('Error while setting controller', { + error: jsonObjects[0].error, + host: this.host.name_label, + }) + } else { + this._controllerUuid = jsonObjects[0].result[0].uuid[1] + log.info('Controller set', { host: this.host.name_label }) + } + + socket.destroy() + } + + async setBridgeControllerForNetwork(network) { + const socket = await this._connect() + if (this._controllerUuid === undefined) { + const where = [['target', '==', TARGET]] + const selectResult = await this._select( + 'Controller', + ['_uuid'], + where, + socket + ) + + this._controllerUuid = selectResult._uuid[1] + } + assert.notStrictEqual(this._controllerUuid, undefined) + + const bridge = await this._getBridgeForNetwork(network, socket) + if (bridge.uuid === undefined) { + socket.destroy() + return + } + + if (await this._bridgeAlreadyControlled(bridge, socket)) { + socket.destroy() + return + } + + const mutateOperation = { + op: 'mutate', + table: 'Bridge', + where: [['_uuid', '==', ['uuid', bridge.uuid]]], + mutations: [ + ['controller', 'insert', ['uuid', this._controllerUuid]], + ['protocols', 'insert', PROTOCOLS], + ], + } + + const params = ['Open_vSwitch', mutateOperation] + const jsonObjects = await this._sendOvsdbTransaction(params, socket) + if (jsonObjects === undefined) { + socket.destroy() + return + } + if (jsonObjects[0].error !== null) { + log.error('Error while setting controller for network', { + error: jsonObjects[0].error, + host: this.host.name_label, + network: network.name_label, + }) + } else { + log.info('Controller set for network', { + controller: this._controllerUuid, + host: this.host.name_label, + network: network.name_label, + }) + } + + socket.destroy() + } + + async getOfPortForVif(vif) { + const where = [ + ['external_ids', 'includes', toMap({ 'xs-vif-uuid': vif.uuid })], + ] + const socket = await this._connect() + const selectResult = await this._select( + 'Interface', + ['name', 'ofport'], + where, + socket, + true // multiResult + ) + if (selectResult === undefined) { + log.error('No of port found for VIF', { + network: vif.$network.name_label, + host: this.host.name_label, + vm: vif.$VM.name_label, + vif: vif.uuid, + }) + return + } + + let ofport + for (const i in selectResult) { + const row = selectResult[i] + if (!row.name.includes('tap')) { + ofport = row.ofport + break + } + } + + socket.destroy() + return ofport + } + // =========================================================================== _parseJson(chunk) { @@ -351,6 +494,25 @@ export class OvsdbClient { }) } + // --------------------------------------------------------------------------- + + async _bridgeAlreadyControlled(bridge, socket) { + const where = [['_uuid', '==', ['uuid', bridge.uuid]]] + let result = await this._select('Bridge', ['controller'], where, socket) + const controllers = setFromSelect(result.controller) + for (const controller of controllers) { + const where = [['_uuid', '==', controller]] + result = await this._select('Controller', ['target'], where, socket) + if (result.target === TARGET) { + return true + } + } + + return false + } + + // --------------------------------------------------------------------------- + async _getBridgeForNetwork(network, socket) { const where = [ ['external_ids', 'includes', toMap({ 'xs-network-uuids': network.uuid })], @@ -406,9 +568,7 @@ export class OvsdbClient { return } - return selectResult.ports[0] === 'set' - ? selectResult.ports[1] - : [selectResult.ports] + return setFromSelect(selectResult.ports) } async _getPortInterfaces(portUuid, socket) { @@ -423,9 +583,7 @@ export class OvsdbClient { return } - return selectResult.interfaces[0] === 'set' - ? selectResult.interfaces[1] - : [selectResult.interfaces] + return setFromSelect(selectResult.interfaces) } async _interfaceHasRemote(interfaceUuid, remoteAddress, socket) { @@ -451,12 +609,12 @@ export class OvsdbClient { // --------------------------------------------------------------------------- - async _select(table, columns, where, socket) { + async _select(table, columns, where, socket, multiResult = false) { const selectOperation = { op: 'select', - table: table, - columns: columns, - where: where, + table, + columns, + where, } const params = ['Open_vSwitch', selectOperation] @@ -487,6 +645,10 @@ export class OvsdbClient { return } + if (multiResult) { + return jsonResult.rows + } + // For now all select operations should return only 1 row assert( jsonResult.rows.length === 1, @@ -538,37 +700,7 @@ export class OvsdbClient { // --------------------------------------------------------------------------- - async _connect() { - const options = { - ca: this._caCert, - key: this._clientKey, - cert: this._clientCert, - host: this.host.address, - port: OVSDB_PORT, - rejectUnauthorized: false, - requestCert: false, - } - const socket = connect(options) - - try { - await fromEvent(socket, 'secureConnect') - } catch (error) { - log.error('TLS connection failed', { - error, - code: error.code, - host: this.host.name_label, - }) - throw error - } - - socket.on('error', error => { - log.error('Socket error', { - error, - code: error.code, - host: this.host.name_label, - }) - }) - - return socket + _connect() { + return this._tlsHelper.connect(this.host.address, OVSDB_PORT) } } diff --git a/packages/xo-server-sdn-controller/src/utils/tls-helper.js b/packages/xo-server-sdn-controller/src/utils/tls-helper.js new file mode 100644 index 000000000..b0de5f22d --- /dev/null +++ b/packages/xo-server-sdn-controller/src/utils/tls-helper.js @@ -0,0 +1,53 @@ +import fromEvent from 'promise-toolbox/fromEvent' +import createLogger from '@xen-orchestra/log' +import { connect } from 'tls' + +// ============================================================================= + +const log = createLogger('xo:xo-server:sdn-controller:tls-connect') + +// ============================================================================= + +export class TlsHelper { + updateCertificates(clientKey, clientCert, caCert) { + this._clientKey = clientKey + this._clientCert = clientCert + this._caCert = caCert + log.debug('Certificates have been updated') + } + + // --------------------------------------------------------------------------- + + async connect(address, port) { + const options = { + ca: this._caCert, + cert: this._clientCert, + host: address, + key: this._clientKey, + port, + rejectUnauthorized: false, + requestCert: false, + } + const socket = connect(options) + try { + await fromEvent(socket, 'secureConnect') + } catch (error) { + log.error('TLS connection failed', { + error, + address, + port, + }) + throw error + } + + socket.on('error', error => { + log.error('Socket error', { + error, + address, + port, + }) + }) + + return socket + } +} diff --git a/packages/xo-server/src/xapi-object-to-xo.js b/packages/xo-server/src/xapi-object-to-xo.js index 3fd820df9..d7b3dd77f 100644 --- a/packages/xo-server/src/xapi-object-to-xo.js +++ b/packages/xo-server/src/xapi-object-to-xo.js @@ -646,6 +646,7 @@ const TRANSFORMS = { device: obj.device, // TODO: should it be cast to a number? MAC: obj.MAC, MTU: +obj.MTU, + other_config: obj.other_config, // in kB/s rateLimit: (() => { diff --git a/packages/xo-web/src/common/intl/messages.js b/packages/xo-web/src/common/intl/messages.js index 9712a1c67..abebc2888 100644 --- a/packages/xo-web/src/common/intl/messages.js +++ b/packages/xo-web/src/common/intl/messages.js @@ -1114,6 +1114,23 @@ const messages = { "The VDIs' SRs must either be shared or on the same host for the VM to be able to start.", // ----- VM network tab ----- + + aclRuleAllow: 'Allow the traffic', + aclRuleProtocol: 'Select a protocol', + aclRulePort: 'Select a port', + aclRuleIpRange: 'Select an IP or an IP range (CIDR format)', + aclRuleDirection: 'Select a direction', + aclRuleAllowField: 'Traffic Enabled/Disabled', + aclRuleProtocolField: 'Protocol', + aclRulePortField: 'Port', + aclRuleIpRangeField: 'IP range (CIDR format)', + aclRuleDirectionField: 'Direction', + addRule: 'Add rule', + deleteRule: 'Delete rule', + hideRules: 'Hide rules', + sdnControllerNotLoaded: 'SDN Controller must be loaded', + showRules: 'Show rules', + vifAclRules: 'Traffic rules', vifCreateDeviceButton: 'New device', vifDeviceLabel: 'Device', vifMacLabel: 'MAC address', diff --git a/packages/xo-web/src/common/xo/index.js b/packages/xo-web/src/common/xo/index.js index 05b2c955e..c4d824f98 100644 --- a/packages/xo-web/src/common/xo/index.js +++ b/packages/xo-web/src/common/xo/index.js @@ -1790,6 +1790,38 @@ export const setVif = ( rateLimit, }) +export const addAclRule = ({ + allow, + protocol = undefined, + port = undefined, + ipRange = '', + direction, + vif, +}) => + _call('sdnController.addRule', { + allow, + protocol, + port, + ipRange, + direction, + vifId: resolveId(vif), + }) + +export const deleteAclRule = ({ + protocol = undefined, + port = undefined, + ipRange = '', + direction, + vif, +}) => + _call('sdnController.deleteRule', { + protocol, + port, + ipRange, + direction, + vifId: resolveId(vif), + }) + // Network ----------------------------------------------------------- export const editNetwork = (network, props) => diff --git a/packages/xo-web/src/xo-app/vm/tab-network.js b/packages/xo-web/src/xo-app/vm/tab-network.js index ec056c35d..c8961805e 100644 --- a/packages/xo-web/src/xo-app/vm/tab-network.js +++ b/packages/xo-web/src/xo-app/vm/tab-network.js @@ -5,11 +5,13 @@ import BaseComponent from 'base-component' import copy from 'copy-to-clipboard' import Icon from 'icon' import PropTypes from 'prop-types' -import React from 'react' +import React, { Component } from 'react' import SortedTable from 'sorted-table' import StateButton from 'state-button' +import SingleLineRow from 'single-line-row' import TabButton from 'tab-button' import Tooltip from 'tooltip' +import { confirm } from 'modal' import { isIp, isIpV4 } from 'ip-utils' import { Container, Row, Col } from 'grid' import { injectIntl } from 'react-intl' @@ -27,6 +29,7 @@ import { SelectResourceSetIp, SelectResourceSetsNetwork, } from 'select-objects' +import { Select, Toggle } from 'form' import { concat, every, @@ -35,6 +38,7 @@ import { isEmpty, keys, map, + pick, remove, some, } from 'lodash' @@ -48,14 +52,17 @@ import { } from 'selectors' import { + addAclRule, connectVif, createVmInterface, + deleteAclRule, deleteVif, deleteVifs, disconnectVif, isVmRunning, setVif, subscribeIpPools, + subscribePlugins, subscribeResourceSets, } from 'xo' @@ -302,6 +309,241 @@ class VifStatus extends BaseComponent { } } +// ----------------------------------------------------------------------------- + +const USABLE_PORT_PROTOCOL = ['TCP', 'ICMP', 'UDP'] +class NewAclRuleForm extends BaseComponent { + state = { + allow: true, + protocol: undefined, + port: undefined, + ipRange: '', + direction: 'from/to', + } + + get value() { + return pick(this.state, [ + 'allow', + 'protocol', + 'port', + 'ipRange', + 'direction', + ]) + } + + render() { + const { protocol, allow, port, ipRange, direction } = this.state + const showIpRange = protocol != null + const showPort = showIpRange && USABLE_PORT_PROTOCOL.includes(protocol) + + return ( +
+
+
+ + + {_('aclRuleAllow')} + + + + + + {_('aclRuleProtocol')} + + + + + )} + {showIpRange && ( + + {_('aclRuleIpRange')} + + + + + )} + + {_('aclRuleDirection')} + +