feat(sdn-controller): traffic rules ordering (#10525)

This commit is contained in:
Killian
2026-10-28 10:19:02 +01:00
committed by GitHub
parent 54c52e8f24
commit 092133f811
10 changed files with 558 additions and 79 deletions

View File

@@ -12,12 +12,20 @@ const BASE = '/rest/v0/plugins/sdn-controller'
// Note : this test assumes that the config has the useDirectChannel option set to false
// the old backend is being replaced and doesn't have support for network rules deletion, and is generally unstable.
// Priorities are unique on a network: the network rule tests delete theirs before the VIF ones add it
const VALID_RULE = {
allow: true,
direction: 'from',
ipRange: '10.0.0.0/8',
protocol: 'TCP',
port: 8080,
priority: 31000,
}
// the rules of a network or a VIF, stored as JSON strings inside their `other_config`
const parseRules = object => {
const raw = object.other_config?.['xo:sdn-controller:of-rules']
return raw !== undefined ? JSON.parse(raw).map(JSON.parse) : []
}
describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID }, () => {
@@ -63,6 +71,7 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
try {
await dispatchClient.restApiClient.post(`${BASE}/vifs/${vifId}/actions/add_traffic_rule?sync=true`, SENTINEL_RULE)
await dispatchClient.restApiClient.post(`${BASE}/vifs/${vifId}/actions/delete_traffic_rule?sync=true`, {
allow: SENTINEL_RULE.allow,
direction: SENTINEL_RULE.direction,
ipRange: SENTINEL_RULE.ipRange,
protocol: SENTINEL_RULE.protocol,
@@ -80,7 +89,7 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
const network = await dispatchClient.restApiClient.get(`/rest/v0/networks/${networkId}?fields=*`)
const ofMethod =
network.$pool !== undefined
? (await dispatchClient.restApiClient.get(`/rest/v0/pools/${network.$pool}?fields=*`)).other_config?.[
? (await dispatchClient.restApiClient.get(`/rest/v0/pools/${network.$pool}?fields=*`)).otherConfig?.[
'xo:sdn-controller:of-method'
]
: undefined
@@ -88,8 +97,8 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
if (ofMethod === undefined || ofMethod !== 'xapi-plugin') {
skipReason =
`SDN controller pool uses the '${ofMethod}' backend; this suite requires the ` +
`'xapi-plugin' backend (set the useDirectChannel option to false in config.toml). ` +
log.warn('SDN controller pre-check failed', { skipReason })
`'xapi-plugin' backend (set the useDirectChannel option to false in config.toml).`
log.warn('SDN controller pre-check failed', { skipReason })
}
})
@@ -157,6 +166,7 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
itSdn('should reject delete when direction is missing', () =>
rejectsWithHttpError(`${BASE}/networks/${networkId}/actions/delete_traffic_rule?sync=true`, {
allow: true,
ipRange: '10.0.0.0/8',
protocol: 'TCP',
})
@@ -164,6 +174,7 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
itSdn('should reject delete when ipRange is missing', () =>
rejectsWithHttpError(`${BASE}/networks/${networkId}/actions/delete_traffic_rule?sync=true`, {
allow: true,
direction: 'from',
protocol: 'TCP',
})
@@ -171,6 +182,7 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
itSdn('should reject delete when protocol is missing', () =>
rejectsWithHttpError(`${BASE}/networks/${networkId}/actions/delete_traffic_rule?sync=true`, {
allow: true,
direction: 'from',
ipRange: '10.0.0.0/8',
})
@@ -181,9 +193,7 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
describe('Network rules – CRUD', () => {
const getNetworkRules = async () => {
const network = await dispatchClient.restApiClient.get(`/rest/v0/networks/${networkId}?fields=*`)
const raw = network.other_config?.['xo:sdn-controller:of-rules']
return raw !== undefined ? JSON.parse(raw).map(JSON.parse) : []
return parseRules(await dispatchClient.restApiClient.get(`/rest/v0/networks/${networkId}?fields=*`))
}
const ruleKey = r => `${r.direction}:${r.ipRange}:${r.protocol}:${r.port}`
@@ -199,26 +209,27 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
assert.strictEqual(found.allow, true)
})
itSdn(
'should overwrite the rule when added again with allow=false (no duplication, cookie preserved)',
async () => {
await dispatchClient.restApiClient.post(`${BASE}/networks/${networkId}/actions/add_traffic_rule?sync=true`, {
...VALID_RULE,
allow: false,
})
const rules = await getNetworkRules()
const matching = rules.filter(r => ruleKey(r) === ruleKey(VALID_RULE))
assert.strictEqual(matching.length, 1, 'Rule must not be duplicated')
assert.strictEqual(matching[0].allow, false, 'Rule allow field must be updated')
}
)
itSdn('should reject the rule added again with its priority, without duplicating it', async () => {
await assert.rejects(
dispatchClient.restApiClient.post(
`${BASE}/networks/${networkId}/actions/add_traffic_rule?sync=true`,
VALID_RULE
),
{ message: /^HTTP 409:/ }
)
const rules = await getNetworkRules()
const matching = rules.filter(r => ruleKey(r) === ruleKey(VALID_RULE))
assert.strictEqual(matching.length, 1, 'Rule must not be duplicated')
})
itSdn('should delete the rule', async () => {
await dispatchClient.restApiClient.post(`${BASE}/networks/${networkId}/actions/delete_traffic_rule?sync=true`, {
allow: VALID_RULE.allow,
direction: VALID_RULE.direction,
ipRange: VALID_RULE.ipRange,
protocol: VALID_RULE.protocol,
port: VALID_RULE.port,
priority: VALID_RULE.priority,
})
const rules = await getNetworkRules()
const found = rules.find(r => ruleKey(r) === ruleKey(VALID_RULE))
@@ -227,13 +238,17 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
assert.strictEqual(found, undefined, 'Rule should be absent after delete')
})
itSdn('should not error when deleting a non-existent rule', () =>
dispatchClient.restApiClient.post(`${BASE}/networks/${networkId}/actions/delete_traffic_rule?sync=true`, {
direction: 'to',
ipRange: '192.168.99.0/24',
protocol: 'UDP',
port: 9999,
})
itSdn('should return 404 when deleting a non-existent rule', () =>
assert.rejects(
dispatchClient.restApiClient.post(`${BASE}/networks/${networkId}/actions/delete_traffic_rule?sync=true`, {
allow: true,
direction: 'to',
ipRange: '192.168.99.0/24',
protocol: 'UDP',
port: 9999,
}),
{ message: /^HTTP 404:/ }
)
)
})
@@ -297,6 +312,7 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
itSdn('should reject delete when direction is missing', () =>
rejectsWithHttpError(`${BASE}/vifs/${vifId}/actions/delete_traffic_rule?sync=true`, {
allow: true,
ipRange: '10.0.0.0/8',
protocol: 'TCP',
})
@@ -304,6 +320,7 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
itSdn('should reject delete when ipRange is missing', () =>
rejectsWithHttpError(`${BASE}/vifs/${vifId}/actions/delete_traffic_rule?sync=true`, {
allow: true,
direction: 'from',
protocol: 'TCP',
})
@@ -311,6 +328,7 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
itSdn('should reject delete when protocol is missing', () =>
rejectsWithHttpError(`${BASE}/vifs/${vifId}/actions/delete_traffic_rule?sync=true`, {
allow: true,
direction: 'from',
ipRange: '10.0.0.0/8',
})
@@ -321,9 +339,7 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
describe('VIF rules – CRUD', () => {
const getVifRules = async () => {
const vif = await dispatchClient.restApiClient.get(`/rest/v0/vifs/${vifId}?fields=*`)
const raw = vif.other_config?.['xo:sdn-controller:of-rules']
return raw !== undefined ? JSON.parse(raw).map(JSON.parse) : []
return parseRules(await dispatchClient.restApiClient.get(`/rest/v0/vifs/${vifId}?fields=*`))
}
const ruleKey = r => `${r.direction}:${r.ipRange}:${r.protocol}:${r.port}`
@@ -336,8 +352,11 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
assert.strictEqual(found.allow, true)
})
itSdn('should not duplicate the rule when added again', async () => {
await dispatchClient.restApiClient.post(`${BASE}/vifs/${vifId}/actions/add_traffic_rule?sync=true`, VALID_RULE)
itSdn('should reject the rule added again with its priority, without duplicating it', async () => {
await assert.rejects(
dispatchClient.restApiClient.post(`${BASE}/vifs/${vifId}/actions/add_traffic_rule?sync=true`, VALID_RULE),
{ message: /^HTTP 409:/ }
)
const rules = await getVifRules()
const matching = rules.filter(r => ruleKey(r) === ruleKey(VALID_RULE))
assert.strictEqual(matching.length, 1, 'Rule must not be duplicated')
@@ -345,10 +364,12 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
itSdn('should delete the rule', async () => {
await dispatchClient.restApiClient.post(`${BASE}/vifs/${vifId}/actions/delete_traffic_rule?sync=true`, {
allow: VALID_RULE.allow,
direction: VALID_RULE.direction,
ipRange: VALID_RULE.ipRange,
protocol: VALID_RULE.protocol,
port: VALID_RULE.port,
priority: VALID_RULE.priority,
})
const rules = await getVifRules()
const found = rules.find(r => ruleKey(r) === ruleKey(VALID_RULE))
@@ -356,13 +377,140 @@ describe('SDN Controller REST API', { skip: !process.env.SDN_CONTROLLER_VM_ID },
assert.strictEqual(found, undefined, 'Rule should be absent after delete')
})
itSdn('should not error when deleting a non-existent rule', () =>
dispatchClient.restApiClient.post(`${BASE}/vifs/${vifId}/actions/delete_traffic_rule?sync=true`, {
direction: 'to',
ipRange: '192.168.99.0/24',
protocol: 'UDP',
port: 9999,
})
itSdn('should return 404 when deleting a non-existent rule', () =>
assert.rejects(
dispatchClient.restApiClient.post(`${BASE}/vifs/${vifId}/actions/delete_traffic_rule?sync=true`, {
allow: true,
direction: 'to',
ipRange: '192.168.99.0/24',
protocol: 'UDP',
port: 9999,
}),
{ message: /^HTTP 404:/ }
)
)
})
// ---------------------------------------------------------------------------
// The client gives the OpenFlow priority itself: XO stores it and passes it to the XAPI plugin
describe('Traffic rule priorities', () => {
// a range VALID_RULE does not use: the cleanup deletes every network rule in it
const TEST_IP_RANGE = '198.51.100.0/24'
const NET_RULE = { allow: true, direction: 'to', ipRange: TEST_IP_RANGE, protocol: 'TCP', port: 443 }
const VIF_RULE = { allow: true, direction: 'from', ipRange: TEST_IP_RANGE, protocol: 'TCP', port: 22 }
const ruleKey = r => `${r.direction}:${r.ipRange}:${r.protocol}:${r.port}`
const addRule = (collection, id, rule) =>
dispatchClient.restApiClient.post(`${BASE}/${collection}/${id}/actions/add_traffic_rule?sync=true`, rule)
const updateRule = (collection, id, oldRule, newRule) =>
dispatchClient.restApiClient.post(`${BASE}/${collection}/${id}/actions/update_traffic_rule?sync=true`, {
oldRule,
newRule,
})
const deleteRule = (collection, id, { allow, direction, ipRange, protocol, port, priority }) =>
dispatchClient.restApiClient.post(`${BASE}/${collection}/${id}/actions/delete_traffic_rule?sync=true`, {
allow,
direction,
ipRange,
protocol,
port,
priority,
})
// `collection` is `networks` or `vifs`, in the core REST API as in the plugin's
const rulesOf = async (collection, id) =>
parseRules(await dispatchClient.restApiClient.get(`/rest/v0/${collection}/${id}?fields=*`))
const findRule = (rules, rule) => rules.find(r => ruleKey(r) === ruleKey(rule))
const rejectsWithStatus = (promise, status) => assert.rejects(promise, { message: new RegExp(`^HTTP ${status}:`) })
// VIF rules go with the VMs, at teardown
after(async () => {
if (skipReason !== undefined) {
return
}
for (const rule of await rulesOf('networks', networkId)) {
if (rule.ipRange === TEST_IP_RANGE) {
await deleteRule('networks', networkId, rule)
}
}
})
itSdn('should store the priority of a network rule', async () => {
await addRule('networks', networkId, { ...NET_RULE, priority: 40000 })
const { cookie, ...rule } = findRule(await rulesOf('networks', networkId), NET_RULE)
assert.deepStrictEqual(rule, { ...NET_RULE, priority: 40000 })
})
itSdn('should store the priority of a VIF rule', async () => {
await addRule('vifs', vifId, { ...VIF_RULE, priority: 50000 })
assert.deepStrictEqual(findRule(await rulesOf('vifs', vifId), VIF_RULE), { ...VIF_RULE, priority: 50000 })
})
itSdn('should change the priority of a rule', async () => {
await updateRule('networks', networkId, { ...NET_RULE, priority: 40000 }, { priority: 45000 })
assert.strictEqual(findRule(await rulesOf('networks', networkId), NET_RULE)?.priority, 45000)
})
itSdn('should keep the priority when an update leaves it out', async () => {
await updateRule('networks', networkId, { ...NET_RULE, priority: 45000 }, { allow: false })
const rule = findRule(await rulesOf('networks', networkId), NET_RULE)
assert.strictEqual(rule?.allow, false)
assert.strictEqual(rule.priority, 45000)
})
// XO 5 and XO 6 send none yet: such a rule has the OpenFlow default, as the rules from before priorities
itSdn('should accept a rule without a priority, and refuse an update removing one', async () => {
const rule = { ...NET_RULE, port: 1444 }
await addRule('networks', networkId, rule)
const { cookie, ...stored } = findRule(await rulesOf('networks', networkId), rule)
assert.deepStrictEqual(stored, rule)
await updateRule('networks', networkId, rule, { priority: 46000 })
assert.strictEqual(findRule(await rulesOf('networks', networkId), rule)?.priority, 46000)
await rejectsWithStatus(updateRule('networks', networkId, { ...rule, priority: 46000 }, { priority: null }), 422)
})
// network and VIF rules alike
itSdn('should reject a priority another rule of the network has', async () => {
await rejectsWithStatus(addRule('networks', networkId, { ...NET_RULE, port: 2443, priority: 50000 }), 409)
await rejectsWithStatus(addRule('vifs', vifId, { ...VIF_RULE, port: 2222, priority: 45000 }), 409)
await rejectsWithStatus(
updateRule('networks', networkId, { ...NET_RULE, allow: false, priority: 45000 }, { priority: 50000 }),
409
)
})
itSdn('should reject a priority that is not an integer from 0 to 65535', async () => {
const rule = { ...NET_RULE, port: 1443 }
for (const priority of [-1, 1.5, 65536, '1']) {
await rejectsWithStatus(addRule('networks', networkId, { ...rule, priority }), 422)
}
assert.strictEqual(findRule(await rulesOf('networks', networkId), rule), undefined)
await rejectsWithStatus(
updateRule('networks', networkId, { ...NET_RULE, allow: false, priority: 45000 }, { priority: 65536 }),
422
)
})
// xo-cli calls the JSON-RPC methods. 0, a valid priority, is falsy
itSdn('should take a priority through the JSON-RPC API too', async () => {
const { allow, ...match } = { ...NET_RULE, port: 3443 }
await assert.rejects(
dispatchClient.xoClient.call('sdnController.addNetworkRule', { networkId, allow, ...match, priority: 65536 })
)
await dispatchClient.xoClient.call('sdnController.addNetworkRule', { networkId, allow, ...match, priority: 0 })
assert.strictEqual(findRule(await rulesOf('networks', networkId), match)?.priority, 0)
await dispatchClient.xoClient.call('sdnController.deleteNetworkRule', { networkId, ...match, priority: 0 })
assert.strictEqual(findRule(await rulesOf('networks', networkId), match), undefined)
})
itSdn('should delete a rule that has a priority, only when given it', async () => {
await rejectsWithStatus(deleteRule('vifs', vifId, VIF_RULE), 404)
await deleteRule('vifs', vifId, { ...VIF_RULE, priority: 50000 })
assert.strictEqual(findRule(await rulesOf('vifs', vifId), VIF_RULE), undefined)
})
})
})

View File

@@ -23,6 +23,7 @@
- [i18n] Update Chinese (Simplified Han script), Czech, Danish, Dutch, German, Italian, Portuguese, Portuguese (Brasil), Russian, Slovak, Spanish, Swedish and Ukrainian translations (PR [#10464](https://github.com/vatesfr/xen-orchestra/pull/10464))
- [i18n] Update Arabic, Danish, Finnish, Italian, Japanese, Korean, Norwegian, Persian, Polish, Portuguese (Brasil), Russian and Ukrainian translations (PR [#10641](https://github.com/vatesfr/xen-orchestra/pull/10641))
- [CLI] Add new command to `xo-server-db` cli: `disable-remote <id>`, disables a remote while xo-server is stopped (PR [#10587](https://github.com/vatesfr/xen-orchestra/pull/10587/))
- [REST API/SDN Controller] With the XAPI plugin, traffic rules take an optional OpenFlow `priority`, unique on their network, the highest applying first, in `add_traffic_rule`, `update_traffic_rule` and the JSON-RPC methods `sdnController.addRule` and `sdnController.addNetworkRule`; a rule that has one is deleted or updated only when it is given too, in `delete_traffic_rule`, the `oldRule` of `update_traffic_rule` and the JSON-RPC methods `sdnController.deleteRule` and `sdnController.deleteNetworkRule` (PR [#10525](https://github.com/vatesfr/xen-orchestra/pull/10525))
### Bug fixes
@@ -53,12 +54,13 @@
- @xen-orchestra/backups minor
- @xen-orchestra/mixins minor
- @xen-orchestra/proxy minor
- @xen-orchestra/qa-test minor
- @xen-orchestra/rest-api patch
- @xen-orchestra/vmware-explorer minor
- @xen-orchestra/web minor
- @xen-orchestra/web-core minor
- xo-server minor
- xo-server-sdn-controller patch
- xo-server-sdn-controller minor
- xo-web minor
<!--packages-end-->

View File

@@ -212,21 +212,26 @@ Parameters:
- _protocol_: IP, TCP, UDP, ICMP or ARP (case-insensitive).
- _port_: required for TCP/UDP protocol. Use `json:22` so the number is passed as an integer.
- _allow_: true or false. If set to false the packets are dropped.
- _priority_ (optional): the OpenFlow priority of the rule, an integer from 0 to 65535, refused when `useDirectChannel` is enabled. A rule without one has the OpenFlow default, 32768. When several rules of a network match a packet, network-wide and per-VIF rules alike, the one with the **highest** priority applies: a rule of priority 30 applies before a rule of priority 20. Two rules of a network can't be given the same priority. To delete a rule that has one, give it too.
:::tip
Leave room between the priorities of your rules (for example 100, 110, 120…) rather than numbering them one after the other (101, 102, 103…): a new rule can then go between two existing ones without renumbering them.
:::
Some examples:
<Terminal shell title="per-VIF rules">{`
xo-cli sdnController.addRule vifId=15f1a9ec-4348-0cca-d2b8-e536db043298 direction=from ipRange=1.1.1.3 protocol=tcp port=json:4242 allow=false
xo-cli sdnController.deleteRule vifId=15f1a9ec-4348-0cca-d2b8-e536db043298 direction=from ipRange=1.1.1.3 protocol=tcp port=json:4242
xo-cli sdnController.addRule vifId=e9a7914a-9518-82e2-7052-42cb16cc9724 direction=to ipRange=1.1.1.2 protocol=icmp allow=false
xo-cli sdnController.deleteRule vifId=e9a7914a-9518-82e2-7052-42cb16cc9724 direction=to ipRange=1.1.1.2 protocol=icmp
xo-cli sdnController.addRule vifId=15f1a9ec-4348-0cca-d2b8-e536db043298 direction=from ipRange=1.1.1.3 protocol=tcp port=json:4242 allow=false priority=json:100
xo-cli sdnController.deleteRule vifId=15f1a9ec-4348-0cca-d2b8-e536db043298 direction=from ipRange=1.1.1.3 protocol=tcp port=json:4242 priority=json:100
xo-cli sdnController.addRule vifId=e9a7914a-9518-82e2-7052-42cb16cc9724 direction=to ipRange=1.1.1.2 protocol=icmp allow=false priority=json:110
xo-cli sdnController.deleteRule vifId=e9a7914a-9518-82e2-7052-42cb16cc9724 direction=to ipRange=1.1.1.2 protocol=icmp priority=json:110
`}</Terminal>
<Terminal shell title="network-wide rules">{`
xo-cli sdnController.addNetworkRule networkId=9334aa83-6960-62e5-a463-5acc05295af4 direction=to ipRange=1.1.1.2 protocol=icmp allow=false
xo-cli sdnController.deleteNetworkRule networkId=9334aa83-6960-62e5-a463-5acc05295af4 direction=to ipRange=1.1.1.2 protocol=icmp
xo-cli sdnController.addNetworkRule networkId=9334aa83-6960-62e5-a463-5acc05295af4 direction=from ipRange=1.1.1.2 protocol=tcp allow=false port=json:4242
xo-cli sdnController.deleteNetworkRule networkId=9334aa83-6960-62e5-a463-5acc05295af4 direction=from ipRange=1.1.1.2 protocol=tcp port=json:4242
xo-cli sdnController.addNetworkRule networkId=9334aa83-6960-62e5-a463-5acc05295af4 direction=to ipRange=1.1.1.2 protocol=icmp allow=false priority=json:200
xo-cli sdnController.deleteNetworkRule networkId=9334aa83-6960-62e5-a463-5acc05295af4 direction=to ipRange=1.1.1.2 protocol=icmp priority=json:200
xo-cli sdnController.addNetworkRule networkId=9334aa83-6960-62e5-a463-5acc05295af4 direction=from ipRange=1.1.1.2 protocol=tcp allow=false port=json:4242 priority=json:210
xo-cli sdnController.deleteNetworkRule networkId=9334aa83-6960-62e5-a463-5acc05295af4 direction=from ipRange=1.1.1.2 protocol=tcp port=json:4242 priority=json:210
`}</Terminal>
### Migration path

View File

@@ -31,6 +31,7 @@ POST /vifs/{id}/actions/add_traffic_rule :
| `ipRange` | string | Yes | IP address or range. Example: `"111.168.1.2"` or `"111.168.1.0/24"`. |
| `port` | string | No | Port or port range. Empty string means all ports. Example: `"80"` or `"1000-2000"`. |
| `protocol` | string | Yes | Network protocol. Common values: `"TCP"`, `"UDP"`, `"IP"` (any protocol). |
| `priority` | number | No | OpenFlow priority, 0 to 65535, unique on the network, highest first. XAPI plugin only. |
Deletes a new rule
POST /vifs/{id}/actions/delete_traffic_rule :
@@ -43,6 +44,7 @@ POST /vifs/{id}/actions/delete_traffic_rule :
| `ipRange` | string | Yes | IP address or range. Example: `"111.168.1.2"` or `"111.168.1.0/24"`. |
| `port` | string | No | Port or port range. Empty string means all ports. Example: `"80"` or `"1000-2000"`. |
| `protocol` | string | Yes | Network protocol. Common values: `"TCP"`, `"UDP"`, `"IP"` (any protocol). |
| `priority` | number | No | The priority of the rule, required when it has one. |
Creates a new rule at network level
POST /networks/{id}/actions/add_traffic_rule :
@@ -56,6 +58,7 @@ POST /networks/{id}/actions/add_traffic_rule :
| `ipRange` | string | Yes | IP address or range. Example: `"111.168.1.2"` or `"111.168.1.0/24"`. |
| `port` | string | No | Port or port range. Empty string means all ports. Example: `"80"` or `"1000-2000"`. |
| `protocol` | string | Yes | Network protocol. Common values: `"TCP"`, `"UDP"`, `"IP"` (any protocol). |
| `priority` | number | No | OpenFlow priority, 0 to 65535, unique on the network, highest first. XAPI plugin only. |
Deletes a new rule at network level
POST /networks/{id}/actions/delete_traffic_rule :
@@ -68,3 +71,15 @@ POST /networks/{id}/actions/delete_traffic_rule :
| `ipRange` | string | Yes | IP address or range. Example: `"111.168.1.2"` or `"111.168.1.0/24"`. |
| `port` | string | No | Port or port range. Empty string means all ports. Example: `"80"` or `"1000-2000"`. |
| `protocol` | string | Yes | Network protocol. Common values: `"TCP"`, `"UDP"`, `"IP"` (any protocol). |
| `priority` | number | No | The priority of the rule, required when it has one. |
Updates a rule at VIF or network level
POST /vifs/{id}/actions/update_traffic_rule :
POST /networks/{id}/actions/update_traffic_rule :
### Fields
| Field | Type | Required | Description |
| --------- | ------ | -------- | ------------------------------------------------------------------------------------------- |
| `oldRule` | object | Yes | Rule to update: `allow`, `direction`, `ipRange`, `protocol`, `port` and `priority`, if set. |
| `newRule` | object | Yes | Fields to change, `priority` included. `null` removes the `port`. |

View File

@@ -39,6 +39,7 @@ POST /vifs/{id}/actions/add_traffic_rule :
| `ipRange` | string | Yes | IP address or range. Example: `"111.168.1.2"` or `"111.168.1.0/24"`. |
| `port` | string | No | Port or port range. Empty string means all ports. Example: `"80"` or `"1000-2000"`. |
| `protocol` | string | Yes | Network protocol. Common values: `"TCP"`, `"UDP"`, `"IP"` (any protocol). |
| `priority` | number | No | OpenFlow priority, 0 to 65535, unique on the network, highest first. XAPI plugin only. |
Deletes a new rule
POST /vifs/{id}/actions/delete_traffic_rule :
@@ -51,6 +52,7 @@ POST /vifs/{id}/actions/delete_traffic_rule :
| `ipRange` | string | Yes | IP address or range. Example: `"111.168.1.2"` or `"111.168.1.0/24"`. |
| `port` | string | No | Port or port range. Empty string means all ports. Example: `"80"` or `"1000-2000"`. |
| `protocol` | string | Yes | Network protocol. Common values: `"TCP"`, `"UDP"`, `"IP"` (any protocol). |
| `priority` | number | No | The priority of the rule, required when it has one. |
Creates a new rule at network level
POST /networks/{id}/actions/add_traffic_rule :
@@ -64,6 +66,7 @@ POST /networks/{id}/actions/add_traffic_rule :
| `ipRange` | string | Yes | IP address or range. Example: `"111.168.1.2"` or `"111.168.1.0/24"`. |
| `port` | string | No | Port or port range. Empty string means all ports. Example: `"80"` or `"1000-2000"`. |
| `protocol` | string | Yes | Network protocol. Common values: `"TCP"`, `"UDP"`, `"IP"` (any protocol). |
| `priority` | number | No | OpenFlow priority, 0 to 65535, unique on the network, highest first. XAPI plugin only. |
Deletes a new rule at network level
POST /networks/{id}/actions/delete_traffic_rule :
@@ -76,6 +79,18 @@ POST /networks/{id}/actions/delete_traffic_rule :
| `ipRange` | string | Yes | IP address or range. Example: `"111.168.1.2"` or `"111.168.1.0/24"`. |
| `port` | string | No | Port or port range. Empty string means all ports. Example: `"80"` or `"1000-2000"`. |
| `protocol` | string | Yes | Network protocol. Common values: `"TCP"`, `"UDP"`, `"IP"` (any protocol). |
| `priority` | number | No | The priority of the rule, required when it has one. |
Updates a rule at VIF or network level
POST /vifs/{id}/actions/update_traffic_rule :
POST /networks/{id}/actions/update_traffic_rule :
### Fields
| Field | Type | Required | Description |
| --------- | ------ | -------- | ------------------------------------------------------------------------------------------- |
| `oldRule` | object | Yes | Rule to update: `allow`, `direction`, `ipRange`, `protocol`, `port` and `priority`, if set. |
| `newRule` | object | Yes | Fields to change, `priority` included. `null` removes the `port`. |
## Contributions

View File

@@ -8,13 +8,14 @@ import { filter, find, forOwn, map, omitBy } from 'lodash'
import { fromCallback, promisify } from 'promise-toolbox'
import { join } from 'path'
import { v4 as uuidv4 } from 'uuid'
import { invalidParameters, objectAlreadyExists } from 'xo-common/api-errors.js'
import { OvsdbClient } from './protocol/ovsdb-client'
import { PrivateNetwork } from './private-network/private-network'
import { TlsHelper } from './utils/tls-helper'
import { instantiateController } from './openflow-controller'
import { randomBytes } from 'crypto'
import { createRestRoutes } from './rest-api'
import { createRestRoutes, listNetworkRules } from './rest-api'
// =============================================================================
@@ -385,13 +386,17 @@ class SDNController extends EventEmitter {
// ---------------- OpenFlow rules method ----------------------------------
const addRule = params => this._addRule(params)
const addRule = params => {
this._checkTrafficRulePriority(params, params.priority)
return this._addRule(params)
}
addRule.description = 'Add an ACL rule to a VIF'
addRule.params = {
allow: { type: 'boolean' },
direction: { type: 'string' },
ipRange: { type: 'string' },
port: { type: 'integer', optional: true },
priority: { type: 'integer', minimum: 0, maximum: 65535, optional: true },
protocol: { type: 'string' },
vifId: { type: 'string' },
}
@@ -403,18 +408,23 @@ class SDNController extends EventEmitter {
direction: { type: 'string' },
ipRange: { type: 'string' },
port: { type: 'integer', optional: true },
priority: { type: 'integer', minimum: 0, maximum: 65535, optional: true },
protocol: { type: 'string' },
vifId: { type: 'string' },
}
deleteRule.permission = 'admin'
const addNetworkRule = params => this._addNetworkRule(params)
const addNetworkRule = params => {
this._checkTrafficRulePriority(params, params.priority)
return this._addNetworkRule(params)
}
addNetworkRule.description = 'Add an ACL rule to a network'
addNetworkRule.params = {
allow: { type: 'boolean' },
direction: { type: 'string' },
ipRange: { type: 'string' },
port: { type: 'integer', optional: true },
priority: { type: 'integer', minimum: 0, maximum: 65535, optional: true },
protocol: { type: 'string' },
networkId: { type: 'string' },
}
@@ -426,6 +436,7 @@ class SDNController extends EventEmitter {
direction: { type: 'string' },
ipRange: { type: 'string' },
port: { type: 'integer', optional: true },
priority: { type: 'integer', minimum: 0, maximum: 65535, optional: true },
protocol: { type: 'string' },
networkId: { type: 'string' },
}
@@ -708,7 +719,31 @@ class SDNController extends EventEmitter {
// ===========================================================================
async _addRule({ allow, direction, ipRange = '', port, protocol, vifId }) {
// Run before anything is written, as the rule methods log their errors instead of throwing them
_checkTrafficRulePriority({ networkId, vifId }, priority) {
if (this.#staticConfig.useDirectChannel) {
if (priority !== undefined) {
throw invalidParameters('a traffic rule priority needs the XAPI plugin')
}
return
}
// TODO: throw once XO 5 and XO 6 send a priority. Until then, a rule without one has the OVS
// default, 32768, as the rules from before priorities
if (priority === undefined) {
return
}
if (!Number.isInteger(priority) || priority < 0 || priority > 65535) {
throw invalidParameters(`traffic rule priority must be an integer from 0 to 65535, not ${priority}`)
}
networkId ??= this._xo.getObject(vifId, 'VIF').$network
const ruleWithPriority = listNetworkRules(this._xo, networkId).find(rule => rule.priority === priority)
if (ruleWithPriority !== undefined) {
throw objectAlreadyExists({ objectId: JSON.stringify(ruleWithPriority), objectType: 'traffic-rule' })
}
}
async _addRule({ allow, direction, ipRange = '', port, priority, protocol, vifId }) {
const vif = this._xo.getXapiObject(this._xo.getObject(vifId, 'VIF'))
try {
await this._setPoolControllerIfNeeded(vif.$pool)
@@ -718,7 +753,7 @@ class SDNController extends EventEmitter {
const channel = await this._getOrCreateOfChannel(vif.$VM.$resident_on)
if (client !== undefined && channel !== undefined) {
const ofport = await client.getOfPortForVif(vif)
await channel.addRule({ vif, allow, protocol, port, ipRange, direction, ofport })
await channel.addRule({ vif, allow, protocol, port, ipRange, direction, priority, ofport })
}
} catch (error) {
if (error.code === 'HOST_OFFLINE') {
@@ -739,6 +774,7 @@ class SDNController extends EventEmitter {
port,
ipRange,
direction,
priority,
})
if (!newVifRules.includes(stringRule)) {
newVifRules.push(stringRule)
@@ -759,7 +795,7 @@ class SDNController extends EventEmitter {
}
}
async _addNetworkRule({ networkId, allow, direction, ipRange = '', port, protocol }) {
async _addNetworkRule({ networkId, allow, direction, ipRange = '', port, priority, protocol }) {
try {
const network = this._xo.getXapiObject(this._xo.getObject(networkId, 'network'))
assert(network.$PIFs.length > 0, 'Network needs to be plugged to add a rule')
@@ -769,7 +805,11 @@ class SDNController extends EventEmitter {
// filter matching rule (don't compare allow and cookie)
const matchRules = networkRules.filter(rule => {
return (
rule.direction === direction && rule.ipRange === ipRange && rule.port === port && rule.protocol === protocol
rule.direction === direction &&
rule.ipRange === ipRange &&
rule.port === port &&
rule.priority === priority &&
rule.protocol === protocol
)
})
@@ -795,7 +835,11 @@ class SDNController extends EventEmitter {
// update the rule if already here (don't compare allow and cookie) to behave like OpenVSwitch
const newNetworkRules = networkRules.filter(rule => {
return (
rule.protocol !== protocol || rule.port !== port || rule.ipRange !== ipRange || rule.direction !== direction
rule.protocol !== protocol ||
rule.port !== port ||
rule.ipRange !== ipRange ||
rule.direction !== direction ||
rule.priority !== priority
)
})
const newRule = {
@@ -804,6 +848,7 @@ class SDNController extends EventEmitter {
port,
ipRange,
direction,
priority,
cookie,
}
newNetworkRules.push(newRule)
@@ -812,7 +857,7 @@ class SDNController extends EventEmitter {
const host = this._xo.getXapiObject(this._xo.getObject(network.$PIFs[0].host, 'host'))
const channel = await this._getOrCreateOfChannel(host)
if (channel !== undefined) {
await channel.addNetworkRule({ network, allow, protocol, port, ipRange, direction, cookie })
await channel.addNetworkRule({ network, allow, protocol, port, ipRange, direction, priority, cookie })
}
} catch (error) {
if (error.code === 'HOST_OFFLINE') {
@@ -844,7 +889,7 @@ class SDNController extends EventEmitter {
}
}
async _deleteRule({ direction, ipRange = '', port, protocol, vifId }, updateOtherConfig = true) {
async _deleteRule({ direction, ipRange = '', port, priority, protocol, vifId }, updateOtherConfig = true) {
let vif = this._xo.getXapiObject(this._xo.getObject(vifId, 'VIF'))
try {
await this._setPoolControllerIfNeeded(vif.$pool)
@@ -880,7 +925,11 @@ class SDNController extends EventEmitter {
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
rule.protocol !== protocol ||
rule.port !== port ||
rule.ipRange !== ipRange ||
rule.direction !== direction ||
rule.priority !== priority
)
})
@@ -906,7 +955,10 @@ class SDNController extends EventEmitter {
}
}
async _deleteNetworkOfRule({ direction, ipRange = '', port, protocol, networkId }, updateOtherConfig = true) {
async _deleteNetworkOfRule(
{ direction, ipRange = '', port, priority, protocol, networkId },
updateOtherConfig = true
) {
try {
let network = this._xo.getXapiObject(this._xo.getObject(networkId, 'network'))
assert(network.$PIFs.length > 0, 'Network needs to be plugged to delete a rule')
@@ -916,7 +968,11 @@ class SDNController extends EventEmitter {
// filter matching rule (don't compare allow and cookie)
const matchRules = networkRules.filter(rule => {
return (
rule.direction === direction && rule.ipRange === ipRange && rule.port === port && rule.protocol === protocol
rule.direction === direction &&
rule.ipRange === ipRange &&
rule.port === port &&
rule.priority === priority &&
rule.protocol === protocol
)
})
@@ -959,6 +1015,7 @@ class SDNController extends EventEmitter {
rule.port !== port ||
rule.ipRange !== ipRange ||
rule.direction !== direction ||
rule.priority !== priority ||
rule.cookie !== cookie
)
})

View File

@@ -23,11 +23,11 @@ export class OpenFlowPlugin {
)
}
async addRule({ vif, allow, protocol, ipRange, direction, port }) {
async addRule({ vif, allow, protocol, ipRange, direction, port, priority }) {
if (port) {
port = String(port)
}
log.debug('addRule', { vif, allow, protocol, ipRange, direction, port })
log.debug('addRule', { vif, allow, protocol, ipRange, direction, port, priority })
return this.#callPluginOnAllNetwork(vif.$network, 'add-rule', {
mac: vif.MAC,
allow: allow ? 'true' : 'false',
@@ -35,6 +35,8 @@ export class OpenFlowPlugin {
ipRange,
direction,
port,
// 0 is a valid priority
priority: priority === undefined ? undefined : String(priority),
})
}
@@ -53,17 +55,18 @@ export class OpenFlowPlugin {
})
}
async addNetworkRule({ network, allow, protocol, ipRange, direction, port, cookie }) {
async addNetworkRule({ network, allow, protocol, ipRange, direction, port, priority, cookie }) {
if (port) {
port = String(port)
}
log.debug('addNetworkRule', { network, allow, protocol, ipRange, direction, port, cookie })
log.debug('addNetworkRule', { network, allow, protocol, ipRange, direction, port, priority, cookie })
return this.#callPluginOnAllNetwork(network, 'add-rule', {
allow: allow ? 'true' : 'false',
protocol,
ipRange,
direction,
port,
priority: priority === undefined ? undefined : String(priority),
cookie,
})
}

View File

@@ -119,8 +119,8 @@ export class OpenFlowChannel extends EventEmitter {
try {
await this._coalesceConnect()
} catch (error) {
log.error("addRule: _coalesceConnect", {
error
log.error('addRule: _coalesceConnect', {
error,
})
return
}
@@ -200,8 +200,8 @@ export class OpenFlowChannel extends EventEmitter {
try {
await this._coalesceConnect()
} catch (error) {
log.error("deleteRule: _coalesceConnect", {
error
log.error('deleteRule: _coalesceConnect', {
error,
})
return
}

View File

@@ -6,14 +6,25 @@ const QUERY_SYNC = { sync: { type: 'boolean', optional: true } }
const PARAMS_ID = { id: { type: 'string', example: 'b97f4e69-d275-4b25-9dc9-c1ac4e9b3fa5' } }
// only the XAPI plugin sets one
const PRIORITY_FIELD = { type: 'number', example: 100, optional: true }
const RULE_FIELDS = {
allow: { type: 'boolean', example: true },
direction: { type: 'string', example: 'to' },
ipRange: { type: 'string', example: '192.168.0.0/24' },
protocol: { type: 'string', example: 'tcp' },
port: { type: 'number', example: 443, optional: true },
priority: PRIORITY_FIELD,
}
const PRIORITY_DESCRIPTION = [
'`priority` is the OpenFlow priority of the rule, an integer from 0 to 65535, optional with the XAPI plugin and refused without it (`useDirectChannel` in the configuration). A rule without one has the OpenFlow default, 32768.',
'Of the rules of a network matching a packet, network and VIF rules alike, the one with the highest priority applies: a rule of priority 30 applies before a rule of priority 20.',
'Two rules of a network cannot be given the same priority.',
'Leave room between priorities (100, 110, 120…, not 101, 102, 103…): a rule can then go between two others without renumbering them.',
].join(' ')
const BODY_UPDATE_RULE = {
oldRule: { type: 'object', fields: RULE_FIELDS },
newRule: {
@@ -24,6 +35,7 @@ const BODY_UPDATE_RULE = {
ipRange: { type: 'string', example: '10.0.0.0/8', optional: true },
protocol: { type: 'string', example: 'tcp', optional: true },
port: { type: 'number', example: 80, optional: true, nullable: true },
priority: PRIORITY_FIELD,
},
},
}
@@ -56,12 +68,35 @@ function parseRules(raw) {
return raw == null ? [] : JSON.parse(raw).map(JSON.parse)
}
// The rules of a network or a VIF, as the 409 of a taken priority returns them: a network rule's cookie is internal
function listedRules(object) {
return parseRules(object.other_config[SDN_CONTROLLER_OF_RULES_KEY]).map(({ cookie, ...rule }) => ({
...rule,
type: object.type,
sourceId: object.id,
}))
}
// Not those of snapshots and templates: XAPI copies a VIF's rules with it, but they never apply
function vmVifsOf(xo, networkId) {
return xo
.getObject(networkId, 'network')
.VIFs.map(id => xo.getObject(id, 'VIF'))
.filter(vif => xo.getObject(vif.$VM).type === 'VM')
}
// The rules of a network, then those of the VIFs of its VMs
export function listNetworkRules(xo, networkId) {
return [xo.getObject(networkId, 'network'), ...vmVifsOf(xo, networkId)].flatMap(listedRules)
}
function rulesEqual(a, b) {
return (
a.allow === b.allow &&
a.direction === b.direction &&
a.ipRange === b.ipRange &&
a.port === b.port &&
a.priority === b.priority &&
a.protocol.toLowerCase() === b.protocol.toLowerCase()
)
}
@@ -90,13 +125,16 @@ function ruleFromBody(req, idKey) {
if (req.body.port != null) {
rule.port = req.body.port
}
if (req.body.priority != null) {
rule.priority = req.body.priority
}
return rule
}
function addRuleRoute(controller, resource) {
return {
endpoint: `/${resource.collection}/{id}/actions/add_traffic_rule`,
description: `Add a traffic rule to a ${resource.type}.\n\nRequired privilege:\n - resource: ${resource.acl}, action: update:other_config`,
description: `Add a traffic rule to a ${resource.type}.\n\n${PRIORITY_DESCRIPTION}\n\nRequired privilege:\n - resource: ${resource.acl}, action: update:other_config`,
method: 'post',
tags: ['sdn-controller'],
params: PARAMS_ID,
@@ -105,20 +143,31 @@ function addRuleRoute(controller, resource) {
responses: [
{ status: 204, description: 'Rule added successfully' },
{ status: 404, description: `No ${resource.type} found for this ID` },
{ status: 409, description: 'Another rule of the network has this priority' },
{
status: 422,
description: 'Invalid rule or priority, or priority given without the XAPI plugin',
},
],
middlewares: jsonAndAcl(resource.acl),
callback: ({ req, createAction }) => {
const rule = ruleFromBody(req, resource.idKey)
return createAction(() => resource.addRule(controller, rule), {
sync: req.query.sync ?? false,
statusCode: 204,
taskProperties: {
name: `add ${resource.acl} traffic rule`,
objectId: rule[resource.idKey],
objectType: resource.type,
params: req.body,
return createAction(
async () => {
controller._checkTrafficRulePriority(rule, req.body.priority)
await resource.addRule(controller, rule)
},
})
{
sync: req.query.sync ?? false,
statusCode: 204,
taskProperties: {
name: `add ${resource.acl} traffic rule`,
objectId: rule[resource.idKey],
objectType: resource.type,
params: req.body,
},
}
)
},
}
}
@@ -167,7 +216,7 @@ function deleteRuleRoute(controller, resource) {
function updateRuleRoute(controller, resource) {
return {
endpoint: `/${resource.collection}/{id}/actions/update_traffic_rule`,
description: `Update a rule on a ${resource.type}: \`oldRule\` identifies the rule to update and must be given in full, \`newRule\` is a partial update where a field set to \`null\` is removed from the rule.\n\nRequired privilege:\n - resource: ${resource.acl}, action: update:other_config`,
description: `Update a rule on a ${resource.type}: \`oldRule\` identifies the rule to update and must be given in full, its priority included, \`newRule\` is a partial update where a field set to \`null\` is removed from the rule.\n\n${PRIORITY_DESCRIPTION}\n\nRequired privilege:\n - resource: ${resource.acl}, action: update:other_config`,
method: 'post',
tags: ['sdn-controller'],
params: PARAMS_ID,
@@ -176,6 +225,11 @@ function updateRuleRoute(controller, resource) {
responses: [
{ status: 204, description: 'Rule updated successfully' },
{ status: 404, description: `Old rule does not exist on this ${resource.type}` },
{ status: 409, description: 'Another rule of the network has the new priority' },
{
status: 422,
description: 'Invalid rule or priority, or priority given without the XAPI plugin',
},
],
middlewares: jsonAndAcl(resource.acl),
callback: ({ req, createAction }) => {
@@ -189,6 +243,10 @@ function updateRuleRoute(controller, resource) {
throw noSuchObject(JSON.stringify(oldRule), 'traffic-rule')
}
const newRule = applyRulePatch(oldRule, partialNewRule)
// a kept priority may be shared, like the copy a clone makes of a VIF's rules
if (newRule.priority !== oldRule.priority) {
controller._checkTrafficRulePriority({ [resource.idKey]: id }, newRule.priority)
}
await resource.deleteRule(controller, { ...oldRule, [resource.idKey]: id })
await resource.addRule(controller, { ...newRule, [resource.idKey]: id })

View File

@@ -0,0 +1,176 @@
import { describe, it } from 'node:test'
import { strict as assert } from 'node:assert'
import { invalidParameters, noSuchObject, objectAlreadyExists } from 'xo-common/api-errors.js'
import { SDN_CONTROLLER_OF_RULES_KEY } from '@vates/types'
import createSdnController from './index.js'
import { createRestRoutes, listNetworkRules } from './rest-api.js'
const noop = () => undefined
const SSH = { allow: false, protocol: 'TCP', port: 22, ipRange: '10.0.0.0/8', direction: 'to' }
const WEB = { allow: true, protocol: 'TCP', port: 443, ipRange: '10.0.0.0/8', direction: 'to' }
const NETWORK_RULE = { ...SSH, priority: 40000 }
const VIF_RULE = { ...WEB, priority: 30000 }
const storeRules = rules => ({ [SDN_CONTROLLER_OF_RULES_KEY]: JSON.stringify(rules.map(rule => JSON.stringify(rule))) })
const NETWORK = {
id: 'net',
type: 'network',
VIFs: ['vif1', 'vif2', 'vif3'],
other_config: storeRules([{ ...NETWORK_RULE, cookie: '0x1' }]),
}
const VIF = {
id: 'vif1',
type: 'VIF',
$VM: 'vm',
$network: 'net',
other_config: storeRules([VIF_RULE]),
}
// XAPI copies the rules of a VIF to the VIFs of the snapshots and the clones of its VM
const SNAPSHOT_VIF = { ...VIF, id: 'vif2', $VM: 'snapshot' }
const CLONE_VIF = { ...VIF, id: 'vif3', $VM: 'clone' }
function createController({ useDirectChannel = false } = {}) {
const objects = {
net: NETWORK,
vif1: VIF,
vif2: SNAPSHOT_VIF,
vif3: CLONE_VIF,
vm: { type: 'VM' },
snapshot: { type: 'VM-snapshot' },
clone: { type: 'VM' },
}
return createSdnController({
xo: {
addApiMethods: noop,
registerRestRoutes: noop,
getAllXapis: () => [],
getObject: id => objects[id],
on: noop,
removeListener: noop,
},
getDataDir: async () => '/tmp',
staticConfig: { useDirectChannel },
})
}
function findRoute(controller, method, endpoint) {
return createRestRoutes(controller).find(route => route.method === method && route.endpoint === endpoint)
}
// Runs an action route, and the task it creates at once. Returns whether the task was created.
async function runAction(controller, endpoint, id, body) {
let created = false
await findRoute(controller, 'post', endpoint).callback({
req: { params: { id }, query: { sync: true }, body },
createAction: task => {
created = true
return task()
},
})
return created
}
// the checks run in the task: without `sync`, the route answers 202 and the error shows in the task
function rejectsInTheTask(controller, endpoint, id, body, error) {
return assert.rejects(runAction(controller, endpoint, id, body), actual => error.is(actual))
}
const ADD = '/networks/{id}/actions/add_traffic_rule'
const UPDATE = '/networks/{id}/actions/update_traffic_rule'
const DELETE = '/networks/{id}/actions/delete_traffic_rule'
describe('a rule', () => {
it('is found with all its fields, its priority included', async () => {
const controller = createController()
for (const oldRule of [SSH, { ...SSH, priority: 100 }]) {
await rejectsInTheTask(controller, UPDATE, 'net', { oldRule, newRule: { allow: true } }, noSuchObject)
await rejectsInTheTask(controller, DELETE, 'net', oldRule, noSuchObject)
}
})
})
describe('the priority of a rule', () => {
it('is an integer from 0 to 65535', async () => {
for (const priority of [-1, 1.5, 65536]) {
await rejectsInTheTask(createController(), ADD, 'net', { ...WEB, priority }, invalidParameters)
await rejectsInTheTask(
createController(),
UPDATE,
'net',
{ oldRule: NETWORK_RULE, newRule: { priority } },
invalidParameters
)
}
})
// XO 5 and XO 6 send none yet
it('is optional with the XAPI plugin', async () => {
const controller = createController()
controller._addNetworkRule = noop
assert.equal(await runAction(controller, ADD, 'net', WEB), true)
})
it('is refused without the XAPI plugin', async () => {
const controller = createController({ useDirectChannel: true })
await rejectsInTheTask(controller, ADD, 'net', { ...WEB, priority: 100 }, invalidParameters)
await rejectsInTheTask(
controller,
UPDATE,
'net',
{ oldRule: NETWORK_RULE, newRule: { priority: 100 } },
invalidParameters
)
})
it('is unique on the network, among its rules and those of the VIFs of its VMs', async () => {
await rejectsInTheTask(createController(), ADD, 'net', { ...WEB, priority: 30000 }, objectAlreadyExists)
const body = { oldRule: NETWORK_RULE, newRule: { priority: 30000 } }
await rejectsInTheTask(createController(), UPDATE, 'net', body, objectAlreadyExists)
const endpoint = '/vifs/{id}/actions/add_traffic_rule'
await rejectsInTheTask(createController(), endpoint, 'vif1', { ...SSH, priority: 40000 }, objectAlreadyExists)
})
it('may be kept by a rule that shares it, like the copy a clone makes', async () => {
const controller = createController()
controller._deleteRule = noop
controller._addRule = noop
const body = { oldRule: VIF_RULE, newRule: { allow: false } }
assert.equal(await runAction(controller, '/vifs/{id}/actions/update_traffic_rule', 'vif3', body), true)
})
})
describe('the update of a rule', () => {
// returns the rule added in place of the old one
async function update(newRule) {
const controller = createController()
let added
controller._deleteNetworkOfRule = noop
controller._addNetworkRule = rule => {
added = rule
}
await runAction(controller, UPDATE, 'net', { oldRule: NETWORK_RULE, newRule })
return added
}
it('keeps the priority when `newRule` leaves it out', async () => {
assert.deepEqual(await update({ allow: true }), { ...SSH, allow: true, priority: 40000, networkId: 'net' })
})
it('changes the priority', async () => {
assert.deepEqual(await update({ priority: 100 }), { ...SSH, priority: 100, networkId: 'net' })
})
})
// those the priority of a new rule is checked against
describe('the rules of a network', () => {
it('are its own, without their cookie, then those of the VIFs of its VMs', () => {
assert.deepEqual(listNetworkRules(createController()._xo, 'net'), [
{ ...SSH, priority: 40000, type: 'network', sourceId: 'net' },
{ ...WEB, priority: 30000, type: 'VIF', sourceId: 'vif1' },
{ ...WEB, priority: 30000, type: 'VIF', sourceId: 'vif3' },
])
})
})