diff --git a/Makefile b/Makefile index 3f3bd47..d542fcd 100644 --- a/Makefile +++ b/Makefile @@ -2,3 +2,8 @@ .PHONY: test test: docker-compose up --build + open coverage/index.html + +.PHONY: clean +clean: + -rm -rf ./coverage ./node_modules diff --git a/README.md b/README.md index 33c293a..8446a36 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,43 @@ # tephra -An event-driven [RADIUS](https://en.wikipedia.org/wiki/RADIUS) server micro-framework based on [node-radius](https://github.com/retailnext/node-radius). Now it's easier than ever to write a RADIUS server that isn't standards-compliant! `;)` +An event-driven [RADIUS](https://en.wikipedia.org/wiki/RADIUS) server micro-framework based on [node-radius](https://github.com/retailnext/node-radius). -## Example +## Configuration -```javascript -var tephra = require('tephra') +Key | Type | Required | Notes +--- | ---- | -------- | ----- +`secret` | `String` | ✅ | +`ports` | `Object` | ✅ | All port types are optional, but at least one must be specified, so as to permit instances with different responsibilities. +`ports.authentication` | `Number` | ❌ | Must be a valid port number (0 - 65535 inclusive) +`ports.accounting` | `Number` | ❌ | Must be a valid port number (0 - 65535 inclusive) +`ports.changeOfAuthorisation` | `Number` | ❌ | Must be a valid port number (0 - 65535 inclusive) +`vendorDictionaries` | `Array` | ❌ | Elements of the array must be objects that conform to `{name: String, path: String, id: Number}` -var users = {user1: 'secret_password'} +## Example -var server = new tephra( - 'shared_secret', - 1812, // authentication port - 1813, // accounting port - 3799, // change of authorisation port - [ // add dictionaries for vendor-specific attributes +```javascript +import tephra from 'tephra' + +var users = {user1: 'foo'} + +var server = new tephra({ + secret: 'foo', + ports: { + authentication: 1812, + accounting: 1813, + changeOfAuthorisation: 1814 + }, + vendorDictionaries: [ { name: 'quux_vendor', path: '/path/to/quux_vendor/dictionary', id: 12345 } ] -) +}) -server.on('Access-Request', function(packet, rinfo, accept, reject) { +server.on('Access-Request', function(packet, remote_host, accept, reject) { var username = packet.attributes['User-Name'] var password = packet.attributes['User-Password'] @@ -46,24 +59,40 @@ server.on('Access-Request', function(packet, rinfo, accept, reject) { accept(attributes, vendor_attributes, console.log) -}).on('Accounting-Request', function(packet, rinfo, respond) { +}).on('Accounting-Request', function(packet, remote_host, respond) { // catch all accounting-requests respond([], {}, console.log) -}).on('Accounting-Request-Start', function(packet, rinfo, respond) { +}).on('Accounting-Request-Start', function(packet, remote_host, respond) { // or just catch specific accounting-request status types... respond([], {}, console.log) -}).on('Accounting-Request-Interim-Update', function(packet, rinfo, respond) { +}).on('Accounting-Request-Interim-Update', function(packet, remote_host, respond) { respond([], {}, console.log) -}).on('Accounting-Request-Stop', function(packet, rinfo, respond) { +}).on('Accounting-Request-Stop', function(packet, remote_host, respond) { respond([], {}, console.log) +}).on('CoA-ACK', function(packet, remote_host) { + + console.log(packet, remote_host) + +}).on('CoA-NAK', function(packet, remote_host) { + + console.log(packet, remote_host) + +}).on('Disconnect-ACK', function(packet, remote_host) { + + console.log(packet, remote_host) + +}).on('Disconnect-NAK', function(packet, remote_host) { + + console.log(packet, remote_host) + }) server.bind() diff --git a/docker-compose.yaml b/docker-compose.yaml index d8f1fd0..6160a80 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,3 +2,5 @@ services: tephra: build: . + volumes: + - ./coverage:/app/coverage diff --git a/index.js b/index.js deleted file mode 100644 index 6b3a552..0000000 --- a/index.js +++ /dev/null @@ -1,2 +0,0 @@ - -module.exports = require('./src') diff --git a/package-lock.json b/package-lock.json index e8a3e70..4d2628d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,22 @@ "radius": "1.1.4" }, "devDependencies": { + "c8": "10.1.3", "chai": "5.1.2", "mocha": "11.1.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=14.0.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" } }, "node_modules/@isaacs/cliui": { @@ -37,6 +48,44 @@ "node": ">=12" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -48,6 +97,13 @@ "node": ">=14" } }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -167,6 +223,71 @@ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, + "node_modules/c8": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", + "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^7.0.1", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/c8/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/c8/node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -614,6 +735,13 @@ "he": "bin/he" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -700,6 +828,74 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -1013,6 +1209,19 @@ ], "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -1205,6 +1414,28 @@ "node": ">=8.0" } }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 78efe58..27219b5 100644 --- a/package.json +++ b/package.json @@ -16,16 +16,19 @@ "accounting" ], "scripts": { - "test": "if [ $(uname) = 'Darwin' ]; then echo NOTICE: please use 'make test' to run the test suite; exit 1; else mocha test; fi" + "test": "c8 --reporter=html mocha" }, "repository": { "type": "git", "url": "https://github.com/io-digital/tephra" }, "engines": { - "node": ">=6.0.0" + "node": ">=14.0.0" }, + "type": "module", + "module": "src/index.js", "devDependencies": { + "c8": "10.1.3", "chai": "5.1.2", "mocha": "11.1.0" }, diff --git a/src/access_accept.js b/src/access_accept.js index 8b59586..38fbc42 100644 --- a/src/access_accept.js +++ b/src/access_accept.js @@ -1,18 +1,18 @@ -module.exports = function access_accept( +export default function access_accept( decoded, - rinfo, + remote_host, attributes, vendor_attributes, on_accepted ) { this.respond( - 'auth', + 'authentication', decoded, 'Access-Accept', - rinfo, + remote_host, attributes, vendor_attributes, - on_accepted || function() {} + on_accepted ) } diff --git a/src/access_reject.js b/src/access_reject.js index 939ec30..ebede0d 100644 --- a/src/access_reject.js +++ b/src/access_reject.js @@ -1,18 +1,18 @@ -module.exports = function access_reject( +export default function access_reject( decoded, - rinfo, + remote_host, attributes, vendor_attributes, on_rejected ) { this.respond( - 'auth', + 'authentication', decoded, 'Access-Reject', - rinfo, + remote_host, attributes, vendor_attributes, - on_rejected || function() {} + on_rejected ) } diff --git a/src/accounting_on_message.js b/src/accounting_on_message.js new file mode 100644 index 0000000..50b344f --- /dev/null +++ b/src/accounting_on_message.js @@ -0,0 +1,32 @@ + +import decode from './decode.js' +import accounting_respond from './accounting_respond.js' + +export default function accounting_on_message(message, remote_host) { + var decoded = decode.call( + this, + message, + function(packet) { + return packet.code === 'Accounting-Request' + }, + this.emit.bind(this, 'error#decode#acct') + ) + + if (!decoded) return + + // emit accounting-request + this.emit( + decoded.code, + decoded, + remote_host, + accounting_respond.bind(this, decoded, remote_host) + ) + + // as well as accounting-request-{{status-type}} + this.emit( + `${decoded.code}-${decoded.attributes['Acct-Status-Type']}`, + decoded, + remote_host, + accounting_respond.bind(this, decoded, remote_host) + ) +} diff --git a/src/accounting_respond.js b/src/accounting_respond.js index ba9c97b..ee11c2a 100644 --- a/src/accounting_respond.js +++ b/src/accounting_respond.js @@ -1,18 +1,18 @@ -module.exports = function accounting_respond( +export default function accounting_respond( decoded, - rinfo, + remote_host, attributes, vendor_attributes, on_responded ) { this.respond( - 'acct', + 'accounting', decoded, 'Accounting-Response', - rinfo, + remote_host, attributes, vendor_attributes, - on_responded || function() {} + on_responded ) } diff --git a/src/acct_on_message.js b/src/acct_on_message.js deleted file mode 100644 index 408e509..0000000 --- a/src/acct_on_message.js +++ /dev/null @@ -1,29 +0,0 @@ - -var decode = require('./decode') -var accounting_respond = require('./accounting_respond') - -module.exports = function acct_on_message(message, rinfo) { - var decoded = decode.call( - this, - message, - function(packet) { - return packet.code === 'Accounting-Request' - }, - this.emit.bind(this, 'error#decode#acct') - ) - if (!decoded) return - // emit accounting-request - this.emit( - decoded.code, - decoded, - rinfo, - accounting_respond.bind(this, decoded, rinfo) - ) - // as well as accounting-request-{{status-type}} - this.emit( - `${decoded.code}-${decoded.attributes['Acct-Status-Type'] || 'unknown'}`, - decoded, - rinfo, - accounting_respond.bind(this, decoded, rinfo) - ) -} diff --git a/src/auth_on_message.js b/src/auth_on_message.js deleted file mode 100644 index 316419f..0000000 --- a/src/auth_on_message.js +++ /dev/null @@ -1,26 +0,0 @@ - -var decode = require('./decode') -var access_accept = require('./access_accept') -var access_reject = require('./access_reject') - -module.exports = function auth_on_message(message, rinfo) { - var decoded = decode.call( - this, - message, - function(packet) { - return packet.code === 'Access-Request' - }, - this.emit.bind(this, 'error#decode#auth') - ) - if (!decoded) { - // seems sensible to default to access-reject here - return access_reject.call(this, decoded, rinfo) - } - this.emit( - decoded.code, - decoded, - rinfo, - access_accept.bind(this, decoded, rinfo), - access_reject.bind(this, decoded, rinfo) - ) -} diff --git a/src/authentication_on_message.js b/src/authentication_on_message.js new file mode 100644 index 0000000..d0e172e --- /dev/null +++ b/src/authentication_on_message.js @@ -0,0 +1,28 @@ + +import decode from './decode.js' +import access_accept from './access_accept.js' +import access_reject from './access_reject.js' + +export default function authentication_on_message(message, remote_host) { + var decoded = decode.call( + this, + message, + function(packet) { + return packet.code === 'Access-Request' + }, + this.emit.bind(this, 'error#decode#auth') + ) + + if (!decoded) { + // seems sensible to default to access-reject here + return access_reject.call(this, decoded, remote_host, [], {}, function() {}) + } + + this.emit( + decoded.code, + decoded, + remote_host, + access_accept.bind(this, decoded, remote_host), + access_reject.bind(this, decoded, remote_host) + ) +} diff --git a/src/coa_on_message.js b/src/change_of_authorisation_on_message.js similarity index 57% rename from src/coa_on_message.js rename to src/change_of_authorisation_on_message.js index bbe5a15..425dc05 100644 --- a/src/coa_on_message.js +++ b/src/change_of_authorisation_on_message.js @@ -1,7 +1,7 @@ -var decode = require('./decode') +import decode from './decode.js' -module.exports = function coa_on_message(message, rinfo) { +export default function change_of_authorisation_on_message(message, remote_host) { var decoded = decode.call( this, message, @@ -11,10 +11,12 @@ module.exports = function coa_on_message(message, rinfo) { 'Disconnect-NAK', 'CoA-ACK', 'CoA-NAK' - ].indexOf(packet.code) !== -1 + ].includes(packet.code) }, this.emit.bind(this, 'error#decode#coa') ) + if (!decoded) return - this.emit(decoded.code, decoded, rinfo) + + this.emit(decoded.code, decoded, remote_host) } diff --git a/src/decode.js b/src/decode.js index c679a06..dd4d852 100644 --- a/src/decode.js +++ b/src/decode.js @@ -1,19 +1,21 @@ -var radius = require('radius') +var radius = (await import('radius')).default -module.exports = function decode(message, guard, on_error) { +export default function decode(message, guard, on_error) { try { var decoded = radius.decode({ packet: message, - secret: this.SHARED_SECRET + secret: this.secret }) } catch (err) { on_error(err) return } + if (!guard(decoded)) { on_error(new Error('packed decode guard failed')) return } + return decoded } diff --git a/src/encode_request.js b/src/encode_request.js index 54178e4..ac42734 100644 --- a/src/encode_request.js +++ b/src/encode_request.js @@ -1,9 +1,9 @@ -var radius = require('radius') +var radius = (await import('radius')).default -var node_radius_shim = require('./node_radius_shim') +import node_radius_shim from './node_radius_shim.js' -module.exports = function encode_request( +export default function encode_request( code, attributes, vendor_attributes, @@ -12,12 +12,13 @@ module.exports = function encode_request( try { var encoded = radius.encode({ attributes: node_radius_shim.call(this, attributes, vendor_attributes), - secret: this.SHARED_SECRET, + secret: this.secret, code: code }) } catch (err) { on_error(err) return } + return encoded } diff --git a/src/encode_response.js b/src/encode_response.js index 8214d3b..e049586 100644 --- a/src/encode_response.js +++ b/src/encode_response.js @@ -1,9 +1,9 @@ -var radius = require('radius') +var radius = (await import('radius')).default -var node_radius_shim = require('./node_radius_shim') +import node_radius_shim from './node_radius_shim.js' -module.exports = function encode_response( +export default function encode_response( packet, code, attributes, @@ -15,11 +15,12 @@ module.exports = function encode_response( packet: packet, code: code, attributes: node_radius_shim.call(this, attributes, vendor_attributes), - secret: this.SHARED_SECRET + secret: this.secret }) } catch (err) { on_error(err) return } + return encoded } diff --git a/src/index.js b/src/index.js index 2d17bbe..452ad2f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,91 +1,155 @@ -'use strict' +import {EventEmitter} from 'events' +import dgram from 'dgram' -var EventEmitter = require('events') -var dgram = require('dgram') +var radius = (await import('radius')).default -var radius = require('radius') +import send from './send.js' +import encode_request from './encode_request.js' +import encode_response from './encode_response.js' +import authentication_on_message from './authentication_on_message.js' +import accounting_on_message from './accounting_on_message.js' +import change_of_authorisation_on_message from './change_of_authorisation_on_message.js' +import * as validate from './validate.js' -var send = require('./send') -var encode_request = require('./encode_request') -var encode_response = require('./encode_response') -var auth_on_message = require('./auth_on_message') -var acct_on_message = require('./acct_on_message') -var coa_on_message = require('./coa_on_message') - -module.exports = (class extends EventEmitter { +export default class extends EventEmitter { constructor( - SHARED_SECRET, - AUTH_PORT, - ACCT_PORT, - COA_PORT, - VENDOR_DICTIONARIES + options = { + secret: '', + shared_secret: '', + sharedSecret: '', + ports: { + auth: false, + authentication: false, + acct: false, + accounting: false, + coa: false, + change_of_authorisation: false, + changeOfAuthorisation: false, + changeOfAuthorization: false + }, + vendor_dictionaries: null, + vendorDictionaries: null + } ) { super() - if (!(SHARED_SECRET && AUTH_PORT && ACCT_PORT && COA_PORT)) { - throw new Error('Missing SHARED_SECRET, AUTH_PORT, ACCT_PORT or COA_PORT arguments') + var secret = (options.secret || options.shared_secret || options.sharedSecret) + + if (!secret) { + throw new Error('Missing shared secret') } - this.SHARED_SECRET = SHARED_SECRET - this.AUTH_PORT = AUTH_PORT - this.ACCT_PORT = ACCT_PORT - this.COA_PORT = COA_PORT - this.VENDOR_IDS = {} - - if (Array.isArray(VENDOR_DICTIONARIES) && VENDOR_DICTIONARIES.length) { - VENDOR_DICTIONARIES.forEach((dict, idx) => { - if (!( - typeof dict.vendor === 'string' && - dict.vendor.length && - typeof dict.path === 'string' && - dict.path.length && - typeof dict.id === 'number' && - dict.id - )) { + this.secret = secret + + var vendor_dictionaries = (options.vendor_dictionaries || options.vendorDictionaries) + + this.vendor_ids = {} + + if (vendor_dictionaries && Array.isArray(vendor_dictionaries)) { + vendor_dictionaries.forEach(function(vendor_dictionary, idx) { + if (!validate.vendor_dictionary(vendor_dictionary)) { throw new Error( - `Expected {vendor: String, path: String, id: Number} at index ${idx} in VENDOR_DICTIONARIES` + `Vendor dictionary at index ${idx} is malformed. Expected {name: String, path: String, id: Number} but got ${JSON.stringify(vendor_dictionary)}` ) } - radius.add_dictionary(dict.path) - this.VENDOR_IDS[dict.vendor] = dict.id - }) + + radius.add_dictionary(vendor_dictionary.path) + this.vendor_ids[vendor_dictionary.name] = vendor_dictionary.id + }, this) + } + + if (!options.ports) { + throw new Error('At least one port is required') } - this.SOCKETS = { - AUTH: dgram.createSocket('udp4', auth_on_message.bind(this)), - ACCT: dgram.createSocket('udp4', acct_on_message.bind(this)), - COA: dgram.createSocket('udp4', coa_on_message.bind(this)) + var authentication_port = +(options.ports.auth || options.ports.authentication) + var accounting_port = +(options.ports.acct || options.ports.accounting) + var change_of_authorisation_port = +( + options.ports.coa || + options.ports.change_of_authorisation || + options.ports.changeOfAuthorisation || + options.ports.changeOfAuthorization + ) + + if (!(authentication_port || accounting_port || change_of_authorisation_port)) { + throw new Error('At least one port is required') } + + var sockets = [ + { + name: 'authentication', + port: authentication_port, + key: 'authentication', + callback: authentication_on_message + }, + { + name: 'accounting', + port: accounting_port, + key: 'accounting', + callback: accounting_on_message + }, + { + name: 'change of authorisation', + port: change_of_authorisation_port, + key: 'change_of_authorisation', + callback: change_of_authorisation_on_message + } + ] + + this.sockets = {} + + sockets.forEach(function(socket) { + var {name, port, key, callback} = socket + + // check if we can return early but also make sure the + // port isn't set to zero because zero is a valid port number + if (!port && port !== 0) return + + if (!validate.port(port)) { + throw new Error(`Invalid port specified for ${name} socket`) + } + + this[key] = port + this.sockets[key] = dgram.createSocket('udp4', callback.bind(this)) + }, this) } bind(on_bound) { - this.SOCKETS.AUTH.bind(this.AUTH_PORT) - this.SOCKETS.ACCT.bind(this.ACCT_PORT) - this.SOCKETS.COA.bind(this.COA_PORT) + Object.keys( + this.sockets + ).forEach(function(socket) { + this.sockets[socket].bind(this[socket]) + }, this) + return typeof on_bound === 'function' ? on_bound() : this } unbind(on_unbound) { - this.SOCKETS.AUTH.close() - this.SOCKETS.ACCT.close() - this.SOCKETS.COA.close() + Object.keys( + this.sockets + ).forEach(function(socket) { + this.sockets[socket].close() + }, this) + this.removeAllListeners() + return typeof on_unbound === 'function' ? on_unbound() : this } send( - type, + socket, code, - rinfo, + remote_host, attributes, vendor_attributes, on_sent ) { - if (typeof type !== 'string') { - throw new Error('Missing required string argument type') + if (!(socket in this.sockets)) { + throw new Error(`Invalid socket given: ${socket}`) } + var encoded = encode_request.call( this, code, @@ -93,27 +157,30 @@ module.exports = (class extends EventEmitter { vendor_attributes, on_sent ) + if (!encoded) return + send.call( - this.SOCKETS[type.toUpperCase()], + this.sockets[socket], encoded, - rinfo, + remote_host, on_sent ) } respond( - type, + socket, packet, code, - rinfo, + remote_host, attributes, vendor_attributes, on_responded ) { - if (typeof type !== 'string') { - throw new Error('Missing required string argument type') + if (!(socket in this.sockets)) { + throw new Error(`Invalid socket given: ${socket}`) } + var encoded = encode_response.call( this, packet, @@ -122,31 +189,31 @@ module.exports = (class extends EventEmitter { vendor_attributes, on_responded ) + if (!encoded) return + send.call( - this.SOCKETS[type.toUpperCase()], + this.sockets[socket], encoded, - rinfo, + remote_host, on_responded ) } disconnect( - rinfo, + remote_host, attributes, vendor_attributes, - on_sent + on_disconnect_sent ) { - // override the reply port for the sake of convenience - rinfo.port = this.COA_PORT - this.send( - 'coa', + // just send from the first available socket + this[Object.keys(this.sockets)[0]], 'Disconnect-Request', - rinfo, + remote_host, attributes, vendor_attributes, - on_sent + on_disconnect_sent ) } -}) +} diff --git a/src/node_radius_shim.js b/src/node_radius_shim.js index 1c45cdc..97a2bc8 100644 --- a/src/node_radius_shim.js +++ b/src/node_radius_shim.js @@ -1,20 +1,23 @@ -module.exports = function node_radius_shim( +export default function node_radius_shim( attributes, vendor_attributes ) { var shimmed = [] + if (Array.isArray(attributes) && attributes.length) { shimmed = shimmed.concat(attributes) } + Object.keys( vendor_attributes - ).forEach(vendor_name => { + ).forEach(function(vendor_name) { shimmed.push([ 'Vendor-Specific', - this.VENDOR_IDS[vendor_name], + this.vendor_ids[vendor_name], vendor_attributes[vendor_name] ]) - }) + }, this) + return shimmed } diff --git a/src/send.js b/src/send.js index 80a1acd..5834b67 100644 --- a/src/send.js +++ b/src/send.js @@ -1,11 +1,11 @@ -module.exports = function send(buffer, rinfo, on_sent) { +export default function send(buffer, remote_host, on_sent) { this.send( buffer, 0, buffer.length, - rinfo.port, - rinfo.address, + remote_host.port, + remote_host.address, on_sent ) } diff --git a/src/validate.js b/src/validate.js new file mode 100644 index 0000000..17426f1 --- /dev/null +++ b/src/validate.js @@ -0,0 +1,21 @@ + +export function vendor_dictionary(vendor_dictionary) { + return ( + typeof vendor_dictionary.name === 'string' && + vendor_dictionary.name.length && + typeof vendor_dictionary.path === 'string' && + vendor_dictionary.path.length && + !isNaN(vendor_dictionary.id) && + Number.isInteger(vendor_dictionary.id) && + vendor_dictionary.id + ) +} + +export function port(port) { + return ( + !isNaN(port) && + Number.isInteger(port) && + port >= 0 && + port <= 65535 + ) +} diff --git a/test/dictionaries/mikrotik.dictionary b/test/dictionaries/mikrotik.dictionary index 040a26f..cb4caa8 100644 --- a/test/dictionaries/mikrotik.dictionary +++ b/test/dictionaries/mikrotik.dictionary @@ -12,7 +12,7 @@ # other damages that may result from the use of this software, including, but # not limited to, loss of data, time and (or) profits. # -# $Id: dictionary.mikrotik,v 1.7 2011/11/25 08:00:00 normis Exp $ +# $Id: dictionary.mikrotik,v 1.8 2019/12/20 11:02:37 strods Exp $ # # MikroTik Attributes @@ -42,13 +42,14 @@ ATTRIBUTE Mikrotik-Address-List 19 string ATTRIBUTE Mikrotik-Wireless-MPKey 20 string ATTRIBUTE Mikrotik-Wireless-Comment 21 string ATTRIBUTE Mikrotik-Delegated-IPv6-Pool 22 string -ATTRIBUTE Mikrotik_DHCP_Option_Set 23 string -ATTRIBUTE Mikrotik_DHCP_Option_Param_STR1 24 string -ATTRIBUTE Mikortik_DHCP_Option_Param_STR2 25 string -ATTRIBUTE Mikrotik_Wireless_VLANID 26 integer -ATTRIBUTE Mikrotik_Wireless_VLANIDtype 27 integer -ATTRIBUTE Mikrotik_Wireless_Minsignal 28 string -ATTRIBUTE Mikrotik_Wireless_Maxsignal 29 string +ATTRIBUTE Mikrotik-DHCP-Option-Set 23 string +ATTRIBUTE Mikrotik-DHCP-Option-Param-STR1 24 string +ATTRIBUTE Mikrotik-DHCP-Option-Param-STR2 25 string +ATTRIBUTE Mikrotik-Wireless-VLANID 26 integer +ATTRIBUTE Mikrotik-Wireless-VLANIDtype 27 integer +ATTRIBUTE Mikrotik-Wireless-Minsignal 28 string +ATTRIBUTE Mikrotik-Wireless-Maxsignal 29 string +ATTRIBUTE Mikrotik-Switching-Filter 30 string # MikroTik Values @@ -57,7 +58,7 @@ VALUE Mikrotik-Wireless-Enc-Algo 40-bit-WEP VALUE Mikrotik-Wireless-Enc-Algo 104-bit-WEP 2 VALUE Mikrotik-Wireless-Enc-Algo AES-CCM 3 VALUE Mikrotik-Wireless-Enc-Algo TKIP 4 -VALUE Mikrotik_Wireless_VLANIDtype 802.1q 0 -VALUE Mikrotik_Wireless_VLANIDtype 802.1ad 1 +VALUE Mikrotik-Wireless-VLANIDtype 802.1q 0 +VALUE Mikrotik-Wireless-VLANIDtype 802.1ad 1 END-VENDOR Mikrotik diff --git a/test/index.js b/test/index.js index 7ebf1a2..3e640c1 100644 --- a/test/index.js +++ b/test/index.js @@ -1,15 +1,13 @@ -'use strict' - function radclient( address, packet_type, - shared_secret, + secret, packet, on_exec ) { - // TODO add options for flooding - var cmd = `echo "${packet}" | ${process.env.TEST ? '/usr/bin/' : './test/'}radclient -n 1 -x ${address} ${packet_type} ${shared_secret}` + var cmd = `echo "${packet}" | ${process.env.TEST ? '/usr/bin/' : './test/'}radclient -n 1 -x ${address} ${packet_type} ${secret}` + cp.exec(cmd, { timeout: 1000 }, function(err, stdout, stderr) { @@ -17,117 +15,192 @@ function radclient( }) } -var cp = require('child_process') -var {expect} = require('chai') -var tephra = require('../') +import cp from 'child_process' + +import {expect} from 'chai' -// some fixtures -var test_secret = 'shared_secret' +import tephra from '../src/index.js' + +var test_secret = 'foo' var auth_request = 'User-Name=foo,User-Password=bar' var acct_interim = 'Acct-Status-Type=Interim-Update' var acct_start = 'Acct-Status-Type=Start' var acct_stop = 'Acct-Status-Type=Stop' -var coa_disconnect = 'Acct-Session-Id=foo,User-Name=bar,NAS-IP-Address=10.0.0.1' +var port_permutations = [ + [1812, 1813, 1814], + [1812, 1813, false], + [1812, false, 1814], + [1812, false, false], + [false, 1813, 1814], + [false, 1813, false], + [false, false, 1814] +] describe('tephra', function() { - this.timeout(5000) - - describe('lifecycle', function() { + this.timeout(2000) - var server + describe('constructor', function() { - it('#constructor should throw if required arguments are missing', function() { + it('should throw if required arguments are missing', function() { expect(function() { new tephra - }).to.throw(/Missing SHARED_SECRET/) + }).to.throw(/Missing shared secret/) }) - describe('vendor dictionaries', function() { - - it('#constructor should throw if vendor dictionary arguments are invalid', function() { - expect(function() { - new tephra( - test_secret, - 1812, - 1813, - 1814, - [ - {} - ] - ) - }).to.throw( - /\{vendor\:\ String\,\ path\:\ String\,\ id\:\ Number\}/ - ) - }) + it('should throw an error if no ports are specified', function() { + expect(function() { + new tephra({ + secret: test_secret + }) + }).to.throw(/At least one port is required/) + + expect(function() { + new tephra({ + secret: test_secret, + ports: {} + }) + }).to.throw(/At least one port is required/) + }) + + it('should throw an error if invalid ports are specified', function() { + expect(function() { + new tephra({ + secret: test_secret, + ports: { + acct: -1 + } + }) + }).to.throw(/Invalid port specified/) + + expect(function() { + new tephra({ + secret: test_secret, + ports: { + acct: 65536 + } + }) + }).to.throw(/Invalid port specified/) + + expect(function() { + new tephra({ + secret: test_secret, + ports: { + acct: 0.5 + } + }) + }).to.throw(/Invalid port specified/) + }) - it('should hold an internal representation of vendor dictionaries, mapping vendor name to vendor id', function() { - var t = new tephra( - test_secret, - 1812, - 1813, - 1814, - [ - { - vendor: 'telkom', - path: './test/dictionaries/telkom.dictionary', - id: 1431 - }, - { - vendor: 'mikrotik', - path: './test/dictionaries/mikrotik.dictionary', - id: 14988 - } + it('should throw if vendor dictionary arguments are invalid', function() { + expect(function() { + new tephra({ + secret: test_secret, + ports: { + auth: 1812, + acct: 1813, + coa: 1814 + }, + vendor_dictionaries: [ + {} ] - ) - expect(t.VENDOR_IDS.mikrotik).to.equal(14988) - expect(t.VENDOR_IDS.telkom).to.equal(1431) + }) + }).to.throw( + /Vendor dictionary at index 0 is malformed/ + ) + }) + + it('should hold an internal representation of vendor dictionaries, mapping vendor name to vendor id', function() { + var t = new tephra({ + secret: test_secret, + ports: { + auth: 1812, + acct: 1813, + coa: 1814 + }, + vendor_dictionaries: [ + { + name: 'telkom', + path: './test/dictionaries/telkom.dictionary', + id: 1431 + }, + { + name: 'mikrotik', + path: './test/dictionaries/mikrotik.dictionary', + id: 14988 + } + ] }) + + expect(t.vendor_ids.mikrotik).to.equal(14988) + expect(t.vendor_ids.telkom).to.equal(1431) }) - describe('sockets', function() { - server = new tephra( - test_secret, - 1812, - 1813, - 1814 - ) + }) - it('should bind', function(done) { - server.bind(done) + describe('socket permutations (authentication, accounting, change of authorisation)', function() { + + port_permutations.forEach(function(ports, idx) { + it(`permutation ${idx + 1} (${JSON.stringify(ports)}) should bind and unbind successfully using callbacks`, function(done) { + var t = new tephra({ + secret: test_secret, + ports: { + auth: ports[0], + acct: ports[1], + coa: ports[2] + } + }) + + t.bind(function() { + t.unbind(done) + }) }) - it('should unbind', function(done) { - server.unbind(done) + it(`permutation ${idx + 1} (${JSON.stringify(ports)}) should bind and unbind successfully without callbacks`, function(done) { + var t = new tephra({ + secret: test_secret, + ports: { + auth: ports[0], + acct: ports[1], + coa: ports[2] + } + }) + + t.bind() + t.unbind() + done() }) }) }) - describe('auth, acct and coa packet transmission', function() { + describe('authentication, accounting, and change of authorisation packet transmission', function() { - var server + var t beforeEach(function(done) { - delete require.cache[require.resolve('..')] try { - server = new tephra( - test_secret, - 1812, - 1813, - 1814 - ) - server.bind(done) + t = new tephra({ + secret: test_secret, + ports: { + auth: 1812, + acct: 1813, + coa: 1814 + } + }) + + t.bind(done) } catch (e) { - return done(e) + done(e) + return } }) afterEach(function(done) { - server.unbind(done) + t.unbind(done) }) it('should reject irrelevant packet types directed at the auth socket', function(done) { - server.on('error#decode#auth', done.bind(done, null)) + t.on('error#decode#auth', done.bind(done, null)) // send an ACCOUNTING packet to the AUTHENTICATION socket radclient( @@ -136,13 +209,16 @@ describe('tephra', function() { test_secret, acct_start, function(err) { - if (err && !err.killed) return done(err) + if (err && !err.killed) { + done(err) + return + } } ) }) it('should emit Access-Request object on receiving packet', function(done) { - server.on('Access-Request', done.bind(done, null)) + t.on('Access-Request', done.bind(done, null)) radclient( 'localhost:1812', @@ -150,13 +226,16 @@ describe('tephra', function() { test_secret, auth_request, function(err) { - if (err && !err.killed) return done(err) + if (err && !err.killed) { + done(err) + return + } } ) }) it('should reject irrelevant packet types directed at the acct socket', function(done) { - server.on('error#decode#acct', done.bind(done, null)) + t.on('error#decode#acct', done.bind(done, null)) // send an AUTHENTICATION packet to the ACCOUNTING socket radclient( @@ -165,14 +244,35 @@ describe('tephra', function() { test_secret, auth_request, function(err) { - if (err && !err.killed) return done(err) + if (err && !err.killed) { + done(err) + return + } + } + ) + }) + + it('should emit a packet decode error if the request parameters are incorrect', function(done) { + t.on('error#decode#acct', done.bind(done, null)) + + radclient( + 'localhost:1813', + 'acct', + // using the wrong secret intentionally to test the packet decode error handling + test_secret + test_secret, + auth_request, + function(err) { + if (err && !err.killed) { + done(err) + return + } } ) }) it('should send a response for accounting packets', function(done) { - server.on('Accounting-Request', function(request, rinfo) { - server.respond('acct', request, 'Accounting-Response', rinfo, [], {}, done) + t.on('Accounting-Request', function(request, remote_host) { + t.respond('accounting', request, 'Accounting-Response', remote_host, [], {}, done) }) radclient( @@ -181,13 +281,16 @@ describe('tephra', function() { test_secret, acct_interim, function(err) { - if (err && !err.killed) return done(err) + if (err && !err.killed) { + done(err) + return + } } ) }) it('should send a response for accounting packets using the event handler responder function', function(done) { - server.on('Accounting-Request', function(request, rinfo, respond) { + t.on('Accounting-Request', function(request, remote_host, respond) { respond([], {}, done) }) @@ -197,22 +300,47 @@ describe('tephra', function() { test_secret, acct_interim, function(err) { - if (err && !err.killed) return done(err) + if (err && !err.killed) { + done(err) + return + } } ) }) it('should emit the accounting request status type when receiving an accounting request', function(done) { var emissions = 0 - var expected = 2 + var expected = 6 function emission_counter() { emissions += 1 - if (emissions === expected) return done() + if (emissions === expected) { + done() + return + } } - server.on('Accounting-Request', emission_counter) - server.on('Accounting-Request-Interim-Update', emission_counter) + + // expected to be emitted three times (once per accounting status type) + t.on('Accounting-Request', emission_counter) + + // each expected to be emitted just once + t.on('Accounting-Request-Start', emission_counter) + t.on('Accounting-Request-Interim-Update', emission_counter) + t.on('Accounting-Request-Stop', emission_counter) + + radclient( + 'localhost:1813', + 'acct', + test_secret, + acct_start, + function(err) { + if (err && !err.killed) { + done(err) + return + } + } + ) radclient( 'localhost:1813', @@ -220,14 +348,30 @@ describe('tephra', function() { test_secret, acct_interim, function(err) { - if (err && !err.killed) return done(err) + if (err && !err.killed) { + done(err) + return + } + } + ) + + radclient( + 'localhost:1813', + 'acct', + test_secret, + acct_stop, + function(err) { + if (err && !err.killed) { + done(err) + return + } } ) }) - it('should send a response for access-request packets', function(done) { - server.on('Access-Request', function(request, rinfo, accept, reject) { - server.respond('auth', request, 'Access-Accept', rinfo, [], {}, done) + it('should send an access-accept for access-request packets', function(done) { + t.on('Access-Request', function(request, remote_host, accept, reject) { + t.respond('authentication', request, 'Access-Accept', remote_host, [], {}, done) }) radclient( @@ -236,28 +380,73 @@ describe('tephra', function() { test_secret, auth_request, function(err) { - if (err && !err.killed) return done(err) + if (err && !err.killed) { + done(err) + return + } } ) }) - it('should send a response for access-request packets using the event handler responder function', function(done) { - server.on('Access-Request', function(request, rinfo, accept, reject) { + it('should send an access-reject for access-request packets', function(done) { + t.on('Access-Request', function(request, remote_host, accept, reject) { + t.respond('authentication', request, 'Access-Reject', remote_host, [], {}, done) + }) + + radclient( + 'localhost:1812', + 'auth', + test_secret, + auth_request, + function(err) { + if (err && !err.killed) { + done(err) + return + } + } + ) + }) + + it('should send an access-accept for access-request packets using the event handler responder function', function(done) { + t.on('Access-Request', function(request, remote_host, accept, reject) { accept([], {}, done) }) + + radclient( + 'localhost:1812', + 'auth', + test_secret, + auth_request, + function(err) { + if (err && !err.killed) { + done(err) + return + } + } + ) + }) + + it('should send an access-reject for access-request packets using the event handler responder function', function(done) { + t.on('Access-Request', function(request, remote_host, accept, reject) { + reject([], {}, done) + }) + radclient( 'localhost:1812', 'auth', test_secret, auth_request, function(err) { - if (err && !err.killed) return done(err) + if (err && !err.killed) { + done(err) + return + } } ) }) it('should reject irrelevant packet types directed at the coa socket', function(done) { - server.on('error#decode#coa', done.bind(done, null)) + t.on('error#decode#coa', done.bind(done, null)) radclient( 'localhost:1814', @@ -265,66 +454,94 @@ describe('tephra', function() { test_secret, auth_request, function(err) { - if (err && !err.killed) return done(err) + if (err && !err.killed) { + done(err) + return + } } ) }) - it('#disconnect should throw if not given rinfo', function() { + it('should emit coa ack or nack and disconnect ack or nack when receiving a change of authorisation response', function(done) { + var emissions = 0 + var expected = 4 + + function emission_counter() { + emissions += 1 + if (emissions === expected) { + done() + return + } + } + + t.on('CoA-NAK', emission_counter) + t.on('CoA-ACK', emission_counter) + t.on('Disconnect-NAK', emission_counter) + t.on('Disconnect-ACK', emission_counter) + + t.send('authentication', 'CoA-ACK', {port: 1814, address: 'localhost'}, [], {}, function() {}) + t.send('authentication', 'CoA-NAK', {port: 1814, address: 'localhost'}, [], {}, function() {}) + t.send('authentication', 'Disconnect-ACK', {port: 1814, address: 'localhost'}, [], {}, function() {}) + t.send('authentication', 'Disconnect-NAK', {port: 1814, address: 'localhost'}, [], {}, function() {}) + }) + + it('should throw when disconnect is not given remote host', function() { expect( - server.disconnect.bind(server, null, [], {}) + t.disconnect.bind(t, null, [], {}) ).to.throw() }) - it('#disconnect should not throw if given all required arguments', function() { + it('should not throw when disconnect is given all required arguments', function() { expect( - server.disconnect.bind(server, {address: '0.0.0.0', port: 12345}, [], {}) + t.disconnect.bind(t, {address: '0.0.0.0', port: 12345}, [], {}) ).to.not.throw }) - it('#send should throw if supplied non-string type', function() { - expect(server.send).to.throw(/string argument type/) + it('should throw when send is not supplied with a socket type', function() { + expect(t.send.bind(t)).to.throw(/Invalid socket given/) }) - it('#send should yield an error if supplied non-array type', function(done) { - server.send( - 'acct', + it('should yield an error when send is supplied with invalid attribute argument types', function(done) { + t.send( + 'accounting', 0, {address: '0.0.0.0', port: 12345}, null, null, function(err) { - return done( + done( err ? null : new Error( 'assertion failed: expected `err` to be truthy' ) ) + return } ) }) - it('#respond should throw if supplied non-string type', function() { - expect(server.respond).to.throw(/string argument type/) + it('should throw when respond is not supplied with a socket type', function() { + expect(t.respond.bind(t)).to.throw(/Invalid socket given/) }) - it('#respond should yield an error if no packet is given', function(done) { - server.respond( - 'acct', + it('should yield an error when respond is not given a packet type', function(done) { + t.respond( + 'accounting', null, 0, {address: '0.0.0.0', port: 12345}, null, null, function(err) { - return done( + done( err ? null : new Error( 'assertion failed: expected `err` to be truthy' ) ) + return } ) })