diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..9396a19e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM node:14.16.1-alpine + +WORKDIR /app + +RUN apk add --update --no-cache curl + +COPY . /app + +RUN npm ci && npm run prepublish + +RUN ln -s /app/bin/server.js /usr/bin/laravel-echo-server + +COPY bin/docker-entrypoint bin/health-check /usr/local/bin/ + +ENTRYPOINT ["docker-entrypoint"] + +VOLUME /app + +EXPOSE 6001 + +HEALTHCHECK --interval=30s --timeout=5s \ + CMD /usr/local/bin/health-check + +CMD ["start"] diff --git a/README.md b/README.md index 757a0b51..ca42bbb4 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Edit the default configuration of the server by adding options to your **laravel | `database` | `redis` | Database used to store data that should persist, like presence channel members. Options are currently `redis` and `sqlite` | | `databaseConfig` | `{}` | Configurations for the different database drivers [Example](#database) | | `devMode` | `false` | Adds additional logging for development purposes | +| `hookEndpoint` | `null` | The route that receives to the client-side event [Example](#hook-client-side-event) | | `host` | `null` | The host of the socket.io server ex.`app.dev`. `null` will accept connections on any IP-address | | `port` | `6001` | The port that the socket.io server should run on | | `protocol` | `http` | Must be either `http` or `https` | @@ -110,6 +111,7 @@ file, the following options can be overridden: - `sslCertPath`: `LARAVEL_ECHO_SERVER_SSL_CERT` - `sslPassphrase`: `LARAVEL_ECHO_SERVER_SSL_PASS` - `sslCertChainPath`: `LARAVEL_ECHO_SERVER_SSL_CHAIN` +- `rejectUnautorized`: `NODE_TLS_REJECT_UNAUTHORIZED` ### Running with SSL @@ -371,3 +373,119 @@ _Note: When using the socket.io client library from your running server, remembe #### µWebSockets deprecation µWebSockets has been [officially deprecated](https://www.npmjs.com/package/uws). Currently there is no support for µWebSockets in Socket.IO, but it may have the new [ClusterWS](https://www.npmjs.com/package/@clusterws/cws) support incoming. Meanwhile Laravel Echo Server will use [`ws` engine](https://www.npmjs.com/package/ws) by default until there is another option. + +## Hook client side event +There are 3 types of client-side event can be listen to. Here is the event names: +- join +- leave +- client_event + +### Hooks configuration +First, you need to configurate your `hookEndpoint`. Here is an example: + +```ini +"hookHost": "/api/hook", +``` + +You don't need to configure hook host. hook host value is getting from `authHost` + +`laravel-echo-server` will send a post request to hook endpoint when there is a client-side event coming. +You can get event information from `cookie` and `form`. + +#### Get data from cookie +`laravel-echo-server` directly use `cookie` from page. So you can add some cookie values like `user_id` to identify user. + +#### Get data from post form +There is always an attribute in post form called `channel`. You can get event payload of [Client Event](https://laravel.com/docs/5.7/broadcasting#client-events) of there is an client event, such as `whisper`. + +**Post form format** + +| Attribute | Description | Example | Default | +| :-------------------| :---------------------- | :-------------------| :---------------------| +| `event` | The event name. Options: `join`, `leave`, `client_event` | `join` | | +| `channel` | The channel name | `meeting` | | +| `payload` | Payload of client event. `joinChannel` or `leaveChannel` hook doesn't have payload | `{from: 'Alex', to: 'Bill'}` | `null` | + +### join channel hook +When users join in a channel `event` should be `join`. + +The request form example: +```ini +event = join +channel = helloworld +``` + +Route configuration example: +```php +Route::post('/hook', function(Request $request) { + if ($request->input('event') === 'join') { + $channel = $request->input('channel'); + $x_csrf_token = $request->header('X-CSRF-TOKEN'); + $cookie = $request->header('Cookie'); + // ... + } +}); +``` + +### leave channel hook +When users leave a channel `event` should be `leave`. + +> Notes that there is no X-CSRF-TOKEN in header when sending a post request for leave channel event, so you'd better not to use the route in `/routes/web.php`. + +The request form example: +```ini +event = leave +channel = helloworld +``` + +Route configuration example: +```php +use Illuminate\Http\Request; + +Route::post('/hook', function(Request $request) { + if ($request->input('event') === 'leave') { + $channel = $request->input('channel'); + $cookie = $request->header('Cookie'); + // ... + } +}); +``` + +### client event hook +When users use `whisper` to broadcast an event in a channel `event` should be `client_event`. + +> Notes that there is no X-CSRF-TOKEN in header when sending a post request for client-event event, so you'd better not to use the route in `/routes/web.php`. + +It will fire the client-event after using `whisper` to broadcast an event like this: +```javascript +Echo.private('chat') + .whisper('whisperEvent', { + from: this.username, + to: this.whisperTo + }); +``` + +The request form example +```ini +event = client_event +channel = helloworld +payload = {from:'Alex', to:'Bill'} +``` + +Route configuration example +```php +use Illuminate\Http\Request; + +Route::post('/hoot', function(Request $request) { + if ($request->input('event') === 'client_event') { + $channel = $request->input('channel'); + $user_id = $request->header('Cookie'); + $payload = $request->input('payload'); + $from = $payload['from']; + $to = $payload['to']; + // ... + } +}); +``` + +> Notes that even though we use an `Object` as payload of client event, the payload will be transformed to an `Array` in PHP. So remember to get your attribute from payload by using an `Array` method like `$payload['xxxx']` \ No newline at end of file diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 00000000..87312d1d --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,40 @@ +#!/bin/sh +set -e + +/usr/sbin/update-ca-certificates + +# laravel-echo-server init +if [[ "$1" == 'init' ]]; then + set -- laravel-echo-server "$@" +fi + +# laravel-echo-server +if [[ "$1" == 'start' ]] || [[ "$1" == 'client:add' ]] || [[ "$1" == 'client:remove' ]]; then + if [[ "${GENERATE_CONFIG:-true}" == "false" ]]; then + # wait for another process to inject the config + echo -n "Waiting for /app/laravel-echo-server.json" + while [[ ! -f /app/laravel-echo-server.json ]]; do + sleep 2 + echo -n "." + done + elif [[ ! -f /app/laravel-echo-server.json ]]; then + cp /usr/local/src/laravel-echo-server.json /app/laravel-echo-server.json + # Replace with environment variables + sed -i "s|LARAVEL_ECHO_SERVER_DB|${LARAVEL_ECHO_SERVER_DB:-redis}|i" /app/laravel-echo-server.json + sed -i "s|REDIS_HOST|${REDIS_HOST:-redis}|i" /app/laravel-echo-server.json + sed -i "s|REDIS_PORT|${REDIS_PORT:-6379}|i" /app/laravel-echo-server.json + sed -i "s|REDIS_PASSWORD|${REDIS_PASSWORD}|i" /app/laravel-echo-server.json + sed -i "s|REDIS_PREFIX|${REDIS_PREFIX:-laravel_database_}|i" /app/laravel-echo-server.json + sed -i "s|REDIS_DB|${REDIS_DB:-0}|i" /app/laravel-echo-server.json + # Remove password config if it is empty + sed -i "s|\"password\": \"\",||i" /app/laravel-echo-server.json + fi + set -- laravel-echo-server "$@" +fi + +# first arg is `-f` or `--some-option` +if [[ "${1#-}" != "$1" ]]; then + set -- laravel-echo-server "$@" +fi + +exec "$@" diff --git a/bin/health-check b/bin/health-check new file mode 100755 index 00000000..a8377d14 --- /dev/null +++ b/bin/health-check @@ -0,0 +1,39 @@ +#!/bin/sh +set -x + +_init () { + scheme="http://" + address="$(netstat -nplt 2>/dev/null | awk ' /(.*\/laravel-echo-serv)/ { gsub(":::","127.0.0.1:",$4); print $4}')" + resource="/socket.io/socket.io.js" + start=$(stat -c "%Y" /proc/1) +} + +fn_health_check () { + # In distributed environment like Swarm, traffic is routed + # to a container only when it reports a `healthy` status. So, we exit + # with 0 to ensure healthy status till distributed service starts (120s). + # + # Refer: https://github.com/moby/moby/pull/28938#issuecomment-301753272 + if [[ $(( $(date +%s) - start )) -lt 120 ]]; then + exit 0 + else + # Get the http response code + http_response=$(curl -s -k -o /dev/null -w "%{http_code}" ${scheme}${address}${resource}) + + # Get the http response body + http_response_body=$(curl -k -s ${scheme}${address}${resource}) + + # server returns response 403 and body "SSL required" if non-TLS + # connection is attempted on a TLS-configured server. Change + # the scheme and try again + if [[ "$http_response" = "403" ]] && [[ "$http_response_body" = "SSL required" ]]; then + scheme="https://" + http_response=$(curl -s -k -o /dev/null -w "%{http_code}" ${scheme}${address}${resource}) + fi + + # If http_response is 200 - server is up. + [[ "$http_response" = "200" ]] + fi +} + +_init && fn_health_check diff --git a/src/channels/channel.ts b/src/channels/channel.ts index bc1dd6b3..cd00d5ea 100644 --- a/src/channels/channel.ts +++ b/src/channels/channel.ts @@ -1,3 +1,4 @@ +let request = require('request'); import { PresenceChannel } from './presence-channel'; import { PrivateChannel } from './private-channel'; import { Log } from './../log'; @@ -13,6 +14,14 @@ export class Channel { */ protected _clientEvents: string[] = ['client-*']; + /** + * Request client. + * + * @type {any} + */ + private request: any; + + /** * Private channel instance. */ @@ -29,6 +38,7 @@ export class Channel { constructor(private io, private options) { this.private = new PrivateChannel(options); this.presence = new PresenceChannel(io, options); + this.request = request; if (this.options.devMode) { Log.success('Channels are ready.'); @@ -44,7 +54,7 @@ export class Channel { this.joinPrivate(socket, data); } else { socket.join(data.channel); - this.onJoin(socket, data.channel); + this.onJoin(socket, data.channel, data.auth); } } } @@ -66,6 +76,7 @@ export class Channel { this.io.sockets.connected[socket.id] .broadcast.to(data.channel) .emit(data.event, data.channel, data.data); + this.hook(socket, data.channel, data.auth, "onClientEvent"); } } } @@ -73,7 +84,7 @@ export class Channel { /** * Leave a channel. */ - leave(socket: any, channel: string, reason: string): void { + leave(socket: any, channel: string, reason: string, auth: any): void { if (channel) { if (this.isPresence(channel)) { this.presence.leave(socket, channel) @@ -84,6 +95,8 @@ export class Channel { if (this.options.devMode) { Log.info(`[${new Date().toISOString()}] - ${socket.id} left channel: ${channel} (${reason})`); } + + this.hook(socket, channel, auth, "onLeave"); } } @@ -117,7 +130,7 @@ export class Channel { this.presence.join(socket, data.channel, member); } - this.onJoin(socket, data.channel); + this.onJoin(socket, data.channel, data.auth); }, error => { if (this.options.devMode) { Log.error(error.reason); @@ -138,10 +151,12 @@ export class Channel { /** * On join a channel log success. */ - onJoin(socket: any, channel: string): void { + onJoin(socket: any, channel: string, auth: any): void { if (this.options.devMode) { Log.info(`[${new Date().toISOString()}] - ${socket.id} joined channel: ${channel}`); } + + this.hook(socket, channel, auth, "onJoin"); } /** @@ -164,4 +179,112 @@ export class Channel { isInChannel(socket: any, channel: string): boolean { return !!socket.rooms[channel]; } + + /** + * + * @param {any} socket + * @param {string} channel + * @param {object} auth + * @param {string} hookEndpoint + * @param {string} hookName + */ + hook(socket:any, channel: any, auth: any, hookName: string) { + if (typeof this.options.hookHost == 'undefined' || + !this.options.hookHost || + typeof this.options.hooks == 'undefined' || + !this.options.hooks) { + return; + } + + let hookEndpoint = this.getHookEndpoint(hookName, channel); + + if (hookEndpoint == null) { + return; + } + + let options = this.prepareHookHeaders(socket, auth, channel, hookEndpoint) + + this.request.post(options, (error, response, body, next) => { + if (error) { + if (this.options.devMode) { + Log.error(`[${new Date().toLocaleTimeString()}] - Error call ${hookName} hook ${socket.id} for ${options.form.channel_name}`); + } + + Log.error(error); + } else if (response.statusCode !== 200) { + if (this.options.devMode) { + Log.warning(`[${new Date().toLocaleTimeString()}] - Error call ${hookName} hook ${socket.id} for ${options.form.channel_name}`); + Log.error(response.body); + } + } else { + if (this.options.devMode) { + Log.info(`[${new Date().toLocaleTimeString()}] - Call ${hookName} hook for ${socket.id} for ${options.form.channel_name}: ${response.body}`); + } + } + }); + } + + /** + * Get hook endpoint for request to app server. + * + * @param {string} hookName + * @returns {string} + */ + getHookEndpoint(hookName: string, channel: any): string { + let hookEndpoint = null; + switch(hookName) { + case "onJoin": { + if (!this.options.hooks.onJoinEndpoint) { + break; + } + if (this.options.hooks.onJoinRegexp && !(new RegExp(this.options.hooks.onJoinRegexp)).test(channel)) { + break; + } + hookEndpoint = this.options.hooks.onJoinEndpoint; + break; + } + case "onLeave": { + if (!this.options.hooks.onLeaveEndpoint) { + break; + } + if (this.options.hooks.onLeaveRegexp && !(new RegExp(this.options.hooks.onLeaveRegexp)).test(channel)) { + break; + } + hookEndpoint = this.options.hooks.onLeaveEndpoint; + break; + } + case "onClientEvent": { + if (!this.options.hooks.onClientEventEndpoint) { + break; + } + hookEndpoint = this.options.hooks.onClientEventEndpoint; + break; + } + default: { + Log.error('cannot find hookEndpoint for hookName: ' + hookName); + break; + } + } + return hookEndpoint; + } + + /** + * Prepare headers for request to app server. + * + * @param {any} socket + * @param {any} auth + * @param {string} channel + * @param {string} hookEndpoint + * @returns {any} + */ + prepareHookHeaders(socket: any, auth: any, channel: string, hookEndpoint: string): any { + let options = { + url: this.options.hookHost + hookEndpoint, + form: { channel_name: channel }, + headers: (auth && auth.headers) ? auth.headers : {} + }; + options.headers['Cookie'] = socket.request.headers.cookie; + options.headers['X-Requested-With'] = 'XMLHttpRequest'; + return options; + } } diff --git a/src/cli/cli.ts b/src/cli/cli.ts index ec12f200..b18621c4 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -38,7 +38,8 @@ export class Cli { LARAVEL_ECHO_SERVER_SSL_CERT: "sslCertPath", LARAVEL_ECHO_SERVER_SSL_KEY: "sslKeyPath", LARAVEL_ECHO_SERVER_SSL_CHAIN: "sslCertChainPath", - LARAVEL_ECHO_SERVER_SSL_PASS: "sslPassphrase" + LARAVEL_ECHO_SERVER_SSL_PASS: "sslPassphrase", + NODE_TLS_REJECT_UNAUTHORIZED: "rejectUnautorized" }; /** diff --git a/src/echo-server.ts b/src/echo-server.ts index 069335c2..b2457497 100644 --- a/src/echo-server.ts +++ b/src/echo-server.ts @@ -44,7 +44,14 @@ export class EchoServer { allowOrigin: '', allowMethods: '', allowHeaders: '' - } + }, + hookHost: null, + hooks: { + "onJoinEndpoint": null, + "onLeaveEndpoint": null, + "onClientEventEndpoint": null + }, + rejectUnautorized: '' }; /** @@ -128,6 +135,8 @@ export class EchoServer { } else { Log.info('Starting server...\n') } + + Log.info(`Searching hooks...\n`); } /** @@ -225,7 +234,7 @@ export class EchoServer { */ onUnsubscribe(socket: any): void { socket.on('unsubscribe', data => { - this.channel.leave(socket, data.channel, 'unsubscribed'); + this.channel.leave(socket, data.channel, 'unsubscribed', data.auth); }); } @@ -236,7 +245,7 @@ export class EchoServer { socket.on('disconnecting', (reason) => { Object.keys(socket.rooms).forEach(room => { if (room !== socket.id) { - this.channel.leave(socket, room, reason); + this.channel.leave(socket, room, reason, {}); } }); }); diff --git a/src/server.ts b/src/server.ts index 0402fda4..35e9e0f2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -106,6 +106,10 @@ export class Server { next(); }); + if (this.options.rejectUnautorized && this.options.rejectUnautorized !== '') { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = this.options.rejectUnautorized + } + if (secure) { var httpServer = https.createServer(this.options, this.express); } else {