diff --git a/labs/bbb-callback/.gitignore b/labs/bbb-callback/.gitignore index 17dd22c42f843999d33060cb38714953eac67352..da92316c6314cdf3b43be248b7a1e9e4c4ad46eb 100644 --- a/labs/bbb-callback/.gitignore +++ b/labs/bbb-callback/.gitignore @@ -2,3 +2,4 @@ **/#*# *.log node_modules/ +config_local.coffee diff --git a/labs/bbb-callback/README b/labs/bbb-callback/README deleted file mode 100644 index 5ba9e31f6379be2280de20d5af7206389533918b..0000000000000000000000000000000000000000 --- a/labs/bbb-callback/README +++ /dev/null @@ -1,34 +0,0 @@ -bbb-callbacks -------------- - - -It's a webapp which allows to perform certain callbacks when an event happens in a bigbluebutton session. - -The webapp uses node.js and redis. - -For run: node app.js - -To run in production, put bbb-callback.sh into /etc/init.d/ -See: https://www.exratione.com/2013/02/nodejs-and-forever-as-a-service-simple-upstart-and-init-scripts-for-ubuntu/ - -1. Install node by downloading source from http://nodejs.org/download/. Extract the downloaded file. - ./configure - make - sudo make install - -2. Install forever - sudo npm -g install forever - -3. Copy the init.d script - sudo cp bbb-callback.sh /etc/init.d/bbb-callback.sh - sudo chmod a+x /etc/init.d/bbb-callback.sh - sudo update-rc.d bbb-callback.sh defaults - -4. Copy bbb-callback dir to /usr/local/bigbluebutton/bbb-callback - -5. How to start/stop the service - sudo service bbb-callback.sh start - sudo service bbb-callback.sh status - sudo service bbb-callback.sh restart - sudo service bbb-callback.sh stop - diff --git a/labs/bbb-callback/README.md b/labs/bbb-callback/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5cc6c9c2a07c7b5cc31d8db82317f9cbb15b2e97 --- /dev/null +++ b/labs/bbb-callback/README.md @@ -0,0 +1,275 @@ +bbb-webhooks +============ + +A node.js application that listens for all events on BigBlueButton and POSTs these events to +hooks registered via an API. A hook is basically a URL that will receive HTTP POST calls with +information about an event when this event happens on BigBlueButton. + +An event can be: a meeting was created, a user joined, a new presentation was uploaded, +a user left, a recording is being processed, and many others. + +Registering hooks: API calls +---------------------------- + +This application adds three new API calls to BigBlueButton's API. + +### Hooks/Create + +Creates a new hook. This call is idempotent: you can call it multiple times with the same parameters +without side effects (just like the `/create` call for meetings). +Can optionally receive a `meetingID` parameter: if informed, this hook +will receive only events for this meeting; otherwise the hook will be global and will receive +events for all meetings in the server. + +**Resource URL:** `http://yourserver.com/bigbluebutton/api/hooks/create?[parameters]&checksum=[checksum]` + +**Parameters**: + +| Param Name | Required / Optional | Type | Description | +| ------------ | -------------------- | ----- | ----------- | +| calllbackURL | Required | String | The URL that will receive a POST call with the events. The same URL cannot be registered more than once. | +| meetingID | Optional | String | A meeting ID to bind this hook to an specific meeting. If not informed, the hook will receive events for all meetings. | + +**Response when a hook is successfully registered**: + +```xml +<response> + <returncode>SUCCESS</returncode> + <hookID>1</hookID> +</response> +``` + +**Response when a hook is already registered**: + +```xml +<response> + <returncode>SUCCESS</returncode> + <hookID>1</hookID> + <messageKey>duplicateWarning</messageKey> + <message>There is already a hook for this callback URL.</message> +</response> +``` + +**Response when there was an error registering the hook**: + +```xml +<response> + <returncode>FAILED</returncode> + <messageKey>createHookError</messageKey> + <message>An error happened while creating your hook. Check the logs.</message> +</response> +``` + + +### Hooks/Destroy + +Remove a previously created hook. A `hookID` must be passed in the parameters to identify +the hook to be removed. + +**Resource URL:** `http://yourserver.com/bigbluebutton/api/hooks/destroy?[parameters]&checksum=[checksum]` + +**Parameters**: + +| Param Name | Required / Optional | Type | Description | +| ----------- | -------------------- | ----- | ----------- | +| hookID | Required | Number | The ID of the hook that should be removed, as returned in the create hook call. | + +**Response when a hook is successfully removed**: + +```xml +<response> + <returncode>SUCCESS</returncode> + <removed>true</removed> +</response> +``` + +**Response when a hook is not found**: + +```xml +<response> + <returncode>FAILED</returncode> + <messageKey>destroyMissingHook</messageKey> + <message>The hook informed was not found.</message> +</response> +``` + +**Response when a hook is not passed in the parameters**: + +```xml +<response> + <returncode>FAILED</returncode> + <messageKey>missingParamHookID</messageKey> + <message>You must specify a hookID in the parameters.</message> +</response> +``` + +**Response when there was an error removing the hook**: + +```xml +<response> + <returncode>FAILED</returncode> + <messageKey>destroyHookError</messageKey> + <message>An error happened while removing your hook. Check the logs.</message> +</response> +``` + + +### Hooks/List + +Returns the hooks registered. If a meeting ID is informed, will return the hooks created +specifically for this meeting plus the global hooks that receive events for this meeting. +If no meeting ID is informed, returns all the hooks available (not only the global hooks!). + +**Resource URL:** `http://yourserver.com/bigbluebutton/api/hooks/list?[parameters]&checksum=[checksum]` + +**Parameters**: + +| Param Name | Required / Optional | Type | Description | +| ----------- | -------------------- | ----- | ----------- | +| meetingID | Optional | String | A meeting ID to restrict the hooks returned only to the hooks that receive events for this meeting. Will include hooks that receive events for this meeting only plus all global hooks. | + +**Response when there are hooks registered**: + +```xml +<response> + <returncode>SUCCESS</returncode> + <hooks> + <hook> + <hookID>1</hookID> + <callbackURL>http://postcatcher.in/catchers/abcdefghijk</callbackURL> + <meetingID>my-meeting</meetingID> <!-- a hook created for this meeting only --> + </hook> + <hook> + <hookID>2</hookID> + <callbackURL>http://postcatcher.in/catchers/1234567890</callbackURL> + <!-- no meetingID means this is a global hook --> + </hook> + </hooks> +</response> +``` + +**Response when there are no hooks registered**: + +```xml +<response> + <returncode>FAILED</returncode> + <hooks></hooks> +</response> +``` + + +Callback format +--------------- + +All hooks registered are called via HTTP POST with all the information about the event in +the body of this request. The request is sent with the `Content-type` HTTP header set to +`application/x-www-form-urlencoded` and the content in the body is a json object in the +following format: + +``` +event={"header":{},"payload":{}} +timestamp=1415900488797 +``` + +The attribute `timestamp` is the timestamp of when this callback was made. If the app tries +to make a callback and it fails, it will try again several times more, always using the same +timestamp. Timestamps will never be the same for different events and the value will always +increase. + +The attribute `event` is a stringified version of all the data from the event as received +from redis. The data varies for different types of events, check the documentation for +more information. + +This is an example of the data sent for a meeting destroyed event: + +``` +event={"payload":{"meeting_id":"82fe1e7040551a6044cf375d12d765b5f0f099a4-1415905067841"},"header":{"timestamp":17779896,"name":"meeting_destroyed_event","current_time":1415905177220,"version":"0.0.1"}} +timestamp=1415900488797 +``` + +Moreover, the callback call is signed with a checksum, that is included in the URL of the +request. If the registered URL is `http://my-server.com/callback`, it will receive the +checksum as in `http://my-server.com/callback?checksum=yalsdk18129e019iklasd90i`. + +The way the checksum is created is similar to how the checksums are calculated for +BigBlueButton's API calls (take a look at the `setConfigXML` call). + +``` +sha1(<callback URL>+<data body>+<shared secret>) +``` + +Where: + +* `<callback URL>`: The original callback URL, that doesn't include the checksum. +* `<data body as a string>`: All the data sent in the body of the request, concatenated and joined by `&`, as if they were parameters in a URL. +* `<shared secret>`: The shared secret of the BigBlueButton server. + +So, upon receiving a callback call, an application could validate the checksum as follows: + +* Get the body of the request and convert the string as in the example below: + ``` +event={"header":{},"payload":{}} +timestamp=1234567890 + ``` + + Should become the string below: + + ``` +'event={"header":{},"payload":{}}×tamp=1234567890' + ``` +* Concatenate the original callback URL, the string from the previous step, and the BigBlueButton's salt. +* Calculate a `sha1()` of this string. +* The checksum calculated should equal the checksum received in the parameters of the request. + + +More details +------------ +* Callbacks are always triggered for one event at a time and in order. They are ordered the same way they appear on pubsub (which might not exactly be the order indicated by their timestamps). The timestamps will almost always be ordered as well, but it's not guaranteed. +* The application assumes that events are never duplicated on pubsub. If they happen to be duplicated, the callback calls will also be duplicated. +* Hooks are only removed if a call to `/destroy` is made or if the callbacks for the hook fail too many times (~12) for a long period of time (~5min). They are never removed otherwise. Valid for both global hooks and hooks for specific meetings. +* If a meeting is created while the webhooks app is down, callbacks will never be triggered for this meeting. The app needs to detect the create event (`meeting_created_message`) to have a mapping of internal to external meeting IDs. +* Mappings are removed after 24 hours of inactivity. If there are no events at all for a given meeting, it will be assumed dead. This is done to prevent data from being stored forever on redis. +* External URLs are expected to respond with an HTTP status 200. Anything different from it will be considered an error and the callback will be called again. This includes URLs that redirect to some other place. + +Development +----------- + +1. Install node. You can use [NVM](https://github.com/creationix/nvm) if you need multiple versions of node or install it from source. To install from source, first check the exact version you need on `package.json` and replace the all `vX.X.X` by the correct version when running the commands below. + + ```bash +$ wget http://nodejs.org/dist/vX.X.X/node-vX.X.X.tar.gz +$ tar -xvf node-vX.X.X.tar.gz +$ cd node-vX.X.X/ +$ ./configure +$ make +$ sudo make install + ``` + +2. Copy and edit the configuration file: `cp config_local.coffee.example config_local.coffee` + +3. Run the application with: + + ```bash +node app.js + ``` + +Production +---------- + +1. Install node. First check the exact version you need on `package.json` and replace the all `vX.X.X` by the correct version in the commands below. + + ```bash +$ wget http://nodejs.org/dist/vX.X.X/node-vX.X.X.tar.gz +$ tar -xvf node-vX.X.X.tar.gz +$ cd node-vX.X.X/ +$ ./configure +$ make +$ sudo make install + ``` + +2. Copy the entire webhooks directory to `/usr/local/bigbluebutton/bbb-webhooks` + +3. Copy and edit the configuration file to adapt to your server: `cp config_local.coffee.example config_local.coffee` + +4. Drop the nginx configuration file in its place: `cp config/webhooks.nginx /etc/bigbluebutton/nginx/`. + If you changed the port of the web server on your configuration file, you will have to edit it in `webhooks.nginx` as well. diff --git a/labs/bbb-callback/callback_emitter.coffee b/labs/bbb-callback/callback_emitter.coffee index 508b33f7192426a8a0405a4ef97919f9c60ae29e..2d039bd05bc063ccca3e8a638bbed191d0915e30 100644 --- a/labs/bbb-callback/callback_emitter.coffee +++ b/labs/bbb-callback/callback_emitter.coffee @@ -1,5 +1,7 @@ -EventEmitter = require('events').EventEmitter +_ = require('lodash') request = require("request") +url = require('url') +EventEmitter = require('events').EventEmitter config = require("./config") Utils = require("./utils") @@ -16,7 +18,7 @@ module.exports = class CallbackEmitter extends EventEmitter @timestap = 0 start: -> - @timestap = new Date().getTime() + @timestamp = new Date().getTime() @nextInterval = 0 @_scheduleNext 0 @@ -43,25 +45,32 @@ module.exports = class CallbackEmitter extends EventEmitter , timeout) _emitMessage: (callback) -> - # basic data structure + # data to be sent + # note: keep keys in alphabetical order data = + event: JSON.stringify(@message) timestamp: @timestamp - event: @message - # add a checksum to the post data + # calculate the checksum checksum = Utils.checksum("#{@callbackURL}#{JSON.stringify(data)}#{config.bbb.sharedSecret}") - data.checksum = checksum + + # get the final callback URL, including the checksum + urlObj = url.parse(@callbackURL, true) + callbackURL = @callbackURL + callbackURL += if _.isEmpty(urlObj.search) then "?" else "&" + callbackURL += "checksum=#{checksum}" requestOptions = - uri: @callbackURL + uri: callbackURL method: "POST" - json: data + form: data request requestOptions, (error, response, body) -> - if error? + # TODO: treat redirects + if error? or response.statusCode != 200 console.log "xx> Error in the callback call to: [#{requestOptions.uri}] for #{simplifiedEvent(data.event)}" console.log "xx> Error:", error - # console.log "xx> Response:", response + console.log "xx> Status:", response.statusCode callback error, false else console.log "==> Successful callback call to: [#{requestOptions.uri}] for #{simplifiedEvent(data.event)}" @@ -69,4 +78,8 @@ module.exports = class CallbackEmitter extends EventEmitter # A simple string that identifies the event simplifiedEvent = (event) -> - "event: { name: #{event.header?.name}, timestamp: #{event.header?.timestamp} }" + try + eventJs = JSON.parse(event) + "event: { name: #{eventJs.header?.name}, timestamp: #{eventJs.header?.timestamp} }" + catch e + "event: #{event}" diff --git a/labs/bbb-callback/config.coffee b/labs/bbb-callback/config.coffee index 20c72422f7ee3442b9f6a85c3fc606de4003b2a4..40e0c455d27c26f227be16d2c09f5c7a961232e1 100644 --- a/labs/bbb-callback/config.coffee +++ b/labs/bbb-callback/config.coffee @@ -1,32 +1,28 @@ -# Global configurations file +# Global configuration file # load the local configs -# config = require("./config_local") -config = {} +config = require("./config_local") # BigBlueButton configs -config.bbb = {} -# TODO: move secret to a config_local file -config.bbb.sharedSecret = "33e06642a13942004fd83b3ba6e4104a" -config.bbb.apiPath = "/bigbluebutton/api" +config.bbb or= {} +config.bbb.sharedSecret or= "33e06642a13942004fd83b3ba6e4104a" +config.bbb.apiPath or= "/bigbluebutton/api" # Web server configs -config.server = {} -config.server.port = 3005 +config.server or= {} +config.server.port or= 3005 # Web hooks configs -config.hooks = {} -config.hooks.pchannel = "bigbluebutton:*" -config.hooks.meetingsChannel = "bigbluebutton:from-bbb-apps:meeting" +config.hooks or= {} +config.hooks.pchannel or= "bigbluebutton:*" +config.hooks.meetingsChannel or= "bigbluebutton:from-bbb-apps:meeting" # Filters to the events we want to generate callback calls for -config.hooks.events = [ +config.hooks.events or= [ { channel: "bigbluebutton:from-bbb-apps:meeting", name: "meeting_created_message" }, { channel: "bigbluebutton:from-bbb-apps:meeting", name: "meeting_destroyed_event" }, { channel: "bigbluebutton:from-bbb-apps:users", name: "user_joined_message" }, - { channel: "bigbluebutton:from-bbb-apps:users", name: "presenter_assigned_message" }, { channel: "bigbluebutton:from-bbb-apps:users", name: "user_left_message" } - # { channel: "bigbluebutton:from-bbb-apps:meeting", name: "user_registered_message" }, ] # Retry intervals for failed attempts for perform callback calls. @@ -59,14 +55,14 @@ config.api.responses.failure = (key, msg) -> config.api.responses.checksumError = config.api.responses.failure("checksumError", "You did not pass the checksum security check.") -config.api.responses.hookSuccess = (id) -> +config.api.responses.createSuccess = (id) -> "<response> \ <returncode>SUCCESS</returncode> \ <hookID>#{id}</hookID> \ </response>" -config.api.responses.hookFailure = +config.api.responses.createFailure = config.api.responses.failure("createHookError", "An error happened while creating your hook. Check the logs.") -config.api.responses.hookDuplicated = (id) -> +config.api.responses.createDuplicated = (id) -> "<response> \ <returncode>SUCCESS</returncode> \ <hookID>#{id}</hookID> \ diff --git a/labs/bbb-callback/config/webhooks.nginx b/labs/bbb-callback/config/webhooks.nginx index 1f274ef11833a0bc32af1ac93817abf56fa494c7..22ec6e4e97700ff298058a3706294c00237c5b53 100644 --- a/labs/bbb-callback/config/webhooks.nginx +++ b/labs/bbb-callback/config/webhooks.nginx @@ -6,5 +6,4 @@ location /bigbluebutton/api/hooks { proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $http_host; proxy_set_header X-NginX-Proxy true; - # proxy_pass http://mconf-lb$request_uri; } diff --git a/labs/bbb-callback/config_local.coffee.example b/labs/bbb-callback/config_local.coffee.example new file mode 100644 index 0000000000000000000000000000000000000000..ec30a418bb5295792002ee55309d8f6f8d1a7f38 --- /dev/null +++ b/labs/bbb-callback/config_local.coffee.example @@ -0,0 +1,22 @@ +# Local configuration file + +config = {} + +# Shared secret of your BigBlueButton server. +config.bbb = {} +config.bbb.sharedSecret = "33e06642a13942004fd83b3ba6e4104a" + +# The port in which the API server will run. +config.server = {} +config.server.port = 3005 + +# Callbacks will be triggered for all the events in this list and only for these events. +config.hooks = {} +config.hooks.events or= [ + { channel: "bigbluebutton:from-bbb-apps:meeting", name: "meeting_created_message" }, + { channel: "bigbluebutton:from-bbb-apps:meeting", name: "meeting_destroyed_event" }, + { channel: "bigbluebutton:from-bbb-apps:users", name: "user_joined_message" }, + { channel: "bigbluebutton:from-bbb-apps:users", name: "user_left_message" } +] + +module.exports = config diff --git a/labs/bbb-callback/web_server.coffee b/labs/bbb-callback/web_server.coffee index b83fede2fccb028b904dbc8a9283efcabb4b13b0..d1a0a9f8bfa0aa1847e3d740d33fab524b7257d6 100644 --- a/labs/bbb-callback/web_server.coffee +++ b/labs/bbb-callback/web_server.coffee @@ -44,11 +44,11 @@ module.exports = class WebServer else Hook.addSubscription callbackURL, meetingID, (error, hook) -> if error? # the only error for now is for duplicated callbackURL - msg = config.api.responses.hookDuplicated(hook.id) + msg = config.api.responses.createDuplicated(hook.id) else if hook? - msg = config.api.responses.hookSuccess(hook.id) + msg = config.api.responses.createSuccess(hook.id) else - msg = config.api.responses.hookFailure + msg = config.api.responses.createFailure respondWithXML(res, msg) _destroy: (req, res, next) ->