├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── codeql-analysis.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── Taskfile.yml ├── api ├── Dockerfile ├── build │ └── .gitkeep ├── channel_handler.go ├── claim_handler.go ├── config.toml ├── disconnect_handler.go ├── info_handler.go ├── main.go ├── resolve_claims.go ├── resolve_workers.go ├── send_handler.go └── send_to_workers.go ├── common ├── constants.go ├── errors.go ├── errors_test.go ├── gin.go ├── options.go ├── ping_handler.go ├── protos │ └── message.pb.go ├── requestid_middleware.go ├── resolve_options.go ├── token_middleware.go ├── utils.go ├── utils_test.go └── version.go ├── docker-compose.e2e.yml ├── docker-compose.yml ├── docs ├── images │ ├── infra.png │ └── logo.png └── releasing.md ├── e2e ├── Dockerfile ├── README.md ├── api_utils_test.go ├── channel_test.go ├── connect_test.go ├── disconnect_test.go ├── info_test.go ├── send_test.go └── test_utils_test.go ├── go.mod ├── go.sum ├── protos └── message.proto └── worker ├── Dockerfile ├── authentication.go ├── build └── .gitkeep ├── channel_handler.go ├── config.toml ├── connect_handler.go ├── jwt.go ├── main.go ├── resolve_connections.go ├── send_handler.go ├── state.go └── ttl.go /.dockerignore: -------------------------------------------------------------------------------- 1 | build 2 | gin-bin 3 | Dockerfile 4 | .dockerignore 5 | */tmp 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: Cretezy 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build-binaries: 11 | name: Build Binaries 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | - name: Install Task 17 | uses: Arduino/actions/setup-taskfile@master 18 | - name: Install Go 19 | uses: actions/setup-go@v2 20 | - name: Build Binaries 21 | run: task build:binaries 22 | - name: Upload Build Artifacts (binaries) 23 | uses: actions/upload-artifact@v1 24 | with: 25 | path: build 26 | name: Binaries 27 | 28 | build-docker: 29 | name: Build Docker Images 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v2 34 | - name: Install Task 35 | uses: Arduino/actions/setup-taskfile@master 36 | - name: Build Docker Images 37 | run: task build:docker 38 | 39 | e2e: 40 | name: E2E 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v2 45 | - name: Install Task 46 | uses: Arduino/actions/setup-taskfile@master 47 | - name: Run E2E Tests 48 | run: task tests:e2e 49 | 50 | unit: 51 | name: Unit 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name: Checkout 55 | uses: actions/checkout@v2 56 | - name: Install Task 57 | uses: Arduino/actions/setup-taskfile@master 58 | - name: Run Unit Tests 59 | run: task tests:unit 60 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Code Scanning 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * 6' 8 | 9 | jobs: 10 | CodeQL: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 2 17 | - name: Checkout (HEAD) 18 | run: git checkout HEAD^2 19 | if: ${{ github.event_name == 'pull_request' }} 20 | - name: Initialize CodeQL 21 | uses: github/codeql-action/init@v1 22 | - name: Autobuild 23 | uses: github/codeql-action/autobuild@v1 24 | - name: Perform CodeQL Analysis 25 | uses: github/codeql-action/analyze@v1 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | gin-bin 3 | */tmp 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | 3 | - Add internal errors to all missing applicable errors 4 | - Optimize info call from O(n) (where n number of connections/claims) to O(1) (in terms of Redis requests) 5 | - Optimize send call 6 | - Optimize channel call 7 | - Add TTL refreshing 8 | 9 | ## v0.4.1 - 2021-03-07 10 | 11 | - Add sending lock/mutex (prevents `concurrent write to websocket connection` error) 12 | 13 | ## v0.4.0 - 2021-02-24 14 | 15 | - Add direct API to worker messaging 16 | - Add `redis_max_retries` option 17 | - Add `redis_tls` option 18 | - Added request ID to error responses 19 | - Added more logging (including on error responses), and change multiple debug logging to info 20 | - Parallelized parsing of messages received from Redis (should increase throughput) 21 | - Deprecating `address` option: Use `port` instead 22 | - Fix bug where creating a claim with ID left ID blank 23 | 24 | ## v0.3.3 - 2020-07-15 25 | 26 | - Add `/ping` endpoint on API & worker 27 | 28 | ## v0.3.2 - 2020-07-12 29 | 30 | - Rebuild with fixed Docker images 31 | 32 | ## v0.3.1 - 2020-06-14 33 | 34 | - Improve query binding 35 | - Add locking for concurrent state (thanks a lot [@abdullah-aghayan](https://github.com/abdullah-aghayan)!) 36 | - Change logger to Zap (include request logger) 37 | - Added logging to all requests 38 | - Change unsubscribe type from 2 to 1 (internal type) 39 | - Fix debug mode never being active 40 | - Add log request (`log_requests`) option 41 | 42 | ## v0.3.0 - 2020-05-08 43 | 44 | - **Breaking** 45 | - Change `MISSING_CONNECTION_OR_USER` to `MISSING_TARGET` (and changed message) 46 | - Added channels: 47 | - Added `channel` target option to sending, getting info, disconnect 48 | - Added `channels` JWT claim (array of strings) 49 | - Added `channels` to claim creation (comma-delimited string) 50 | - Added `channels` to info response for connections 51 | - Added channel subscribing and unsubscribing 52 | - Added `default_channels` config options (comma-delimited string) 53 | - Replace `scripts` directory with [Task](https://taskfile.dev) 54 | 55 | ## v0.2.0 - 2020-05-06 56 | 57 | - **Breaking** 58 | - Changed response format for creating claims 59 | - All claim data is now inside the `claim` key, and more data is present. Example: 60 | 61 | Before: 62 | ```json 63 | { 64 | "success": true, 65 | "id": "XXX", 66 | "expiration": 1588473164 67 | } 68 | ``` 69 | 70 | After: 71 | ```json 72 | { 73 | "success": true, 74 | "claim": { 75 | "id": "XXX", 76 | "expiration": 1588473164, 77 | "user": "a", 78 | "session": "b" 79 | } 80 | } 81 | ``` 82 | 83 | - Added tests (E2E, unit) and CI 84 | - Added past expiration validation (doesn't allow expiration dates in the past) during claim creation 85 | 86 | ## v0.1.2 - 2020-05-06 87 | 88 | - Improved code documentation, refactored to make it cleaner 89 | - Improved API response code (refactored error handling) 90 | - Small performance improvements for some hot-paths 91 | - Actually bump version in code 92 | 93 | ## v0.1.1 - 2020-05-05 94 | 95 | - Fixed bug with missing `errorCode` when duration is negative during claim creation 96 | - Fix bug with incorrect `errorCode` when error is trigger during checking if a claim exists 97 | - Added documentation on errors 98 | 99 | > Version code during application startup is wrongly reported as v0.1.0 100 | 101 | ## v0.1.0 - 2020-05-04 102 | 103 | - Initial beta release 104 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Charles-William Crete 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |

dSock

5 |

dSock is a distributed WebSocket broker (in Go, using Redis).

6 |

Clients can authenticate & connect, and you can send text/binary message as an API.

7 |
8 | 9 | ## Features 10 | 11 | **Multiple clients per user & authentication** 12 | 13 | dSock can broadcast a message to all clients for a certain user (identified by user ID and optionally session ID) or a certain connection (by ID). Users can be authenticated using claims or JWTs (see below). 14 | 15 | **Distributed** 16 | 17 | dSock can be scaled up easily as it uses Redis as a central database & pub/sub, with clients connecting to worker. It's designed to run on the cloud using scalable platforms such as Kubernetes or Cloud Run. 18 | 19 | **Text & binary messaging** 20 | 21 | dSock is designed for text and binary messaging, enabling JSON (UTF-8), Protocol Buffers, or any custom protocol. 22 | 23 | **Lightweight & fast** 24 | 25 | dSock utilized Go's concurrency for great performance at scale, with easy distribution and safety. It is available as Docker images for convenience. 26 | 27 | **Disconnects** 28 | 29 | Disconnect clients from an external event (logout) from a session ID or for all user connections. 30 | 31 | ## Uses 32 | 33 | The main use case for dSock is having stateful WebSocket connections act as a stateless API. 34 | 35 | This enables you to not worry about your connection handling and simply send messages to all (or some) of a user's clients as any other HTTP API. 36 | 37 | **Chat service** 38 | 39 | Clients connect to dSock, and your back-end can broadcast messages to a specific user's clients 40 | 41 | **More!** 42 | 43 | ## Clients 44 | 45 | Use a client to interact with the dSock API easily. Your language missing? Open a ticket! 46 | 47 | - [Node](https://github.com/Cretezy/dSock-node) 48 | - [Go](https://github.com/Cretezy/dSock-go) 49 | 50 | ## Architecture 51 | 52 | ![](https://i.imgur.com/pFo2zDU.png) 53 | 54 | dSock is separated into 2 main services: 55 | 56 | - **dSock Worker** 57 | This is the main server clients connect to. The worker distributed the messages to the clients ("last mile") 58 | 59 | - **dSock API** 60 | The API receives messages and distributes it to the workers for target clients 61 | 62 | This allows the worker (connections) and API (gateway) to scale independently and horizontally. 63 | 64 | dSock uses Redis as a backend data store, to store connection locations and claims. 65 | 66 | ### Terminology 67 | 68 | | Word | | 69 | | ------------------------------------------------ | ------------------------------------------------------------------- | 70 | | [WebSocket](https://tools.ietf.org/html/rfc6455) | Sockets "over" HTTP(S) | 71 | | [JWT](https://tools.ietf.org/html/rfc7519) | JSON Web Token | 72 | | Claim | dSock authentication mention using a pre-registered claim ("token") | 73 | | [Redis](https://redis.io/) | Open-source in-memory key-value database | 74 | 75 | ### Flow 76 | 77 | - Authentication: 78 | - Client does request to your API, you either: 79 | - Hit the dSock API to create a claim for the user 80 | - Generate & sign a JWT for the user 81 | - You return the claim or JWT to client 82 | - Connection: 83 | - User connects to a worker with claim or JWT 84 | - Sending: 85 | - You hit the dSock API (`POST /send`) with the target (`user`, `session` optionally) and the message as body 86 | - Message sent to target(s) 87 | 88 | ## Setup 89 | 90 | ### Installation 91 | 92 | dSock is published as binaries and as Docker images. 93 | 94 | #### Binaries 95 | 96 | Binaries are available on the [releases pages](https://github.com/Cretezy/dSock/releases). 97 | 98 | You can simply run the binary for your architecture/OS. 99 | 100 | You can configure dSock using environment variables or a config (see below). 101 | 102 | #### Docker images 103 | 104 | Docker images are published on Docker Hub: 105 | 106 | - [`dsock/api`](https://hub.docker.com/r/dsock/api) 107 | - [`dsock/worker`](https://hub.docker.com/r/dsock/worker) 108 | 109 | The images are small (~15MB) and expose on port `80` by default (controllable by setting the `PORT` environment variable). 110 | 111 | It is recommended to use the environment variables to configure dSock instead of a config when using the images. 112 | Configs are still supported (can be mounted to `/config.toml` or `/config.$EXT`, see below). 113 | 114 | ### Options 115 | 116 | dSock can be configured using a config file or using environment variables. 117 | 118 | - `PORT` (`port`, integer, or `DSOCK_PORT` environment variable): Port to listen to. Defaults to `6241` 119 | - `DSOCK_ADDRESS` (`address`, string, _deprecated_):: Address to listen to. Defaults to `:6241`. Uses port if empty. 120 | - Redis: 121 | - `DSOCK_REDIS_HOST` (`redis_host`, string): Redis host. Defaults to `localhost:6379` 122 | - `DSOCK_REDIS_PASSWORD` (`redis_password`, string): Redis password. Defaults to no password 123 | - `DSOCK_REDIS_DB` (`redis_db`, integer): Redis database. Defaults to `0` 124 | - `DSOCK_REDIS_MAX_RETRIES` (`redis_max_retries`, integer): Maximum retries before failing Redis connection. Defaults to `10` 125 | - `DSOCK_REDIS_TLS` (`redis_tls`, boolean): Whether to enable TLS for Redis. Defaults to `false` 126 | - `DSOCK_DEFAULT_CHANNELS` (`default_channels`, comma-delimited string, optional): When set, clients will be automatically subscribed to these channels 127 | - Authentication: 128 | - `DSOCK_TOKEN` (`token`, string): Authentication token to do requests to the API 129 | - `DSOCK_JWT_SECRET` (`jwt_secret`, string, optional): When set, enables JWT authentication 130 | - `DSOCK_DEBUG` (`debug`, boolean): Enables debugging, useful for development. Defaults to `false` 131 | - `DSOCK_LOG_REQUESTS` (`log_requests`, boolean): Enables request logging. Defaults to `false` 132 | - `DSOCK_MESSAGING_METHOD` (`messaging_method`, string): The messages method for communication from API to worker. Can be: `redis`, `direct`. Defaults to `redis` 133 | 134 | #### Worker only 135 | 136 | - `DSOCK_DIRECT_MESSAGE_HOSTNAME` (`direct_message_hostname`, string, worker only): If `method_method` is set to `direct`, this is the hostname of the worker accessible from the API. Defaults to first local non-loopback IPv4 137 | - `DSOCK_DIRECT_MESSAGE_PORT` (`direct_message_port`, string, worker only): If `method_method` is set to `direct`, this is the port that the worker is listening on. Defaults to port 138 | - `DSOCK_TTL_DURATION` (`ttl_duration`, string duration, worker only): How often to refresh worker/connection keys in Redis. Uses [Go duration parsing](https://golang.org/pkg/time/#ParseDuration). Defaults to `60s` (should not be lower than `10s`) 139 | 140 | You can write your config file in TOML (recommended), JSON, YAML, or any format supported by [viper](https://github.com/spf13/viper) 141 | 142 | Configs are loaded from (in order): 143 | 144 | - `$PWD/config.$EXT` 145 | - `$HOME/.config/dsock/config.$EXT` 146 | - `/etc/dsock/config.$EXT` 147 | 148 | A default config will be created at `$PWD/config.toml` if no config is found. 149 | 150 | ## Usage 151 | 152 | All API calls will return a `success` boolean. 153 | If it is `false`, it will also add `error` (message) and `errorCode` (constant from `common/errors.go`). 154 | 155 | All API calls (excluding `/connect` endpoint) requires authentication with a `token` query parameter, or set as a `Authorization` header in the format of: `Bearer $TOKEN`. 156 | 157 | Having an invalid or missing token will result in the `INVALID_AUTHORIZATION` error code. 158 | 159 | Most errors starting with `ERROR_` are downstream errors, usually from Redis. Check if your Redis connection is valid! 160 | 161 | When targeting, the precedence order is: `id`, `channel`, `user`. 162 | 163 | ### Client authentication 164 | 165 | #### Claims 166 | 167 | Claims are the recommended way to authenticate with dSock. Before a client connects, they should hit your API (which you can use your usual authentication), and your API requests the dSock API to create a "claim", which you then return to the client. 168 | 169 | Once a client has a claim, it can then connect to the worker using the `claim` query parameter. 170 | 171 | You can create them by accessing the API as `POST /claim` with the following query options: 172 | 173 | - `user` (required, string): The user ID 174 | - `session` (optional, string): The session ID (scoped per user) 175 | - `channels` (optional, comma-delimited string): Channels to subscribe on join (merged with `default_channels`) 176 | - Time-related (not required, default expiration is 1 minute after the claim is created, only one used): 177 | - `expiration` (integer, seconds from epoch): Time the claim expires (takes precedence over `duration`) 178 | - `duration` (integer, seconds): Duration of the claim 179 | - `token` (required, string): Authorization token for API set in config. Can also be a `Authorization` Bearer token 180 | - `id` (optional, string): The claim ID to use. This should not be guessed, so long random string or UUIDv4 is recommended. If not set, it will generate a random string (recommended to let dSock generate the ID) 181 | 182 | The returned body will contain the following keys: 183 | 184 | - `claim`: The claim data 185 | - `id`: The claim ID 186 | - `expiration`: The expiration in seconds from epoch 187 | - `user`: The user for the claim 188 | - `session` (if session is provided): The user session for the claim 189 | - `channels`: The channels to subscribe on join (excludes defaults) 190 | 191 | A claim is single-use, so once a client connects, it will instantly expire. 192 | 193 | ##### Examples 194 | 195 | Create a claim for a user (`1`) expiring in 10 seconds, with 2 channels: 196 | 197 | ```text 198 | POST /claim?token=abcxyz&user=1&duration=10&channels=group-1,group-2 199 | ``` 200 | 201 | Create a claim for a user (`1`) with a session (`a`) with a claim ID (`a1b2c3`) expiring at some time: 202 | 203 | ```text 204 | POST /claim?user=1&session=a&expiration=1588473164&id=a1b2c3 205 | Authorization: Bearer abcxyz 206 | ``` 207 | 208 | ##### Errors 209 | 210 | Creating a claim has the follow possible errors: 211 | 212 | - `USER_ID_REQUIRED`: If the `user` parameter is not set 213 | - `INVALID_EXPIRATION`: If the expiration is invalid (not parsable as integer) 214 | - `NEGATIVE_EXPIRATION`: If the expiration is negative 215 | - `INVALID_DURATION`: If the duration is invalid (not parsable as integer) 216 | - `NEGATIVE_DURATION`: If the duration is negative 217 | - `ERROR_CHECKING_CLAIM`: If an error occurred during checking if a claim exist (Redis error) 218 | - `CLAIM_ID_ALREADY_USED`: If the claim ID is set and is already used 219 | 220 | #### JWT 221 | 222 | To authenticate a client, you can also create a JWT token and deliver it to the client before connecting. To enable this, set the `jwt_secret` to with your JWT secret (HMAC signature secret) 223 | 224 | Payload options: 225 | 226 | - `sub` (required, string): The user ID 227 | - `sid` (optional, string): The session ID (scoped per user) 228 | - `channels` (optional, array of string): Channels to subscribe on join (merged with `default_channels`) 229 | - Time-related (one is required): 230 | - [`iat`](https://tools.ietf.org/html/rfc7519#section-4.1.6) (integer, in seconds from epoch): Time the JWT is issued (expires 1 minute after this time) 231 | - [`exp`](https://tools.ietf.org/html/rfc7519#section-4.1.4) (integer, in seconds from epoch): Expiration time for the JWT, takes precedence over `iat` 232 | 233 | ### Client connections 234 | 235 | Connect using a WebSocket to `ws://worker/connect` with the one of the following query parameter options: 236 | 237 | - `claim`: The authentication claim created previously (takes precedence over `jwt`) 238 | - `jwt`: JWT created previously 239 | 240 | You can load-balance a cluster of workers, as long as the load-balancer supports WebSockets. 241 | 242 | #### Errors 243 | 244 | The following errors can happen during connection: 245 | 246 | - `ERROR_GETTING_CLAIM`: If an error occurred during fetching the claim (Redis error) 247 | - `MISSING_CLAIM`: If the claim ID doesn't exists. This can also happen if the claim has expired 248 | - `INVALID_EXPIRATION`: If the claim has an invalid expiration (shouldn't happen unless Redis error) 249 | - `EXPIRED_CLAIM`: If the claim has expired, but Redis hasn't expired the claim on it's own 250 | - `INVALID_JWT`: If the JWT is malformed (bad JSON/JWT format) or is not signed with proper key 251 | - `MISSING_AUTHENTICATION`: If no authentication is provided (no claim/JWT) 252 | 253 | ### Sending message 254 | 255 | Sending a message is done through the `POST /send` API endpoint. 256 | 257 | Query param options: 258 | 259 | - Targeting (one is required): 260 | - `user` (string): The user ID to target 261 | - `session` (optional, string, when `user` is set): The specific session(s) to target from the user 262 | - `id` (string UUID): The specific internal connection ID 263 | - `channel` (string): The channel to target 264 | - `type` (required, string): Message (body) type. Can be `text` (UTF-8 text) or `binary`. This becomes the WebSocket message type. 265 | - `token` (required, string): Authorization token for API set in config. Can also be a `Authorization` Bearer token 266 | 267 | The body of the request is used as the message. This can be text/binary, and the `Content-Type` header is not used internally (only `type` is used). 268 | 269 | #### Examples 270 | 271 | Send a JSON message to a user (`1`) 272 | 273 | ```text 274 | POST /send?token=abcxyz&user=1&type=text 275 | 276 | {"message":"Hello world!","from":"Charles"} 277 | ``` 278 | 279 | Send a text value to a user (`1`) with a session (`a`) 280 | 281 | ```text 282 | POST /send?user=1&session=a&type=text 283 | Authorization: Bearer abcxyz 284 | 285 | Hey! 286 | ``` 287 | 288 | Send a binary value to all clients subscribed in a channel: 289 | 290 | ```text 291 | POST /send?channel=group-1&type=binary 292 | Authorization: Bearer abcxyz 293 | 294 | # Binary... 295 | ``` 296 | 297 | #### Errors 298 | 299 | The following errors can happen during sending a message: 300 | 301 | - `INVALID_AUTHORIZATION`: Invalid authentication (token). See errors section under usage 302 | - `ERROR_GETTING_CONNECTION`: If could not fetch connection(s) (Redis error) 303 | - `ERROR_GETTING_USER`: If `user` is set and could not fetch user (Redis error) 304 | - `ERROR_GETTING_CHANNEL`: If `channel` is set and could not fetch channel (Redis error) 305 | - `MISSING_TARGET`: If target is not provider 306 | - `INVALID_MESSAGE_TYPE`: If the `type` is invalid 307 | - `ERROR_READING_MESSAGE`: If an error occurred during reading the request body 308 | - `ERROR_MARSHALLING_MESSAGE`: If an error occurred during preparing to send the message to the workers (shouldn't happen) 309 | 310 | ### Disconnecting 311 | 312 | You can disconnect a client by user (and optionally session) ID. 313 | 314 | This is useful when logging out a user, to make sure it also disconnects any connections. 315 | Make sure to include a session in your claim/JWT to be able to disconnect only some of a user's connections. 316 | 317 | The API endpoint is `POST /disconnect`, with the following query params: 318 | 319 | - Targeting (one is required): 320 | - `user` (string): The user ID to target 321 | - `session` (optional, string, when `user` is set): The specific session(s) to target from the user 322 | - `id` (string UUID): The specific internal connection ID 323 | - `channel` (string): The channel to target 324 | - `token` (required, string): Authorization token for API set in config. Can also be a `Authorization` Bearer token 325 | - `keepClaims` (optional, boolean): When set to `true`, keeps active claims for the target. By default, dSock will remove claims for the target to prevent race conditions 326 | 327 | #### Examples 328 | 329 | Disconnect a user (`1`) with a session (`a`): 330 | 331 | ```text 332 | POST /send?token=abcxyz&user=1&session=a 333 | ``` 334 | 335 | #### Errors 336 | 337 | The following errors can happen during disconnection: 338 | 339 | - `INVALID_AUTHORIZATION`: Invalid authentication (token). See errors section under usage 340 | - `ERROR_GETTING_CONNECTION`: If could not fetch connection(s) (Redis error) 341 | - `ERROR_GETTING_USER`: If `user` is set and could not fetch user (Redis error) 342 | - `ERROR_GETTING_CHANNEL`: If `channel` is set and could not fetch channel (Redis error) 343 | - `MISSING_TARGET`: If target is not provider 344 | - `ERROR_GETTING_CLAIM`: If an error occurred during fetching the claim(s) (Redis error) 345 | - `ERROR_MARSHALLING_MESSAGE`: If an error occurred during preparing to send the message to the workers (shouldn't happen) 346 | 347 | ### Info 348 | 349 | You can access info about connections and claims using the `GET /info` API endpoint. The following query params are supported: 350 | 351 | - Targeting (one is required): 352 | - `user` (string): The user ID to query 353 | - `session` (optional, string, when `user` is set): The specific session(s) to query from the user 354 | - `id` (string UUID): The specific internal connection ID 355 | - `channel` (string): The channel to query 356 | - `token` (required, string): Authorization token for API set in config. Can also be a `Authorization` Bearer token 357 | 358 | The API will return all opened connections and non-expired claims for the target. 359 | 360 | The returned object contains: 361 | 362 | - `connections` (array of objects): List of open connections for the target 363 | - `id`: Internal connection ID 364 | - `worker`: Internal worker holding the connection 365 | - `lastPing`: Last ping from client in seconds from epoch 366 | - `user`: The connection's user 367 | - `session` (optional): The connection's session 368 | - `channels`: The connection's subscribe channels (includes `default_channels`) 369 | - `claims` (array of objects): List of non-expired claims for the target: 370 | - `id`: Claim ID (what a client would connect with) 371 | - `expiration`: Claim expiration in seconds from epoch 372 | - `user`: The claim's user 373 | - `session` (optional): The claim's session 374 | 375 | #### Examples 376 | 377 | Get info for a user (`1`) with a session (`a`): 378 | 379 | ```text 380 | GET /info?token=abcxyz&user=1&session=a 381 | ``` 382 | 383 | #### Errors 384 | 385 | The following errors can happen during getting info: 386 | 387 | - `INVALID_AUTHORIZATION`: Invalid authentication (token). See errors section under usage 388 | - `ERROR_GETTING_CLAIM`: If an error occurred during fetching the claim(s) (Redis error) 389 | - `ERROR_GETTING_CONNECTION`: If could not fetch connection(s) (Redis error) 390 | - `ERROR_GETTING_USER`: If `user` is set and could not fetch user (Redis error) 391 | - `ERROR_GETTING_CHANNEL`: If `channel` is set and could not fetch channel (Redis error) 392 | - `MISSING_TARGET`: If target is not provider 393 | 394 | ### Channels 395 | 396 | You can subscribe/unsubscribe clients to a channel using `POST /channel/subscribe/$CHANNEL` or `POST /channel/unsubscribe/$CHANNEL`. 397 | 398 | This will subscribe the connections and claims (optional) for the target provided. 399 | 400 | The follow query parameters are accepted: 401 | - Targeting (one is required): 402 | - `user` (string): The user ID to query 403 | - `session` (optional, string, when `user` is set): The specific session(s) to query from the user 404 | - `id` (string UUID): The specific internal connection ID 405 | - `channel` (string): The channel to query 406 | - `ignoreClaims` (optional, boolean): When set to `true`, doesn't add channel to claims for target. By default, dSock will add the channel to the target claims (for when the client does join) 407 | - `token` (required, string): Authorization token for API set in config. Can also be a `Authorization` Bearer token 408 | 409 | #### Examples 410 | 411 | Subscribe a user (`1`) to a channel (`a`): 412 | 413 | ```text 414 | POST /channel/subscribe/a?token=abcxyz&user=1 415 | ``` 416 | 417 | Unsubscribe all clients in a channel from a channel (`a`): 418 | 419 | ```text 420 | POST /channel/unsubscribe/a?token=abcxyz&channel=a 421 | ``` 422 | 423 | #### Errors 424 | 425 | The following errors can happen during channel subscription/unsubscription: 426 | 427 | - `INVALID_AUTHORIZATION`: Invalid authentication (token). See errors section under usage 428 | - `ERROR_GETTING_CONNECTION`: If could not fetch connection(s) (Redis error) 429 | - `ERROR_GETTING_USER`: If `user` is set and could not fetch user (Redis error) 430 | - `ERROR_GETTING_CHANNEL`: If `channel` is set and could not fetch channel (Redis error) 431 | - `MISSING_TARGET`: If target is not provider 432 | - `ERROR_MARSHALLING_MESSAGE`: If an error occurred during preparing to send the message to the workers (shouldn't happen) 433 | 434 | ## Internals 435 | 436 | dSock uses Redis as it's database (for claims and connection information) and for it's publish/subscribe capabilities. 437 | Redis was chosen because it is widely used, is performant, and supports all requried features. 438 | 439 | ### Claims 440 | 441 | When creating a claim, dSock does the following operations: 442 | 443 | - Set `claim:$id` to the claim information (user, session, expiration) 444 | - Add the claim ID to `claim-user:$user` (to be able to lookup all of a user's claims) 445 | - Add the claim ID to `claim-user-session:$user-$session` if session is passed (to be able to lookup all of a user session's claims) 446 | - Add the claim ID to `claim-channel:$channel` if channel is passed (to be able to lookup all of a channel's claims) 447 | 448 | When a user connects, dSock retrieves the claim by ID and validates it's expiration. It then removes the claim from the user and user session storages. 449 | 450 | When getting information or disconnecting, it retrieves or deletes the claim(s). 451 | 452 | ### Connections 453 | 454 | When a user connects and authenticates, dSock does the following operations: 455 | 456 | - Set `conn:$id` to the connection's information (using a random UUID, with user, session, worker ID, and last ping) 457 | - Add the connection ID to `user:$user` (to be able to lookup all of a user's connections) 458 | - Add the connection ID to `user-sesion:$user-$session` (if session was in authentication, to be able to lookup all of a user session's connections) 459 | - Add the connection ID to `channel:$channel` (for each channel in authentication, to be able to lookup all of a channel's connections) 460 | 461 | When receiving a ping or pong from the client, it updates the last ping time. A ping is sent from the server every minute. 462 | 463 | Connections are kept alive until a client disconnects, or is forcibly disconnected using `POST /disconnect` 464 | 465 | ### Sending 466 | 467 | When sending a message, the API resolves of all of the workers that hold connections for the target user/session/connection, and sends the message through Redis to that worker's channel (`worker:$id`). 468 | 469 | API to worker messages are encoded using [Protocol Buffer](https://developers.google.com/protocol-buffers) for efficiency; 470 | they are fast to encode/decode, and binary messages to not need to be encoded as strings during communication. 471 | 472 | ### Channels 473 | 474 | Channels are assosiated to claims/JWTs (before a client connects) and connections. 475 | 476 | When (un)subscribing a target to a channel, it looks up all of the target's claims and adds the claim (if `ignoreClaim` is not set), 477 | and broadcasts to workers with connections that are connected through the `$workerId:channel` Redis channel. 478 | 479 | The worker then resolves all connections for the target and adds them to the channel. 480 | 481 | Channels are found under `channel:$channel` and contain the list of connection IDs which are subscribed. 482 | 483 | Claim channels are found under `claim-channel:$channel` and contain the list of claim IDs which will become subscribed, 484 | and is also stored under `channels` in the claim. 485 | 486 | ## FAQ 487 | 488 | **Why is built-in HTTPS not supported?** 489 | 490 | To remove complexity inside dSock, TLS is not implemented. 491 | It is expected that the API and worker nodes are behind load-balancers, which would be able to do TLS termination. 492 | 493 | If you need TLS, you can either add a TLS-terminating load-balancer, or a reverse proxy (such as nginx or Caddy). 494 | 495 | **How can I do a health-check on dSock?** 496 | 497 | You can use the `/ping` endpoint on the API & worker to monitor if the service is up. It will response `pong`. 498 | 499 | ## Development 500 | 501 | ### Setup 502 | 503 | - Install [Go](https://golang.org) 504 | - Install [Docker](https://www.docker.com) and [Docker Compose](https://docs.docker.com/compose/) 505 | - Install [Task](https://taskfile.dev) 506 | - Pull the [dSock repository](https://github.com/Cretzy/dSock) 507 | - Run `docker-compose up` 508 | - Develop! API is available at `:3000`, and worker at `:3001`. Configs are in their respective folders 509 | 510 | ### Protocol Buffers 511 | 512 | If making changes to the Protocol Buffer definitions (under `protos`), make sure you have the [`protoc`](https://github.com/protocolbuffers/protobuf) compiler and [`protoc-gen-go`](https://developers.google.com/protocol-buffers/docs/reference/go-generated). 513 | 514 | Once changes are done to the definitions, run `task build:protos` to generate the associated Go code. 515 | 516 | ### Docker 517 | 518 | You can build the Docker images by running `task build:docker`. This will create the `dsock-worker` and `dsock-api` images. 519 | 520 | ### Tests 521 | 522 | dSock has multiple types of tests to ensure stability and maximum coverage. 523 | 524 | You can run all tests by running `task tests`. You can also run individual test suites (see below) 525 | 526 | #### End-to-end (E2E) 527 | 528 | You can run the E2E tests by running `task tests:e2e`. The E2E tests are located inside the `e2e` directory. 529 | 530 | #### Unit 531 | 532 | You can run the unit tests by running `task tests:unit`. The units tests are located inside the `common`/`api`/`worker` directories. 533 | 534 | ### Contributing 535 | 536 | [Pull requests](https://github.com/Cretezy/dSock/pulls) are encouraged! 537 | 538 | ## License & support 539 | 540 | dSock is MIT licensed ([see license](./LICENSE)). 541 | 542 | Community support is available through [GitHub issues](https://github.com/Cretezy/dSock/issues). 543 | For professional support, please contact [charles@cretezy.com](mailto:charles@cretezy.com?subject=dSock%20Support). 544 | 545 | ## Credit 546 | 547 | Icon made by Freepik from flaticon.com. 548 | 549 | Project was created & currently maintained by [Charles Crete](https://github.com/Cretezy). 550 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | silent: true 4 | 5 | tasks: 6 | start: 7 | cmds: 8 | - docker-compose up 9 | 10 | format: 11 | cmds: 12 | - gofmt -s -w . 13 | 14 | tests: 15 | cmds: 16 | # Run sequentially 17 | - task: tests:unit 18 | - task: tests:e2e 19 | 20 | tests:unit: 21 | cmds: 22 | - go test ./common 23 | - go test ./api 24 | - go test ./worker 25 | 26 | tests:e2e: 27 | cmds: 28 | - docker-compose down 29 | - docker-compose -f docker-compose.yml -f docker-compose.e2e.yml run e2e 30 | 31 | build:protos: 32 | cmds: 33 | - protoc --proto_path=protos --go_opt=paths=source_relative --go_out=common/protos protos/* 34 | 35 | build: 36 | cmds: 37 | # Run sequentially 38 | - task: build:binaries 39 | - task: build:docker 40 | 41 | build:binaries: 42 | cmds: 43 | - rm -rf build && mkdir build 44 | - task: build:binaries:api 45 | - task: build:binaries:worker 46 | 47 | build:binaries:api: 48 | cmds: 49 | - echo "Building API - Linux" 50 | - cd api && GOOS=linux GOARCH=386 go build -o ../build/api-linux-386 -ldflags "-s -w" 51 | - cd api && GOOS=linux GOARCH=amd64 go build -o ../build/api-linux-amd64 -ldflags "-s -w" 52 | - echo "Building API - Windows" 53 | - cd api && GOOS=windows GOARCH=386 go build -o ../build/api-windows-386 -ldflags "-s -w" 54 | - cd api && GOOS=windows GOARCH=amd64 go build -o ../build/api-windows-amd64 -ldflags "-s -w" 55 | - echo "Building API - macOS" 56 | - cd api && GOOS=darwin GOARCH=amd64 go build -o ../build/api-darwin-amd64 -ldflags "-s -w" 57 | 58 | build:binaries:worker: 59 | cmds: 60 | - echo "Building worker - Linux" 61 | - cd worker && GOOS=linux GOARCH=386 go build -o ../build/worker-linux-386 -ldflags "-s -w" 62 | - cd worker && GOOS=linux GOARCH=amd64 go build -o ../build/worker-linux-amd64 -ldflags "-s -w" 63 | - echo "Building worker - Windows" 64 | - cd worker && GOOS=windows GOARCH=386 go build -o ../build/worker-windows-386 -ldflags "-s -w" 65 | - cd worker && GOOS=windows GOARCH=amd64 go build -o ../build/worker-windows-amd64 -ldflags "-s -w" 66 | - echo "Building worker - macOS" 67 | - cd worker && GOOS=darwin GOARCH=amd64 go build -o ../build/worker-darwin-amd64 -ldflags "-s -w" 68 | 69 | build:binaries:race: 70 | cmds: 71 | - echo "Building API" 72 | - cd api && go build -race -o ../build/api 73 | - echo "Building worker" 74 | - cd worker && go build -race -o ../build/worker 75 | 76 | build:docker: 77 | cmds: 78 | - docker build -t dsock-api -f api/Dockerfile . 79 | - docker build -t dsock-worker -f worker/Dockerfile . 80 | 81 | push:docker: 82 | cmds: 83 | - echo "Tagging images as {{.TAG}}..." 84 | - docker tag dsock-api dsock/api:{{.TAG}} 85 | - docker tag dsock-worker dsock/worker:{{.TAG}} 86 | - echo "Pushing images..." 87 | - docker push dsock/api:{{.TAG}} 88 | - docker push dsock/worker:{{.TAG}} 89 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | # Development stage with gin and all required files 2 | FROM golang:1.13 AS development 3 | 4 | RUN go get -u github.com/cosmtrek/air 5 | 6 | WORKDIR /app 7 | 8 | ADD go.mod go.sum ./ 9 | RUN go mod download 10 | 11 | ADD common ./common 12 | ADD api ./api 13 | 14 | WORKDIR /app/api 15 | ENV PORT 80 16 | EXPOSE 80 17 | 18 | ENTRYPOINT ["air"] 19 | 20 | 21 | # Release builder stage, to build the output binary 22 | FROM development AS release_builder 23 | 24 | COPY --from=development /app /app 25 | 26 | ENV CGO_ENABLED=0 27 | RUN go build -o build/app -ldflags "-s -w" 28 | 29 | 30 | # Release stage, with only the binary 31 | FROM scratch AS release 32 | 33 | COPY --from=release_builder /app/api/build/app /app 34 | 35 | ENV PORT 80 36 | EXPOSE 80 37 | 38 | ENTRYPOINT ["/app"] 39 | -------------------------------------------------------------------------------- /api/build/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cretezy/dSock/cea9d8399c23d75d6668a0a7dfaeb8e5c1c01a80/api/build/.gitkeep -------------------------------------------------------------------------------- /api/channel_handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Cretezy/dSock/common" 5 | "github.com/Cretezy/dSock/common/protos" 6 | "github.com/gin-contrib/requestid" 7 | "github.com/gin-gonic/gin" 8 | "github.com/go-redis/redis/v7" 9 | "go.uber.org/zap" 10 | "strings" 11 | ) 12 | 13 | var actionTypeName = map[protos.ChannelAction_ChannelActionType]string{ 14 | protos.ChannelAction_SUBSCRIBE: "subscribe", 15 | protos.ChannelAction_UNSUBSCRIBE: "unsubscribe", 16 | } 17 | 18 | func getChannelHandler(actionType protos.ChannelAction_ChannelActionType) gin.HandlerFunc { 19 | return func(c *gin.Context) { 20 | requestId := requestid.Get(c) 21 | 22 | logger.Info("Getting channel request", 23 | zap.String("requestId", requestId), 24 | zap.String("action", actionTypeName[actionType]), 25 | zap.String("id", c.Query("id")), 26 | zap.String("user", c.Query("user")), 27 | zap.String("session", c.Query("session")), 28 | zap.String("channel", c.Query("channel")), 29 | zap.String("ignoreClaims", c.Query("ignoreClaims")), 30 | zap.String("channelChange", c.Param("channel")), 31 | ) 32 | 33 | resolveOptions := common.ResolveOptions{} 34 | 35 | err := c.BindQuery(&resolveOptions) 36 | if err != nil { 37 | apiError := &common.ApiError{ 38 | InternalError: err, 39 | ErrorCode: common.ErrorBindingQueryParams, 40 | StatusCode: 400, 41 | RequestId: requestId, 42 | } 43 | apiError.Send(c) 44 | return 45 | } 46 | 47 | channelChange := c.Param("channel") 48 | ignoreClaims := c.Query("ignoreClaims") == "true" 49 | 50 | // Get all worker IDs that the target(s) is connected to 51 | workerIds, apiError := resolveWorkers(resolveOptions, requestId) 52 | if apiError != nil { 53 | apiError.Send(c) 54 | return 55 | } 56 | 57 | if !ignoreClaims { 58 | // Add channel to all claims for the target 59 | claimIds, apiError := resolveClaims(resolveOptions, requestId) 60 | 61 | if apiError != nil { 62 | apiError.Send(c) 63 | return 64 | } 65 | 66 | var claimCmds = make([]*redis.StringStringMapCmd, len(claimIds)) 67 | _, err := redisClient.Pipelined(func(pipeliner redis.Pipeliner) error { 68 | for index, claimId := range claimIds { 69 | // HGetAll instead of HGet to be able to check if claim exist 70 | claimCmds[index] = pipeliner.HGetAll("claim:" + claimId) 71 | } 72 | 73 | return nil 74 | }) 75 | 76 | if err != nil { 77 | apiError = &common.ApiError{ 78 | InternalError: err, 79 | ErrorCode: common.ErrorGettingClaim, 80 | StatusCode: 500, 81 | RequestId: requestId, 82 | } 83 | apiError.Send(c) 84 | 85 | return 86 | } 87 | 88 | _, err = redisClient.Pipelined(func(pipeliner redis.Pipeliner) error { 89 | // Update all resolved claims 90 | for index, claimId := range claimIds { 91 | claimKey := "claim:" + claimId 92 | claim := claimCmds[index] 93 | 94 | if len(claim.Val()) == 0 { 95 | // Claim doesn't exist 96 | continue 97 | } 98 | 99 | channels := common.RemoveEmpty(strings.Split(claim.Val()["channels"], ",")) 100 | 101 | if actionType == protos.ChannelAction_SUBSCRIBE && !common.IncludesString(channels, channelChange) { 102 | channels = append(channels, channelChange) 103 | pipeliner.SAdd("claim-channel:"+channelChange, claimId) 104 | } else if actionType != protos.ChannelAction_SUBSCRIBE && common.IncludesString(channels, channelChange) { 105 | channels = common.RemoveString(channels, channelChange) 106 | pipeliner.SRem("claim-channel:"+channelChange, claimId) 107 | } else { 108 | continue 109 | } 110 | 111 | pipeliner.HSet(claimKey, "channels", strings.Join(channels, ",")) 112 | } 113 | 114 | return nil 115 | }) 116 | 117 | if err != nil { 118 | apiError = &common.ApiError{ 119 | InternalError: err, 120 | // TODO: Improve error 121 | ErrorCode: common.ErrorGettingChannel, 122 | StatusCode: 500, 123 | RequestId: requestId, 124 | } 125 | apiError.Send(c) 126 | return 127 | } 128 | } 129 | 130 | // Prepare message for worker 131 | message := &protos.ChannelAction{ 132 | Channel: channelChange, 133 | Target: &protos.Target{ 134 | Connection: resolveOptions.Connection, 135 | User: resolveOptions.User, 136 | Session: resolveOptions.Session, 137 | Channel: resolveOptions.Channel, 138 | }, 139 | Type: actionType, 140 | } 141 | 142 | // Send to all workers 143 | apiError = sendToWorkers(workerIds, message, ChannelMessageType, requestId) 144 | if apiError != nil { 145 | apiError.Send(c) 146 | return 147 | } 148 | 149 | logger.Info("Set channel", 150 | zap.String("requestId", requestId), 151 | zap.String("action", actionTypeName[actionType]), 152 | zap.String("id", resolveOptions.Connection), 153 | zap.String("user", resolveOptions.User), 154 | zap.String("session", resolveOptions.Session), 155 | zap.String("channel", resolveOptions.Channel), 156 | zap.Bool("ignoreClaims", ignoreClaims), 157 | zap.String("channelChange", channelChange), 158 | ) 159 | 160 | c.AbortWithStatusJSON(200, map[string]interface{}{ 161 | "success": true, 162 | }) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /api/claim_handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Cretezy/dSock/common" 5 | "github.com/gin-contrib/requestid" 6 | "github.com/gin-gonic/gin" 7 | "go.uber.org/zap" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type claimOptions struct { 14 | Id string `form:"id"` 15 | User string `form:"user"` 16 | Channels string `form:"channels"` 17 | Session string `form:"session"` 18 | Expiration string `form:"expiration"` 19 | Duration string `form:"duration"` 20 | } 21 | 22 | func createClaimHandler(c *gin.Context) { 23 | logger.Info("Getting new claim request", 24 | zap.String("requestId", requestid.Get(c)), 25 | zap.String("id", c.Query("id")), 26 | zap.String("user", c.Query("user")), 27 | zap.String("session", c.Query("session")), 28 | zap.String("channels", c.Query("channels")), 29 | zap.String("expiration", c.Query("expiration")), 30 | zap.String("duration", c.Query("duration")), 31 | ) 32 | 33 | claimOptions := claimOptions{} 34 | 35 | err := c.BindQuery(&claimOptions) 36 | if err != nil { 37 | apiError := &common.ApiError{ 38 | InternalError: err, 39 | ErrorCode: common.ErrorBindingQueryParams, 40 | StatusCode: 400, 41 | RequestId: requestid.Get(c), 42 | } 43 | apiError.Send(c) 44 | return 45 | } 46 | 47 | channels := common.UniqueString(common.RemoveEmpty( 48 | strings.Split(claimOptions.Channels, ","), 49 | )) 50 | 51 | if claimOptions.User == "" { 52 | apiError := common.ApiError{ 53 | ErrorCode: common.ErrorUserIdRequired, 54 | StatusCode: 400, 55 | RequestId: requestid.Get(c), 56 | } 57 | apiError.Send(c) 58 | return 59 | } 60 | 61 | // Parses expiration time from expiration or duration 62 | var expirationTime time.Time 63 | 64 | if claimOptions.Expiration != "" { 65 | expiration, err := strconv.Atoi(claimOptions.Expiration) 66 | 67 | if err != nil { 68 | apiError := common.ApiError{ 69 | InternalError: err, 70 | ErrorCode: common.ErrorInvalidExpiration, 71 | StatusCode: 400, 72 | RequestId: requestid.Get(c), 73 | } 74 | apiError.Send(c) 75 | return 76 | } 77 | 78 | if expiration < 1 { 79 | apiError := common.ApiError{ 80 | ErrorCode: common.ErrorNegativeExpiration, 81 | StatusCode: 400, 82 | RequestId: requestid.Get(c), 83 | } 84 | apiError.Send(c) 85 | return 86 | } 87 | 88 | expirationTime = time.Unix(int64(expiration), 0) 89 | 90 | if expirationTime.Before(time.Now()) { 91 | apiError := common.ApiError{ 92 | ErrorCode: common.ErrorInvalidExpiration, 93 | StatusCode: 400, 94 | RequestId: requestid.Get(c), 95 | } 96 | apiError.Send(c) 97 | return 98 | } 99 | } else if claimOptions.Duration != "" { 100 | duration, err := strconv.Atoi(claimOptions.Duration) 101 | 102 | if err != nil { 103 | apiError := common.ApiError{ 104 | InternalError: err, 105 | ErrorCode: common.ErrorInvalidDuration, 106 | StatusCode: 400, 107 | RequestId: requestid.Get(c), 108 | } 109 | apiError.Send(c) 110 | return 111 | } 112 | 113 | if duration < 1 { 114 | apiError := common.ApiError{ 115 | ErrorCode: common.ErrorNegativeDuration, 116 | StatusCode: 400, 117 | RequestId: requestid.Get(c), 118 | } 119 | apiError.Send(c) 120 | return 121 | } 122 | 123 | expirationTime = time.Now().Add(time.Duration(duration) * time.Second) 124 | } else { 125 | expirationTime = time.Now().Add(time.Minute) 126 | } 127 | 128 | // Gets or generates claim ID 129 | var id string 130 | 131 | if claimOptions.Id != "" { 132 | exists := redisClient.Exists("claim:" + claimOptions.Id) 133 | 134 | if exists.Err() != nil { 135 | apiError := common.ApiError{ 136 | InternalError: exists.Err(), 137 | ErrorCode: common.ErrorCheckingClaim, 138 | StatusCode: 500, 139 | RequestId: requestid.Get(c), 140 | } 141 | apiError.Send(c) 142 | return 143 | } 144 | 145 | if exists.Val() == 1 { 146 | apiError := common.ApiError{ 147 | ErrorCode: common.ErrorClaimIdAlreadyUsed, 148 | StatusCode: 400, 149 | RequestId: requestid.Get(c), 150 | } 151 | apiError.Send(c) 152 | return 153 | } 154 | 155 | id = claimOptions.Id 156 | } else { 157 | id = common.RandomString(32) 158 | } 159 | 160 | // Creates claim in Redis 161 | claim := map[string]interface{}{ 162 | "user": claimOptions.User, 163 | "expiration": expirationTime.Format(time.RFC3339), 164 | } 165 | 166 | if claimOptions.Session != "" { 167 | claim["session"] = claimOptions.Session 168 | } 169 | 170 | if len(channels) != 0 { 171 | claim["channels"] = strings.Join(channels, ",") 172 | } 173 | 174 | claimKey := "claim:" + id 175 | redisClient.HSet(claimKey, claim) 176 | redisClient.ExpireAt(claimKey, expirationTime) 177 | 178 | // Create user/session claim 179 | userKey := "claim-user:" + claimOptions.User 180 | redisClient.SAdd(userKey, id, 0) 181 | 182 | if claimOptions.Session != "" { 183 | userSessionKey := "claim-user-session:" + claimOptions.User + "-" + claimOptions.Session 184 | redisClient.SAdd(userSessionKey, id, 0) 185 | } 186 | for _, channel := range channels { 187 | channelKey := "claim-channel:" + channel 188 | redisClient.SAdd(channelKey, id, 0) 189 | } 190 | 191 | logger.Info("Created new claim", 192 | zap.String("requestId", requestid.Get(c)), 193 | zap.String("id", id), 194 | zap.String("user", claimOptions.User), 195 | zap.Strings("channels", channels), 196 | zap.String("session", claimOptions.Session), 197 | zap.Time("expiration", expirationTime), 198 | ) 199 | 200 | claimResponse := gin.H{ 201 | "id": id, 202 | "expiration": expirationTime.Unix(), 203 | "user": claimOptions.User, 204 | "channels": channels, 205 | } 206 | 207 | if claimOptions.Session != "" { 208 | claimResponse["session"] = claimOptions.Session 209 | } 210 | 211 | if len(channels) != 0 { 212 | claimResponse["channels"] = channels 213 | } 214 | 215 | c.AbortWithStatusJSON(200, map[string]interface{}{ 216 | "success": true, 217 | "claim": claimResponse, 218 | }) 219 | } 220 | -------------------------------------------------------------------------------- /api/config.toml: -------------------------------------------------------------------------------- 1 | debug = true 2 | log_requests = true 3 | redis_db = 0 4 | redis_host = "redis:6379" 5 | redis_password = "" 6 | token = "abc123" 7 | jwt_token = "abc123" 8 | default_channels = "global" 9 | messaging_method = "direct" 10 | -------------------------------------------------------------------------------- /api/disconnect_handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Cretezy/dSock/common" 5 | "github.com/Cretezy/dSock/common/protos" 6 | "github.com/gin-contrib/requestid" 7 | "github.com/gin-gonic/gin" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | func disconnectHandler(c *gin.Context) { 12 | logger.Info("Getting disconnect request", 13 | zap.String("requestId", requestid.Get(c)), 14 | zap.String("id", c.Query("id")), 15 | zap.String("user", c.Query("user")), 16 | zap.String("session", c.Query("session")), 17 | zap.String("channel", c.Query("channel")), 18 | zap.String("keepClaims", c.Query("keepClaims")), 19 | ) 20 | 21 | resolveOptions := common.ResolveOptions{} 22 | 23 | err := c.BindQuery(&resolveOptions) 24 | if err != nil { 25 | apiError := &common.ApiError{ 26 | InternalError: err, 27 | ErrorCode: common.ErrorBindingQueryParams, 28 | StatusCode: 400, 29 | RequestId: requestid.Get(c), 30 | } 31 | apiError.Send(c) 32 | return 33 | } 34 | 35 | keepClaims := c.Query("keepClaims") == "true" 36 | 37 | // Get all worker IDs that the target is connected to 38 | workerIds, apiError := resolveWorkers(resolveOptions, requestid.Get(c)) 39 | if apiError != nil { 40 | apiError.Send(c) 41 | return 42 | } 43 | 44 | if !keepClaims { 45 | // Expire claims instantly, must resolve all claims for target 46 | claimIds, apiError := resolveClaims(resolveOptions, requestid.Get(c)) 47 | 48 | if apiError != nil { 49 | apiError.Send(c) 50 | return 51 | } 52 | 53 | // Delete all resolved claims 54 | claimKeys := make([]string, len(claimIds)) 55 | for index, claim := range claimIds { 56 | claimKeys[index] = "claim:" + claim 57 | } 58 | 59 | redisClient.SRem("claim-user:"+resolveOptions.User, claimIds) 60 | if resolveOptions.Session != "" { 61 | redisClient.SRem("claim-user-session:"+resolveOptions.User+"-"+resolveOptions.Session, claimIds) 62 | } 63 | if resolveOptions.Channel != "" { 64 | redisClient.SRem("claim-channel:"+resolveOptions.Channel, claimIds) 65 | } 66 | 67 | redisClient.Del(claimKeys...) 68 | } 69 | 70 | // Prepare message for worker 71 | message := &protos.Message{ 72 | Type: protos.Message_DISCONNECT, 73 | Target: &protos.Target{ 74 | Connection: resolveOptions.Connection, 75 | User: resolveOptions.User, 76 | Session: resolveOptions.Session, 77 | Channel: resolveOptions.Channel, 78 | }, 79 | } 80 | 81 | // Send to all workers 82 | apiError = sendToWorkers(workerIds, message, MessageMessageType, requestid.Get(c)) 83 | if apiError != nil { 84 | apiError.Send(c) 85 | return 86 | } 87 | 88 | logger.Info("Disconnected", 89 | zap.String("requestId", requestid.Get(c)), 90 | zap.Strings("workerIds", workerIds), 91 | zap.String("id", resolveOptions.Connection), 92 | zap.String("user", resolveOptions.User), 93 | zap.String("session", resolveOptions.Session), 94 | zap.String("channel", resolveOptions.Channel), 95 | zap.Bool("keepClaims", keepClaims), 96 | ) 97 | 98 | c.AbortWithStatusJSON(200, map[string]interface{}{ 99 | "success": true, 100 | }) 101 | } 102 | -------------------------------------------------------------------------------- /api/info_handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Cretezy/dSock/common" 5 | "github.com/gin-contrib/requestid" 6 | "github.com/gin-gonic/gin" 7 | "github.com/go-redis/redis/v7" 8 | "go.uber.org/zap" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | func formatConnection(id string, connection map[string]string) gin.H { 14 | // Can safely ignore, will become 0 15 | lastPingTime, _ := time.Parse(time.RFC3339, connection["lastPing"]) 16 | 17 | connectionMap := gin.H{ 18 | "id": id, 19 | "worker": connection["workerId"], 20 | "lastPing": lastPingTime.Unix(), 21 | "user": connection["user"], 22 | "channels": strings.Split(connection["channels"], ","), 23 | } 24 | 25 | if connection["session"] != "" { 26 | connectionMap["session"] = connection["session"] 27 | } 28 | 29 | return connectionMap 30 | } 31 | 32 | func formatClaim(id string, claim map[string]string) gin.H { 33 | // Can safely ignore, invalid times are already filtered out in infoHandler 34 | expirationTime, _ := time.Parse(time.RFC3339, claim["expiration"]) 35 | 36 | claimMap := gin.H{ 37 | "id": id, 38 | "expiration": expirationTime.Unix(), 39 | "user": claim["user"], 40 | "channels": strings.Split(claim["channels"], ","), 41 | } 42 | 43 | if claim["session"] != "" { 44 | claimMap["session"] = claim["session"] 45 | } 46 | 47 | return claimMap 48 | } 49 | 50 | func infoHandler(c *gin.Context) { 51 | logger.Info("Getting info request", 52 | zap.String("requestId", requestid.Get(c)), 53 | zap.String("id", c.Query("id")), 54 | zap.String("user", c.Query("user")), 55 | zap.String("session", c.Query("session")), 56 | zap.String("channel", c.Query("channel")), 57 | ) 58 | 59 | resolveOptions := common.ResolveOptions{} 60 | 61 | err := c.BindQuery(&resolveOptions) 62 | if err != nil { 63 | apiError := &common.ApiError{ 64 | InternalError: err, 65 | ErrorCode: common.ErrorBindingQueryParams, 66 | StatusCode: 400, 67 | RequestId: requestid.Get(c), 68 | } 69 | apiError.Send(c) 70 | return 71 | } 72 | 73 | claimIds, apiError := resolveClaims(resolveOptions, requestid.Get(c)) 74 | if apiError != nil { 75 | apiError.Send(c) 76 | return 77 | } 78 | 79 | claimCmds := make([]*redis.StringStringMapCmd, len(claimIds)) 80 | _, err = redisClient.Pipelined(func(pipeliner redis.Pipeliner) error { 81 | for index, claimId := range claimIds { 82 | claimCmds[index] = pipeliner.HGetAll("claim:" + claimId) 83 | } 84 | 85 | return nil 86 | }) 87 | 88 | if err != nil { 89 | apiError := &common.ApiError{ 90 | InternalError: err, 91 | ErrorCode: common.ErrorGettingClaim, 92 | StatusCode: 500, 93 | RequestId: requestid.Get(c), 94 | } 95 | apiError.Send(c) 96 | return 97 | } 98 | 99 | claims := make([]gin.H, 0) 100 | 101 | for index, claimId := range claimIds { 102 | claim := claimCmds[index] 103 | 104 | if claim.Err() != nil { 105 | apiError := &common.ApiError{ 106 | InternalError: claim.Err(), 107 | ErrorCode: common.ErrorGettingClaim, 108 | StatusCode: 500, 109 | RequestId: requestid.Get(c), 110 | } 111 | apiError.Send(c) 112 | return 113 | } 114 | 115 | if len(claim.Val()) == 0 { 116 | // Connection doesn't exist 117 | continue 118 | } 119 | 120 | expirationTime, _ := time.Parse(time.RFC3339, claim.Val()["expiration"]) 121 | 122 | if expirationTime.Before(time.Now()) { 123 | // Ignore invalid times (would become 0) or expired claims 124 | return 125 | } 126 | 127 | claims = append(claims, formatClaim(claimId, claim.Val())) 128 | } 129 | 130 | // Get connection(s) 131 | if resolveOptions.Connection != "" { 132 | connection := redisClient.HGetAll("conn:" + resolveOptions.Connection) 133 | 134 | if connection.Err() != nil { 135 | apiError := common.ApiError{ 136 | InternalError: connection.Err(), 137 | StatusCode: 500, 138 | ErrorCode: common.ErrorGettingConnection, 139 | RequestId: requestid.Get(c), 140 | } 141 | apiError.Send(c) 142 | return 143 | } 144 | 145 | if len(connection.Val()) == 0 { 146 | // Connection doesn't exist 147 | c.AbortWithStatusJSON(200, map[string]interface{}{ 148 | "success": true, 149 | "connections": []interface{}{}, 150 | "claims": claims, 151 | }) 152 | return 153 | } 154 | 155 | c.AbortWithStatusJSON(200, map[string]interface{}{ 156 | "success": true, 157 | "connections": []gin.H{formatConnection(resolveOptions.Connection, connection.Val())}, 158 | "claims": claims, 159 | }) 160 | } else if resolveOptions.User != "" { 161 | user := redisClient.SMembers("user:" + resolveOptions.User) 162 | 163 | if user.Err() != nil { 164 | apiError := common.ApiError{ 165 | InternalError: user.Err(), 166 | StatusCode: 500, 167 | ErrorCode: common.ErrorGettingUser, 168 | RequestId: requestid.Get(c), 169 | } 170 | apiError.Send(c) 171 | return 172 | } 173 | 174 | if len(user.Val()) == 0 { 175 | // User doesn't exist 176 | c.AbortWithStatusJSON(200, map[string]interface{}{ 177 | "success": true, 178 | "connections": []interface{}{}, 179 | "claims": claims, 180 | }) 181 | return 182 | } 183 | 184 | var connectionCmds = make([]*redis.StringStringMapCmd, len(user.Val())) 185 | _, err = redisClient.Pipelined(func(pipeliner redis.Pipeliner) error { 186 | for index, connId := range user.Val() { 187 | connectionCmds[index] = pipeliner.HGetAll("conn:" + connId) 188 | } 189 | 190 | return nil 191 | }) 192 | 193 | if err != nil { 194 | apiError := &common.ApiError{ 195 | InternalError: err, 196 | StatusCode: 500, 197 | ErrorCode: common.ErrorGettingConnection, 198 | RequestId: requestid.Get(c), 199 | } 200 | apiError.Send(c) 201 | return 202 | } 203 | 204 | connections := make([]gin.H, 0) 205 | 206 | for index, connId := range user.Val() { 207 | connId := connId 208 | 209 | connection := connectionCmds[index] 210 | 211 | if connection.Err() != nil { 212 | apiError = &common.ApiError{ 213 | InternalError: connection.Err(), 214 | StatusCode: 500, 215 | ErrorCode: common.ErrorGettingConnection, 216 | RequestId: requestid.Get(c), 217 | } 218 | apiError.Send(c) 219 | 220 | return 221 | } 222 | 223 | if len(connection.Val()) == 0 { 224 | // Connection doesn't exist 225 | continue 226 | } 227 | 228 | // Target specific session(s) for user 229 | if resolveOptions.Session != "" && connection.Val()["session"] != resolveOptions.Session { 230 | return 231 | } 232 | 233 | connections = append(connections, formatConnection(connId, connection.Val())) 234 | } 235 | 236 | c.AbortWithStatusJSON(200, map[string]interface{}{ 237 | "success": true, 238 | "connections": connections, 239 | "claims": claims, 240 | }) 241 | } else if resolveOptions.Channel != "" { 242 | channel := redisClient.SMembers("channel:" + resolveOptions.Channel) 243 | 244 | if channel.Err() != nil { 245 | apiError := common.ApiError{ 246 | InternalError: channel.Err(), 247 | StatusCode: 500, 248 | ErrorCode: common.ErrorGettingChannel, 249 | RequestId: requestid.Get(c), 250 | } 251 | apiError.Send(c) 252 | return 253 | } 254 | 255 | if len(channel.Val()) == 0 { 256 | // User doesn't exist 257 | c.AbortWithStatusJSON(200, map[string]interface{}{ 258 | "success": true, 259 | "connections": []interface{}{}, 260 | "claims": claims, 261 | }) 262 | return 263 | } 264 | 265 | var connectionCmds = make([]*redis.StringStringMapCmd, len(channel.Val())) 266 | _, err = redisClient.Pipelined(func(pipeliner redis.Pipeliner) error { 267 | for index, connId := range channel.Val() { 268 | connectionCmds[index] = pipeliner.HGetAll("conn:" + connId) 269 | } 270 | 271 | return nil 272 | }) 273 | 274 | connections := make([]gin.H, 0) 275 | 276 | for index, connId := range channel.Val() { 277 | connection := connectionCmds[index] 278 | 279 | if connection.Err() != nil { 280 | apiError = &common.ApiError{ 281 | InternalError: connection.Err(), 282 | StatusCode: 500, 283 | ErrorCode: common.ErrorGettingConnection, 284 | RequestId: requestid.Get(c), 285 | } 286 | return 287 | } 288 | 289 | if len(connection.Val()) == 0 { 290 | // Connection doesn't exist 291 | return 292 | } 293 | 294 | connections = append(connections, formatConnection(connId, connection.Val())) 295 | } 296 | 297 | c.AbortWithStatusJSON(200, gin.H{ 298 | "success": true, 299 | "connections": connections, 300 | "claims": claims, 301 | }) 302 | } else { 303 | apiError := common.ApiError{ 304 | StatusCode: 400, 305 | ErrorCode: common.ErrorTarget, 306 | RequestId: requestid.Get(c), 307 | } 308 | apiError.Send(c) 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/Cretezy/dSock/common" 6 | "github.com/Cretezy/dSock/common/protos" 7 | "github.com/gin-gonic/gin" 8 | "github.com/go-redis/redis/v7" 9 | "github.com/google/uuid" 10 | "go.uber.org/zap" 11 | "net/http" 12 | "os" 13 | "os/signal" 14 | "syscall" 15 | "time" 16 | ) 17 | 18 | var apiId = uuid.New().String() 19 | 20 | var redisClient *redis.Client 21 | 22 | var options *common.DSockOptions 23 | var logger *zap.Logger 24 | 25 | func init() { 26 | var err error 27 | 28 | options, err = common.GetOptions(false) 29 | 30 | if err != nil { 31 | println("Could not get options. Make sure your config is valid!") 32 | panic(err) 33 | } 34 | 35 | if options.Debug { 36 | logger, err = zap.NewDevelopment() 37 | } else { 38 | logger, err = zap.NewProduction() 39 | } 40 | 41 | if err != nil { 42 | println("Could not create logger") 43 | panic(err) 44 | } 45 | } 46 | 47 | func main() { 48 | logger.Info("Starting dSock API", 49 | zap.String("version", common.DSockVersion), 50 | zap.String("apiId", apiId), 51 | zap.Int("port", options.Port), 52 | zap.String("DEPRECATED.address", options.Address), 53 | ) 54 | 55 | // Setup application 56 | redisClient = redis.NewClient(options.RedisOptions) 57 | 58 | _, err := redisClient.Ping().Result() 59 | if err != nil { 60 | logger.Error("Could not connect to Redis (ping)", 61 | zap.Error(err), 62 | ) 63 | } 64 | 65 | if options.Debug { 66 | gin.SetMode(gin.DebugMode) 67 | } else { 68 | gin.SetMode(gin.ReleaseMode) 69 | } 70 | 71 | router := common.NewGinEngine(logger, options) 72 | router.Use(common.RequestIdMiddleware) 73 | router.Use(common.TokenMiddleware(options.Token)) 74 | 75 | router.Any(common.PathPing, common.PingHandler) 76 | router.POST(common.PathSend, sendHandler) 77 | router.POST(common.PathDisconnect, disconnectHandler) 78 | router.POST(common.PathClaim, createClaimHandler) 79 | router.GET(common.PathInfo, infoHandler) 80 | router.POST(common.PathChannelSubscribe, getChannelHandler(protos.ChannelAction_SUBSCRIBE)) 81 | router.POST(common.PathChannelUnsubscribe, getChannelHandler(protos.ChannelAction_UNSUBSCRIBE)) 82 | 83 | // Start HTTP server 84 | srv := &http.Server{ 85 | Addr: options.Address, 86 | Handler: router, 87 | } 88 | 89 | go func() { 90 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 91 | logger.Error("Failed listening", 92 | zap.Error(err), 93 | zap.String("apiId", apiId), 94 | ) 95 | options.QuitChannel <- struct{}{} 96 | } 97 | }() 98 | 99 | logger.Info("Listening", 100 | zap.String("address", options.Address), 101 | ) 102 | 103 | signalQuit := make(chan os.Signal, 1) 104 | 105 | // Listen for signal or message in quit channel 106 | signal.Notify(signalQuit, syscall.SIGINT, syscall.SIGTERM) 107 | 108 | select { 109 | case <-options.QuitChannel: 110 | case <-signalQuit: 111 | } 112 | 113 | signalQuit = nil 114 | 115 | // Server shutdown 116 | logger.Info("Shutting down", 117 | zap.String("apiId", apiId), 118 | ) 119 | 120 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 121 | defer cancel() 122 | if err := srv.Shutdown(ctx); err != nil { 123 | logger.Error("Error during server shutdown", 124 | zap.Error(err), 125 | zap.String("apiId", apiId), 126 | ) 127 | } 128 | 129 | logger.Info("Stopped", 130 | zap.String("apiId", apiId), 131 | ) 132 | _ = logger.Sync() 133 | } 134 | -------------------------------------------------------------------------------- /api/resolve_claims.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Cretezy/dSock/common" 5 | ) 6 | 7 | func resolveClaims(options common.ResolveOptions, requestId string) ([]string, *common.ApiError) { 8 | if options.Session != "" { 9 | userSessionClaims := redisClient.SMembers("claim-user-session:" + options.User + "-" + options.Session) 10 | 11 | if userSessionClaims.Err() != nil { 12 | return nil, &common.ApiError{ 13 | InternalError: userSessionClaims.Err(), 14 | ErrorCode: common.ErrorGettingClaim, 15 | StatusCode: 500, 16 | RequestId: requestId, 17 | } 18 | } 19 | 20 | return userSessionClaims.Val(), nil 21 | } else if options.Channel != "" { 22 | channelClaims := redisClient.SMembers("claim-channel:" + options.Channel) 23 | 24 | if channelClaims.Err() != nil { 25 | return nil, &common.ApiError{ 26 | InternalError: channelClaims.Err(), 27 | ErrorCode: common.ErrorGettingClaim, 28 | StatusCode: 500, 29 | RequestId: requestId, 30 | } 31 | } 32 | 33 | return channelClaims.Val(), nil 34 | } else if options.User != "" { 35 | userClaims := redisClient.SMembers("claim-user:" + options.User) 36 | 37 | if userClaims.Err() != nil { 38 | return nil, &common.ApiError{ 39 | InternalError: userClaims.Err(), 40 | ErrorCode: common.ErrorGettingClaim, 41 | StatusCode: 500, 42 | RequestId: requestId, 43 | } 44 | } 45 | 46 | return userClaims.Val(), nil 47 | } else { 48 | return []string{}, nil 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /api/resolve_workers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Cretezy/dSock/common" 5 | "github.com/go-redis/redis/v7" 6 | ) 7 | 8 | /// Resolves the workers holding the connection 9 | func resolveWorkers(options common.ResolveOptions, requestId string) ([]string, *common.ApiError) { 10 | workerIds := make([]string, 0) 11 | 12 | if options.Connection != "" { 13 | // Get a connection by connection ID 14 | connection := redisClient.HGetAll("conn:" + options.Connection) 15 | 16 | if connection.Err() != nil { 17 | return nil, &common.ApiError{ 18 | InternalError: connection.Err(), 19 | StatusCode: 500, 20 | ErrorCode: common.ErrorGettingConnection, 21 | } 22 | } 23 | 24 | if len(connection.Val()) == 0 { 25 | // Connection doesn't exist 26 | return []string{}, nil 27 | } 28 | 29 | workerId, hasWorkerId := connection.Val()["workerId"] 30 | 31 | if !hasWorkerId { 32 | // Is missing worker ID, ignoring 33 | return []string{}, nil 34 | } 35 | 36 | workerIds = append(workerIds, workerId) 37 | } else if options.Channel != "" { 38 | // Get all connections for a channel 39 | channel := redisClient.SMembers("channel:" + options.Channel) 40 | 41 | if channel.Err() != nil { 42 | return nil, &common.ApiError{ 43 | InternalError: channel.Err(), 44 | StatusCode: 500, 45 | ErrorCode: common.ErrorGettingChannel, 46 | RequestId: requestId, 47 | } 48 | } 49 | 50 | if len(channel.Val()) == 0 { 51 | // User doesn't exist 52 | return []string{}, nil 53 | } 54 | 55 | var connectionCmds = make([]*redis.StringStringMapCmd, len(channel.Val())) 56 | _, err := redisClient.Pipelined(func(pipeliner redis.Pipeliner) error { 57 | for index, connId := range channel.Val() { 58 | connectionCmds[index] = pipeliner.HGetAll("conn:" + connId) 59 | } 60 | 61 | return nil 62 | }) 63 | 64 | if err != nil { 65 | return nil, &common.ApiError{ 66 | InternalError: err, 67 | StatusCode: 500, 68 | ErrorCode: common.ErrorGettingConnection, 69 | RequestId: requestId, 70 | } 71 | } 72 | 73 | // Resolves connection for each user connection 74 | for index := range channel.Val() { 75 | connection := connectionCmds[index] 76 | 77 | if connection.Err() != nil { 78 | return nil, &common.ApiError{ 79 | InternalError: connection.Err(), 80 | StatusCode: 500, 81 | ErrorCode: common.ErrorGettingConnection, 82 | RequestId: requestId, 83 | } 84 | } 85 | 86 | if len(connection.Val()) == 0 { 87 | // Connection doesn't exist 88 | continue 89 | } 90 | 91 | workerId, hasWorkerId := connection.Val()["workerId"] 92 | 93 | if !hasWorkerId { 94 | // Is missing worker ID, ignoring 95 | continue 96 | } 97 | 98 | workerIds = append(workerIds, workerId) 99 | } 100 | 101 | } else if options.User != "" { 102 | // Get all connections for a user (optionally filtered by session) 103 | user := redisClient.SMembers("user:" + options.User) 104 | 105 | if user.Err() != nil { 106 | return nil, &common.ApiError{ 107 | InternalError: user.Err(), 108 | StatusCode: 500, 109 | ErrorCode: common.ErrorGettingUser, 110 | RequestId: requestId, 111 | } 112 | } 113 | 114 | if len(user.Val()) == 0 { 115 | // User doesn't exist 116 | return []string{}, nil 117 | } 118 | 119 | var connectionCmds = make([]*redis.StringStringMapCmd, len(user.Val())) 120 | _, err := redisClient.Pipelined(func(pipeliner redis.Pipeliner) error { 121 | for index, connId := range user.Val() { 122 | connectionCmds[index] = pipeliner.HGetAll("conn:" + connId) 123 | } 124 | 125 | return nil 126 | }) 127 | 128 | if err != nil { 129 | return nil, &common.ApiError{ 130 | InternalError: err, 131 | StatusCode: 500, 132 | ErrorCode: common.ErrorGettingConnection, 133 | RequestId: requestId, 134 | } 135 | } 136 | 137 | // Resolves connection for each user connection 138 | for index := range user.Val() { 139 | connection := connectionCmds[index] 140 | 141 | if connection.Err() != nil { 142 | return nil, &common.ApiError{ 143 | InternalError: connection.Err(), 144 | StatusCode: 500, 145 | ErrorCode: common.ErrorGettingConnection, 146 | RequestId: requestId, 147 | } 148 | } 149 | 150 | if len(connection.Val()) == 0 { 151 | // Connection doesn't exist 152 | continue 153 | } 154 | 155 | workerId, hasWorkerId := connection.Val()["workerId"] 156 | 157 | if !hasWorkerId { 158 | // Is missing worker ID, ignoring 159 | continue 160 | } 161 | 162 | // Target specific session(s) for user if set 163 | if options.Session != "" && connection.Val()["session"] != options.Session { 164 | continue 165 | } 166 | 167 | workerIds = append(workerIds, workerId) 168 | } 169 | 170 | } else { 171 | // No targeting options where provided 172 | return nil, &common.ApiError{ 173 | StatusCode: 400, 174 | ErrorCode: common.ErrorTarget, 175 | RequestId: requestId, 176 | } 177 | } 178 | 179 | return common.RemoveEmpty(common.UniqueString(workerIds)), nil 180 | } 181 | -------------------------------------------------------------------------------- /api/send_handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Cretezy/dSock/common" 5 | "github.com/Cretezy/dSock/common/protos" 6 | "github.com/gin-contrib/requestid" 7 | "github.com/gin-gonic/gin" 8 | "go.uber.org/zap" 9 | "io/ioutil" 10 | ) 11 | 12 | func sendHandler(c *gin.Context) { 13 | logger.Info("Getting send request", 14 | zap.String("requestId", requestid.Get(c)), 15 | zap.String("id", c.Query("id")), 16 | zap.String("user", c.Query("user")), 17 | zap.String("session", c.Query("session")), 18 | zap.String("channel", c.Query("channel")), 19 | ) 20 | 21 | resolveOptions := common.ResolveOptions{} 22 | 23 | err := c.BindQuery(&resolveOptions) 24 | if err != nil { 25 | apiError := &common.ApiError{ 26 | InternalError: err, 27 | ErrorCode: common.ErrorBindingQueryParams, 28 | StatusCode: 400, 29 | RequestId: requestid.Get(c), 30 | } 31 | apiError.Send(c) 32 | return 33 | } 34 | 35 | // Get all worker IDs that the target(s) is connected to 36 | workerIds, apiError := resolveWorkers(resolveOptions, requestid.Get(c)) 37 | if apiError != nil { 38 | apiError.Send(c) 39 | return 40 | } 41 | 42 | parsedMessageType := ParseMessageType(c.Query("type")) 43 | 44 | if parsedMessageType == -1 { 45 | apiError := common.ApiError{ 46 | StatusCode: 400, 47 | ErrorCode: common.ErrorInvalidMessageType, 48 | RequestId: requestid.Get(c), 49 | } 50 | apiError.Send(c) 51 | return 52 | } 53 | 54 | // Read full body (message data) 55 | body, err := ioutil.ReadAll(c.Request.Body) 56 | 57 | if err != nil { 58 | apiError := common.ApiError{ 59 | InternalError: err, 60 | StatusCode: 500, 61 | ErrorCode: common.ErrorReadingMessage, 62 | RequestId: requestid.Get(c), 63 | } 64 | apiError.Send(c) 65 | return 66 | } 67 | 68 | // Prepare message for worker 69 | message := &protos.Message{ 70 | Type: parsedMessageType, 71 | Body: body, 72 | Target: &protos.Target{ 73 | Connection: resolveOptions.Connection, 74 | User: resolveOptions.User, 75 | Session: resolveOptions.Session, 76 | Channel: resolveOptions.Channel, 77 | }, 78 | } 79 | 80 | // Send to all workers 81 | apiError = sendToWorkers(workerIds, message, MessageMessageType, requestid.Get(c)) 82 | if apiError != nil { 83 | apiError.Send(c) 84 | return 85 | } 86 | 87 | logger.Info("Sent message", 88 | zap.String("requestId", requestid.Get(c)), 89 | zap.Strings("workerIds", workerIds), 90 | zap.String("id", resolveOptions.Connection), 91 | zap.String("user", resolveOptions.User), 92 | zap.String("session", resolveOptions.Session), 93 | zap.String("channel", resolveOptions.Channel), 94 | zap.Int("bodyLength", len(body)), 95 | ) 96 | 97 | c.AbortWithStatusJSON(200, map[string]interface{}{ 98 | "success": true, 99 | }) 100 | } 101 | 102 | /// Parse message type, allowing for WebSocket frame type ID 103 | func ParseMessageType(messageType string) protos.Message_MessageType { 104 | switch messageType { 105 | case "text": 106 | fallthrough 107 | case "1": 108 | return protos.Message_TEXT 109 | case "binary": 110 | fallthrough 111 | case "2": 112 | return protos.Message_BINARY 113 | } 114 | 115 | return -1 116 | } 117 | -------------------------------------------------------------------------------- /api/send_to_workers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "github.com/Cretezy/dSock/common" 7 | "github.com/go-redis/redis/v7" 8 | "go.uber.org/zap" 9 | "google.golang.org/protobuf/proto" 10 | "net/http" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | const ( 16 | MessageMessageType = "message" 17 | ChannelMessageType = "channel" 18 | ) 19 | 20 | func sendToWorkers(workerIds []string, message proto.Message, messageType string, requestId string) *common.ApiError { 21 | rawMessage, err := proto.Marshal(message) 22 | 23 | if err != nil { 24 | return &common.ApiError{ 25 | InternalError: err, 26 | ErrorCode: common.ErrorMarshallingMessage, 27 | StatusCode: 500, 28 | RequestId: requestId, 29 | } 30 | } 31 | 32 | errs := make([]error, 0) 33 | errsLock := sync.Mutex{} 34 | 35 | addError := func(err error) { 36 | errsLock.Lock() 37 | defer errsLock.Lock() 38 | 39 | errs = append(errs, err) 40 | } 41 | 42 | if options.MessagingMethod == common.MessageMethodRedis { 43 | _, err := redisClient.Pipelined(func(pipeliner redis.Pipeliner) error { 44 | for _, workerId := range workerIds { 45 | redisChannel := workerId 46 | if messageType == ChannelMessageType { 47 | redisChannel = redisChannel + ":channel" 48 | } 49 | 50 | logger.Info("Publishing to worker", 51 | zap.String("requestId", requestId), 52 | zap.String("workerId", workerId), 53 | zap.String("messageType", messageType), 54 | zap.String("redisChannel", redisChannel), 55 | ) 56 | 57 | pipeliner.Publish(redisChannel, rawMessage) 58 | } 59 | 60 | return nil 61 | }) 62 | 63 | if err != nil { 64 | return &common.ApiError{ 65 | InternalError: err, 66 | ErrorCode: common.ErrorDeliveringMessage, 67 | StatusCode: 500, 68 | RequestId: requestId, 69 | } 70 | } 71 | 72 | } else { 73 | var workerCmds = make([]*redis.StringStringMapCmd, len(workerIds)) 74 | _, err := redisClient.Pipelined(func(pipeliner redis.Pipeliner) error { 75 | for index, workerId := range workerIds { 76 | workerCmds[index] = pipeliner.HGetAll("worker:" + workerId) 77 | } 78 | 79 | return nil 80 | }) 81 | 82 | if err != nil { 83 | return &common.ApiError{ 84 | InternalError: err, 85 | ErrorCode: common.ErrorDeliveringMessage, 86 | StatusCode: 500, 87 | RequestId: requestId, 88 | } 89 | } 90 | 91 | var workersWaitGroup sync.WaitGroup 92 | workersWaitGroup.Add(len(workerIds)) 93 | 94 | for index, workerId := range workerIds { 95 | index := index 96 | workerId := workerId 97 | 98 | go func() { 99 | defer workersWaitGroup.Done() 100 | 101 | worker := workerCmds[index] 102 | 103 | if len(worker.Val()) == 0 { 104 | logger.Error("Found empty worker in Redis", 105 | zap.String("requestId", requestId), 106 | zap.String("workerId", workerId), 107 | ) 108 | return 109 | } 110 | 111 | ip := worker.Val()["ip"] 112 | 113 | if ip == "" { 114 | logger.Error("Found worker with no IP in Redis (is the worker not configured for direct access?)", 115 | zap.String("requestId", requestId), 116 | zap.String("workerId", workerId), 117 | ) 118 | return 119 | } 120 | 121 | path := common.PathReceiveMessage 122 | if messageType == ChannelMessageType { 123 | path = common.PathReceiveChannelMessage 124 | } 125 | 126 | url := "http://" + ip + path 127 | 128 | logger.Info("Starting request to worker", 129 | zap.String("requestId", requestId), 130 | zap.String("workerId", workerId), 131 | zap.String("messageType", messageType), 132 | zap.String("url", url), 133 | ) 134 | 135 | // Non-standardized Content-Type used here. 136 | // Future: Add HTTPS options? Maybe store full direct path in Redis instead of "ip", 137 | // which here is hostname + port. 138 | // Also, no error can be returned on the worker side, so only 200 can be handled. 139 | req, err := http.NewRequest("POST", url, bytes.NewReader(rawMessage)) 140 | if err != nil { 141 | logger.Error("Could create request", 142 | zap.String("requestId", requestId), 143 | zap.String("workerId", workerId), 144 | zap.String("url", url), 145 | zap.String("messageType", messageType), 146 | zap.Error(err), 147 | ) 148 | addError(&common.ApiError{ 149 | InternalError: worker.Err(), 150 | StatusCode: 500, 151 | ErrorCode: common.ErrorReachingWorker, 152 | }) 153 | return 154 | } 155 | 156 | req.Header.Set("Content-Type", common.ProtobufContentType) 157 | req.Header.Set("X-Request-ID", requestId) 158 | 159 | beforeRequestTime := time.Now() 160 | resp, err := http.DefaultClient.Do(req) 161 | requestTime := time.Now().Sub(beforeRequestTime) 162 | 163 | if err != nil { 164 | logger.Error("Could not reach worker", 165 | zap.String("requestId", requestId), 166 | zap.String("workerId", workerId), 167 | zap.String("url", url), 168 | zap.String("messageType", messageType), 169 | zap.Duration("requestTime", requestTime), 170 | zap.Error(err), 171 | ) 172 | addError(&common.ApiError{ 173 | InternalError: worker.Err(), 174 | StatusCode: 500, 175 | ErrorCode: common.ErrorReachingWorker, 176 | }) 177 | return 178 | } 179 | 180 | if resp.StatusCode != 200 { 181 | logger.Error("Worker could not handle request", 182 | zap.String("requestId", requestId), 183 | zap.String("workerId", workerId), 184 | zap.String("url", url), 185 | zap.String("messageType", messageType), 186 | zap.Int("statusCode", resp.StatusCode), 187 | zap.String("status", resp.Status), 188 | zap.Duration("requestTime", requestTime), 189 | ) 190 | addError(&common.ApiError{ 191 | InternalError: worker.Err(), 192 | StatusCode: 500, 193 | ErrorCode: common.ErrorReachingWorker, 194 | }) 195 | } 196 | 197 | logger.Info("Finished request to worker", 198 | zap.String("requestId", requestId), 199 | zap.String("workerId", workerId), 200 | zap.String("url", url), 201 | zap.String("messageType", messageType), 202 | zap.Int("statusCode", resp.StatusCode), 203 | zap.String("status", resp.Status), 204 | zap.Duration("requestTime", requestTime), 205 | ) 206 | }() 207 | } 208 | 209 | workersWaitGroup.Wait() 210 | 211 | if len(errs) > 0 { 212 | errorMessage := "" 213 | for index, err := range errs { 214 | errorMessage = errorMessage + err.Error() 215 | if index+1 != len(errs) { 216 | errorMessage = errorMessage + ", " 217 | } 218 | } 219 | 220 | return &common.ApiError{ 221 | InternalError: errors.New(errorMessage), 222 | ErrorCode: common.ErrorDeliveringMessage, 223 | StatusCode: 500, 224 | RequestId: requestId, 225 | } 226 | } 227 | } 228 | 229 | return nil 230 | } 231 | -------------------------------------------------------------------------------- /common/constants.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | const ( 4 | PathPing = "/ping" 5 | PathSend = "/send" 6 | PathConnect = "/connect" 7 | PathClaim = "/claim" 8 | PathInfo = "/info" 9 | PathDisconnect = "/disconnect" 10 | PathChannelSubscribe = "/channel/subscribe/:channel" 11 | PathChannelUnsubscribe = "/channel/unsubscribe/:channel" 12 | PathReceiveMessage = "/_/message" 13 | PathReceiveChannelMessage = "/_/message/channel" 14 | ) 15 | 16 | const ProtobufContentType = "application/protobuf" 17 | -------------------------------------------------------------------------------- /common/errors.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | const ( 8 | ErrorUserIdRequired = "USER_ID_REQUIRED" 9 | ErrorInvalidExpiration = "INVALID_EXPIRATION" 10 | ErrorNegativeExpiration = "NEGATIVE_EXPIRATION" 11 | ErrorInvalidDuration = "INVALID_DURATION" 12 | ErrorNegativeDuration = "NEGATIVE_DURATION" 13 | ErrorGettingConnection = "ERROR_GETTING_CONNECTION" 14 | ErrorGettingUser = "ERROR_GETTING_USER" 15 | ErrorGettingChannel = "ERROR_GETTING_CHANNEL" 16 | ErrorTarget = "MISSING_TARGET" 17 | ErrorInvalidAuthorization = "INVALID_AUTHORIZATION" 18 | ErrorMissingAuthentication = "MISSING_AUTHENTICATION" 19 | ErrorInvalidJwt = "INVALID_JWT" 20 | ErrorClaimIdAlreadyUsed = "CLAIM_ID_ALREADY_USED" 21 | ErrorCheckingClaim = "ERROR_CHECKING_CLAIM" 22 | ErrorGettingClaim = "ERROR_GETTING_CLAIM" 23 | ErrorMissingClaim = "MISSING_CLAIM" 24 | ErrorExpiredClaim = "EXPIRED_CLAIM" 25 | ErrorReadingMessage = "ERROR_READING_MESSAGE" 26 | ErrorMarshallingMessage = "ERROR_MARSHALLING_MESSAGE" 27 | ErrorInvalidMessageType = "INVALID_MESSAGE_TYPE" 28 | ErrorBindingQueryParams = "ERROR_BINDING_QUERY_PARAMS" 29 | ErrorGettingWorker = "ERROR_GETTING_WORKER" 30 | ErrorReachingWorker = "ERROR_REACHING_WORKER" 31 | ErrorDeliveringMessage = "ERROR_DELIVERING_MESSAGING" 32 | ErrorInvalidContentType = "INVALID_CONTENT_TYPE" 33 | ErrorReadingBody = "ERROR_READING_BODY" 34 | ) 35 | 36 | var ErrorMessages = map[string]string{ 37 | ErrorUserIdRequired: "User ID is required", 38 | ErrorInvalidExpiration: "Error parsing expiration (must be a integer)", 39 | ErrorNegativeExpiration: "Can not use 0 or negative expiration", 40 | ErrorInvalidDuration: "Could not parse duration (must be a integer)", 41 | ErrorNegativeDuration: "Can not use 0 or negative duration", 42 | ErrorGettingConnection: "Error getting connection", 43 | ErrorGettingUser: "Error getting user", 44 | ErrorGettingChannel: "Error getting channel", 45 | ErrorTarget: "Missing target", 46 | ErrorInvalidAuthorization: "Invalid authorization", 47 | ErrorMissingAuthentication: "Did not provide an authentication method", 48 | ErrorInvalidJwt: "Could not validate JWT", 49 | ErrorClaimIdAlreadyUsed: "Claim ID is already used", 50 | ErrorCheckingClaim: "Error checking if claim already exists", 51 | ErrorGettingClaim: "Error getting claim", 52 | ErrorMissingClaim: "Could not find claim", 53 | ErrorExpiredClaim: "Claim has expired", 54 | ErrorReadingMessage: "Error reading message", 55 | ErrorMarshallingMessage: "Error marshalling message", 56 | ErrorInvalidMessageType: "Invalid message type, must be text or binary", 57 | ErrorBindingQueryParams: "Error binding query parameters", 58 | ErrorGettingWorker: "Error getting worker", 59 | ErrorReachingWorker: "Error reaching worker", 60 | ErrorDeliveringMessage: "Error delivering message", 61 | ErrorInvalidContentType: "Invalid Content-Type", 62 | ErrorReadingBody: "Error reading body", 63 | } 64 | 65 | type ApiError struct { 66 | /// Wrapped error. Can be nil if application/validation error 67 | InternalError error 68 | /// Error code. Used to take error message from ErrorMessages 69 | ErrorCode string 70 | /// If set, overrides error message from ErrorCode 71 | CustomErrorMessage string 72 | /// HTTP status code 73 | StatusCode int 74 | /// Request ID 75 | RequestId string 76 | } 77 | 78 | func (apiError *ApiError) Error() string { 79 | if apiError.InternalError != nil { 80 | return apiError.ErrorCode 81 | } 82 | 83 | return apiError.ErrorCode + ": " + apiError.InternalError.Error() 84 | } 85 | 86 | func (apiError *ApiError) Format() (int, gin.H) { 87 | statusCode := apiError.StatusCode 88 | if statusCode == 0 { 89 | statusCode = 500 90 | } 91 | 92 | errorMessage := ErrorMessages[apiError.ErrorCode] 93 | 94 | if apiError.CustomErrorMessage != "" { 95 | errorMessage = apiError.CustomErrorMessage 96 | } 97 | 98 | if errorMessage == "" { 99 | // Default message if unknown error code 100 | errorMessage = apiError.ErrorCode 101 | } 102 | 103 | response := gin.H{ 104 | "success": false, 105 | "errorCode": apiError.ErrorCode, 106 | "error": errorMessage, 107 | } 108 | 109 | if apiError.RequestId != "" { 110 | response["requestId"] = apiError.RequestId 111 | } 112 | 113 | return statusCode, response 114 | } 115 | 116 | func (apiError *ApiError) Send(c *gin.Context) { 117 | c.AbortWithStatusJSON(apiError.Format()) 118 | } 119 | -------------------------------------------------------------------------------- /common/errors_test.go: -------------------------------------------------------------------------------- 1 | package common_test 2 | 3 | import ( 4 | "github.com/Cretezy/dSock/common" 5 | "github.com/stretchr/testify/suite" 6 | "testing" 7 | ) 8 | 9 | type ApiErrorSuite struct { 10 | suite.Suite 11 | } 12 | 13 | func TestApiErrorSuite(t *testing.T) { 14 | suite.Run(t, new(ApiErrorSuite)) 15 | } 16 | 17 | func (suite *ApiErrorSuite) TestFormat() { 18 | apiError := common.ApiError{ 19 | StatusCode: 400, 20 | ErrorCode: common.ErrorInvalidAuthorization, 21 | RequestId: "some-request-id", 22 | } 23 | 24 | statusCode, body := apiError.Format() 25 | 26 | if !suite.Equal(400, statusCode, "Incorrect status code") { 27 | return 28 | } 29 | 30 | if !suite.Equal(common.ErrorInvalidAuthorization, body["errorCode"], "Incorrect error code") { 31 | return 32 | } 33 | 34 | if !suite.Equal("Invalid authorization", body["error"], "Incorrect error message") { 35 | return 36 | } 37 | 38 | if !suite.Equal("some-request-id", body["requestId"], "Incorrect request ID") { 39 | return 40 | } 41 | } 42 | 43 | func (suite *ApiErrorSuite) TestFormatNoStatusCode() { 44 | apiError := common.ApiError{ 45 | ErrorCode: common.ErrorInvalidAuthorization, 46 | } 47 | 48 | statusCode, _ := apiError.Format() 49 | 50 | if !suite.Equal(500, statusCode, "Incorrect status code") { 51 | return 52 | } 53 | } 54 | 55 | func (suite *ApiErrorSuite) TestFormatCustomErrorMessage() { 56 | apiError := common.ApiError{ 57 | ErrorCode: common.ErrorInvalidAuthorization, 58 | CustomErrorMessage: "Custom Error", 59 | } 60 | 61 | _, body := apiError.Format() 62 | 63 | if !suite.Equal(common.ErrorInvalidAuthorization, body["errorCode"], "Incorrect error code") { 64 | return 65 | } 66 | 67 | if !suite.Equal("Custom Error", body["error"], "Incorrect error message") { 68 | return 69 | } 70 | } 71 | 72 | func (suite *ApiErrorSuite) TestFormatNoErrorMessage() { 73 | apiError := common.ApiError{ 74 | ErrorCode: "_OTHER_ERROR", 75 | } 76 | 77 | _, body := apiError.Format() 78 | 79 | if !suite.Equal("_OTHER_ERROR", body["errorCode"], "Incorrect error code") { 80 | return 81 | } 82 | 83 | if !suite.Equal("_OTHER_ERROR", body["error"], "Incorrect error message") { 84 | return 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /common/gin.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "go.uber.org/zap" 6 | "time" 7 | ) 8 | import "github.com/gin-contrib/zap" 9 | 10 | func NewGinEngine(logger *zap.Logger, options *DSockOptions) *gin.Engine { 11 | engine := gin.New() 12 | if options.LogRequests { 13 | engine.Use(ginzap.Ginzap(logger, time.RFC3339, true)) 14 | } 15 | engine.Use(ginzap.RecoveryWithZap(logger, options.Debug)) 16 | return engine 17 | } 18 | -------------------------------------------------------------------------------- /common/options.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "crypto/tls" 5 | "errors" 6 | "github.com/go-redis/redis/v7" 7 | "github.com/spf13/viper" 8 | "net" 9 | "os" 10 | "strconv" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | const MessageMethodRedis = "redis" 16 | const MessageMethodDirect = "direct" 17 | 18 | type JwtOptions struct { 19 | JwtSecret string 20 | } 21 | 22 | type DSockOptions struct { 23 | RedisOptions *redis.Options 24 | Address string 25 | Port int 26 | QuitChannel chan struct{} 27 | Debug bool 28 | LogRequests bool 29 | /// Token for your API -> dSock and between dSock services 30 | Token string 31 | /// JWT parsing/verifying options 32 | Jwt JwtOptions 33 | /// Default channels to subscribe on join 34 | DefaultChannels []string 35 | /// The message method between the API to the worker 36 | MessagingMethod string 37 | /// The worker hostname 38 | DirectHostname string 39 | /// The worker port 40 | DirectPort int 41 | /// Interval for refreshing expiring data 42 | TtlDuration time.Duration 43 | } 44 | 45 | func SetupConfig() error { 46 | viper.SetConfigName("config") 47 | viper.SetEnvPrefix("DSOCK") 48 | viper.AutomaticEnv() 49 | 50 | viper.AddConfigPath(".") 51 | viper.AddConfigPath("$HOME/.config/dsock") 52 | viper.AddConfigPath("/etc/dsock") 53 | 54 | viper.SetDefault("redis_host", "localhost:6379") 55 | viper.SetDefault("redis_password", "") 56 | viper.SetDefault("redis_db", 0) 57 | viper.SetDefault("redis_max_retries", 10) 58 | viper.SetDefault("redis_tls", false) 59 | viper.SetDefault("port", 6241) 60 | viper.SetDefault("default_channels", "") 61 | viper.SetDefault("token", "") 62 | viper.SetDefault("jwt_secret", "") 63 | viper.SetDefault("debug", false) 64 | viper.SetDefault("log_requests", false) 65 | viper.SetDefault("messaging_method", "redis") 66 | viper.SetDefault("direct_message_hostname", "") 67 | viper.SetDefault("direct_message_port", "") 68 | viper.SetDefault("ttl_duration", "60s") 69 | 70 | err := viper.ReadInConfig() 71 | 72 | if err != nil { 73 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 74 | err = viper.SafeWriteConfigAs("config.toml") 75 | 76 | if err != nil { 77 | return err 78 | } 79 | } else { 80 | return err 81 | } 82 | } 83 | 84 | return nil 85 | } 86 | 87 | func GetOptions(worker bool) (*DSockOptions, error) { 88 | err := SetupConfig() 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | port := viper.GetInt("port") 94 | 95 | if os.Getenv("PORT") != "" { 96 | port, err = strconv.Atoi(os.Getenv("PORT")) 97 | 98 | if err != nil { 99 | return nil, errors.New("invalid port: could not parse integer") 100 | } 101 | } 102 | 103 | address := ":" + strconv.Itoa(port) 104 | 105 | if viper.IsSet("address") { 106 | println("DEPRECATED: address is deprecated, use port") 107 | address = viper.GetString("address") 108 | } 109 | 110 | redisOptions := redis.Options{ 111 | Addr: viper.GetString("redis_host"), 112 | Password: viper.GetString("redis_password"), 113 | DB: viper.GetInt("redis_db"), 114 | MaxRetries: viper.GetInt("redis_max_retries"), 115 | } 116 | 117 | if viper.GetBool("redis_tls") { 118 | redisOptions.TLSConfig = &tls.Config{} 119 | } 120 | 121 | ttlDuration, err := time.ParseDuration(viper.GetString("ttl_duration")) 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | directHostname := GetLocalIP() 127 | directPort := port 128 | messagingMethod := viper.GetString("messaging_method") 129 | 130 | if messagingMethod == MessageMethodRedis { 131 | // OK 132 | } else if messagingMethod == MessageMethodDirect { 133 | if worker { 134 | if viper.IsSet("direct_message_hostname") { 135 | directHostname = viper.GetString("direct_message_hostname") 136 | } 137 | 138 | if viper.IsSet("direct_message_port") { 139 | directPort = viper.GetInt("direct_message_port") 140 | } 141 | } 142 | } else { 143 | return nil, errors.New("invalid messaging method") 144 | } 145 | 146 | return &DSockOptions{ 147 | Debug: viper.GetBool("debug"), 148 | LogRequests: viper.GetBool("log_requests"), 149 | RedisOptions: &redisOptions, 150 | Address: address, 151 | Token: viper.GetString("token"), 152 | QuitChannel: make(chan struct{}, 0), 153 | Jwt: JwtOptions{ 154 | JwtSecret: viper.GetString("jwt_secret"), 155 | }, 156 | DefaultChannels: UniqueString(RemoveEmpty( 157 | strings.Split(viper.GetString("default_channels"), ","), 158 | )), 159 | MessagingMethod: messagingMethod, 160 | DirectHostname: directHostname, 161 | DirectPort: directPort, 162 | Port: port, 163 | TtlDuration: ttlDuration, 164 | }, nil 165 | } 166 | 167 | func GetLocalIP() string { 168 | addrs, err := net.InterfaceAddrs() 169 | if err != nil { 170 | return "" 171 | } 172 | for _, address := range addrs { 173 | // check the address type and if it is not a loopback the display it 174 | if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { 175 | if ipnet.IP.To4() != nil { 176 | return ipnet.IP.String() 177 | } 178 | } 179 | } 180 | return "" 181 | } 182 | -------------------------------------------------------------------------------- /common/ping_handler.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | func PingHandler(c *gin.Context) { 8 | c.String(200, "pong") 9 | } 10 | -------------------------------------------------------------------------------- /common/protos/message.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.21.0 4 | // protoc v3.6.1 5 | // source: message.proto 6 | 7 | package protos 8 | 9 | import ( 10 | proto "github.com/golang/protobuf/proto" 11 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 12 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 13 | reflect "reflect" 14 | sync "sync" 15 | ) 16 | 17 | const ( 18 | // Verify that this generated code is sufficiently up-to-date. 19 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 20 | // Verify that runtime/protoimpl is sufficiently up-to-date. 21 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 22 | ) 23 | 24 | // This is a compile-time assertion that a sufficiently up-to-date version 25 | // of the legacy proto package is being used. 26 | const _ = proto.ProtoPackageIsVersion4 27 | 28 | type Message_MessageType int32 29 | 30 | const ( 31 | Message_DISCONNECT Message_MessageType = 0 32 | // Must match RFC 6455, section 11.8 33 | Message_TEXT Message_MessageType = 1 34 | Message_BINARY Message_MessageType = 2 35 | ) 36 | 37 | // Enum value maps for Message_MessageType. 38 | var ( 39 | Message_MessageType_name = map[int32]string{ 40 | 0: "DISCONNECT", 41 | 1: "TEXT", 42 | 2: "BINARY", 43 | } 44 | Message_MessageType_value = map[string]int32{ 45 | "DISCONNECT": 0, 46 | "TEXT": 1, 47 | "BINARY": 2, 48 | } 49 | ) 50 | 51 | func (x Message_MessageType) Enum() *Message_MessageType { 52 | p := new(Message_MessageType) 53 | *p = x 54 | return p 55 | } 56 | 57 | func (x Message_MessageType) String() string { 58 | return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) 59 | } 60 | 61 | func (Message_MessageType) Descriptor() protoreflect.EnumDescriptor { 62 | return file_message_proto_enumTypes[0].Descriptor() 63 | } 64 | 65 | func (Message_MessageType) Type() protoreflect.EnumType { 66 | return &file_message_proto_enumTypes[0] 67 | } 68 | 69 | func (x Message_MessageType) Number() protoreflect.EnumNumber { 70 | return protoreflect.EnumNumber(x) 71 | } 72 | 73 | // Deprecated: Use Message_MessageType.Descriptor instead. 74 | func (Message_MessageType) EnumDescriptor() ([]byte, []int) { 75 | return file_message_proto_rawDescGZIP(), []int{1, 0} 76 | } 77 | 78 | type ChannelAction_ChannelActionType int32 79 | 80 | const ( 81 | ChannelAction_SUBSCRIBE ChannelAction_ChannelActionType = 0 82 | ChannelAction_UNSUBSCRIBE ChannelAction_ChannelActionType = 2 83 | ) 84 | 85 | // Enum value maps for ChannelAction_ChannelActionType. 86 | var ( 87 | ChannelAction_ChannelActionType_name = map[int32]string{ 88 | 0: "SUBSCRIBE", 89 | 2: "UNSUBSCRIBE", 90 | } 91 | ChannelAction_ChannelActionType_value = map[string]int32{ 92 | "SUBSCRIBE": 0, 93 | "UNSUBSCRIBE": 2, 94 | } 95 | ) 96 | 97 | func (x ChannelAction_ChannelActionType) Enum() *ChannelAction_ChannelActionType { 98 | p := new(ChannelAction_ChannelActionType) 99 | *p = x 100 | return p 101 | } 102 | 103 | func (x ChannelAction_ChannelActionType) String() string { 104 | return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) 105 | } 106 | 107 | func (ChannelAction_ChannelActionType) Descriptor() protoreflect.EnumDescriptor { 108 | return file_message_proto_enumTypes[1].Descriptor() 109 | } 110 | 111 | func (ChannelAction_ChannelActionType) Type() protoreflect.EnumType { 112 | return &file_message_proto_enumTypes[1] 113 | } 114 | 115 | func (x ChannelAction_ChannelActionType) Number() protoreflect.EnumNumber { 116 | return protoreflect.EnumNumber(x) 117 | } 118 | 119 | // Deprecated: Use ChannelAction_ChannelActionType.Descriptor instead. 120 | func (ChannelAction_ChannelActionType) EnumDescriptor() ([]byte, []int) { 121 | return file_message_proto_rawDescGZIP(), []int{2, 0} 122 | } 123 | 124 | type Target struct { 125 | state protoimpl.MessageState 126 | sizeCache protoimpl.SizeCache 127 | unknownFields protoimpl.UnknownFields 128 | 129 | Connection string `protobuf:"bytes,1,opt,name=connection,proto3" json:"connection,omitempty"` 130 | User string `protobuf:"bytes,2,opt,name=user,proto3" json:"user,omitempty"` 131 | Session string `protobuf:"bytes,3,opt,name=session,proto3" json:"session,omitempty"` 132 | Channel string `protobuf:"bytes,4,opt,name=channel,proto3" json:"channel,omitempty"` 133 | } 134 | 135 | func (x *Target) Reset() { 136 | *x = Target{} 137 | if protoimpl.UnsafeEnabled { 138 | mi := &file_message_proto_msgTypes[0] 139 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 140 | ms.StoreMessageInfo(mi) 141 | } 142 | } 143 | 144 | func (x *Target) String() string { 145 | return protoimpl.X.MessageStringOf(x) 146 | } 147 | 148 | func (*Target) ProtoMessage() {} 149 | 150 | func (x *Target) ProtoReflect() protoreflect.Message { 151 | mi := &file_message_proto_msgTypes[0] 152 | if protoimpl.UnsafeEnabled && x != nil { 153 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 154 | if ms.LoadMessageInfo() == nil { 155 | ms.StoreMessageInfo(mi) 156 | } 157 | return ms 158 | } 159 | return mi.MessageOf(x) 160 | } 161 | 162 | // Deprecated: Use Target.ProtoReflect.Descriptor instead. 163 | func (*Target) Descriptor() ([]byte, []int) { 164 | return file_message_proto_rawDescGZIP(), []int{0} 165 | } 166 | 167 | func (x *Target) GetConnection() string { 168 | if x != nil { 169 | return x.Connection 170 | } 171 | return "" 172 | } 173 | 174 | func (x *Target) GetUser() string { 175 | if x != nil { 176 | return x.User 177 | } 178 | return "" 179 | } 180 | 181 | func (x *Target) GetSession() string { 182 | if x != nil { 183 | return x.Session 184 | } 185 | return "" 186 | } 187 | 188 | func (x *Target) GetChannel() string { 189 | if x != nil { 190 | return x.Channel 191 | } 192 | return "" 193 | } 194 | 195 | type Message struct { 196 | state protoimpl.MessageState 197 | sizeCache protoimpl.SizeCache 198 | unknownFields protoimpl.UnknownFields 199 | 200 | Type Message_MessageType `protobuf:"varint,1,opt,name=type,proto3,enum=Message_MessageType" json:"type,omitempty"` 201 | Body []byte `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` 202 | Target *Target `protobuf:"bytes,3,opt,name=target,proto3" json:"target,omitempty"` 203 | } 204 | 205 | func (x *Message) Reset() { 206 | *x = Message{} 207 | if protoimpl.UnsafeEnabled { 208 | mi := &file_message_proto_msgTypes[1] 209 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 210 | ms.StoreMessageInfo(mi) 211 | } 212 | } 213 | 214 | func (x *Message) String() string { 215 | return protoimpl.X.MessageStringOf(x) 216 | } 217 | 218 | func (*Message) ProtoMessage() {} 219 | 220 | func (x *Message) ProtoReflect() protoreflect.Message { 221 | mi := &file_message_proto_msgTypes[1] 222 | if protoimpl.UnsafeEnabled && x != nil { 223 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 224 | if ms.LoadMessageInfo() == nil { 225 | ms.StoreMessageInfo(mi) 226 | } 227 | return ms 228 | } 229 | return mi.MessageOf(x) 230 | } 231 | 232 | // Deprecated: Use Message.ProtoReflect.Descriptor instead. 233 | func (*Message) Descriptor() ([]byte, []int) { 234 | return file_message_proto_rawDescGZIP(), []int{1} 235 | } 236 | 237 | func (x *Message) GetType() Message_MessageType { 238 | if x != nil { 239 | return x.Type 240 | } 241 | return Message_DISCONNECT 242 | } 243 | 244 | func (x *Message) GetBody() []byte { 245 | if x != nil { 246 | return x.Body 247 | } 248 | return nil 249 | } 250 | 251 | func (x *Message) GetTarget() *Target { 252 | if x != nil { 253 | return x.Target 254 | } 255 | return nil 256 | } 257 | 258 | type ChannelAction struct { 259 | state protoimpl.MessageState 260 | sizeCache protoimpl.SizeCache 261 | unknownFields protoimpl.UnknownFields 262 | 263 | Channel string `protobuf:"bytes,1,opt,name=channel,proto3" json:"channel,omitempty"` 264 | Target *Target `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` 265 | Type ChannelAction_ChannelActionType `protobuf:"varint,3,opt,name=type,proto3,enum=ChannelAction_ChannelActionType" json:"type,omitempty"` 266 | } 267 | 268 | func (x *ChannelAction) Reset() { 269 | *x = ChannelAction{} 270 | if protoimpl.UnsafeEnabled { 271 | mi := &file_message_proto_msgTypes[2] 272 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 273 | ms.StoreMessageInfo(mi) 274 | } 275 | } 276 | 277 | func (x *ChannelAction) String() string { 278 | return protoimpl.X.MessageStringOf(x) 279 | } 280 | 281 | func (*ChannelAction) ProtoMessage() {} 282 | 283 | func (x *ChannelAction) ProtoReflect() protoreflect.Message { 284 | mi := &file_message_proto_msgTypes[2] 285 | if protoimpl.UnsafeEnabled && x != nil { 286 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 287 | if ms.LoadMessageInfo() == nil { 288 | ms.StoreMessageInfo(mi) 289 | } 290 | return ms 291 | } 292 | return mi.MessageOf(x) 293 | } 294 | 295 | // Deprecated: Use ChannelAction.ProtoReflect.Descriptor instead. 296 | func (*ChannelAction) Descriptor() ([]byte, []int) { 297 | return file_message_proto_rawDescGZIP(), []int{2} 298 | } 299 | 300 | func (x *ChannelAction) GetChannel() string { 301 | if x != nil { 302 | return x.Channel 303 | } 304 | return "" 305 | } 306 | 307 | func (x *ChannelAction) GetTarget() *Target { 308 | if x != nil { 309 | return x.Target 310 | } 311 | return nil 312 | } 313 | 314 | func (x *ChannelAction) GetType() ChannelAction_ChannelActionType { 315 | if x != nil { 316 | return x.Type 317 | } 318 | return ChannelAction_SUBSCRIBE 319 | } 320 | 321 | var File_message_proto protoreflect.FileDescriptor 322 | 323 | var file_message_proto_rawDesc = []byte{ 324 | 0x0a, 0x0d, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 325 | 0x70, 0x0a, 0x06, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 326 | 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 327 | 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 328 | 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x18, 0x0a, 329 | 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 330 | 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x68, 0x61, 0x6e, 0x6e, 331 | 0x65, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 332 | 0x6c, 0x22, 0x9d, 0x01, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x28, 0x0a, 333 | 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x14, 0x2e, 0x4d, 0x65, 334 | 0x73, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x54, 0x79, 0x70, 335 | 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 336 | 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x1f, 0x0a, 0x06, 0x74, 337 | 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x54, 0x61, 338 | 0x72, 0x67, 0x65, 0x74, 0x52, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x22, 0x33, 0x0a, 0x0b, 339 | 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x0a, 0x44, 340 | 0x49, 0x53, 0x43, 0x4f, 0x4e, 0x4e, 0x45, 0x43, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x54, 341 | 0x45, 0x58, 0x54, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x42, 0x49, 0x4e, 0x41, 0x52, 0x59, 0x10, 342 | 0x02, 0x22, 0xb5, 0x01, 0x0a, 0x0d, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x41, 0x63, 0x74, 343 | 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x18, 0x01, 344 | 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x1f, 0x0a, 345 | 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 346 | 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x52, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x12, 0x34, 347 | 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x43, 348 | 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x68, 0x61, 349 | 0x6e, 0x6e, 0x65, 0x6c, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 350 | 0x74, 0x79, 0x70, 0x65, 0x22, 0x33, 0x0a, 0x11, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x41, 351 | 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0d, 0x0a, 0x09, 0x53, 0x55, 0x42, 352 | 0x53, 0x43, 0x52, 0x49, 0x42, 0x45, 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x55, 0x4e, 0x53, 0x55, 353 | 0x42, 0x53, 0x43, 0x52, 0x49, 0x42, 0x45, 0x10, 0x02, 0x42, 0x28, 0x5a, 0x26, 0x67, 0x69, 0x74, 354 | 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x43, 0x72, 0x65, 0x74, 0x65, 0x7a, 0x79, 0x2f, 355 | 0x64, 0x53, 0x6f, 0x63, 0x6b, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 356 | 0x74, 0x6f, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 357 | } 358 | 359 | var ( 360 | file_message_proto_rawDescOnce sync.Once 361 | file_message_proto_rawDescData = file_message_proto_rawDesc 362 | ) 363 | 364 | func file_message_proto_rawDescGZIP() []byte { 365 | file_message_proto_rawDescOnce.Do(func() { 366 | file_message_proto_rawDescData = protoimpl.X.CompressGZIP(file_message_proto_rawDescData) 367 | }) 368 | return file_message_proto_rawDescData 369 | } 370 | 371 | var file_message_proto_enumTypes = make([]protoimpl.EnumInfo, 2) 372 | var file_message_proto_msgTypes = make([]protoimpl.MessageInfo, 3) 373 | var file_message_proto_goTypes = []interface{}{ 374 | (Message_MessageType)(0), // 0: Message.MessageType 375 | (ChannelAction_ChannelActionType)(0), // 1: ChannelAction.ChannelActionType 376 | (*Target)(nil), // 2: Target 377 | (*Message)(nil), // 3: Message 378 | (*ChannelAction)(nil), // 4: ChannelAction 379 | } 380 | var file_message_proto_depIdxs = []int32{ 381 | 0, // 0: Message.type:type_name -> Message.MessageType 382 | 2, // 1: Message.target:type_name -> Target 383 | 2, // 2: ChannelAction.target:type_name -> Target 384 | 1, // 3: ChannelAction.type:type_name -> ChannelAction.ChannelActionType 385 | 4, // [4:4] is the sub-list for method output_type 386 | 4, // [4:4] is the sub-list for method input_type 387 | 4, // [4:4] is the sub-list for extension type_name 388 | 4, // [4:4] is the sub-list for extension extendee 389 | 0, // [0:4] is the sub-list for field type_name 390 | } 391 | 392 | func init() { file_message_proto_init() } 393 | func file_message_proto_init() { 394 | if File_message_proto != nil { 395 | return 396 | } 397 | if !protoimpl.UnsafeEnabled { 398 | file_message_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { 399 | switch v := v.(*Target); i { 400 | case 0: 401 | return &v.state 402 | case 1: 403 | return &v.sizeCache 404 | case 2: 405 | return &v.unknownFields 406 | default: 407 | return nil 408 | } 409 | } 410 | file_message_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { 411 | switch v := v.(*Message); i { 412 | case 0: 413 | return &v.state 414 | case 1: 415 | return &v.sizeCache 416 | case 2: 417 | return &v.unknownFields 418 | default: 419 | return nil 420 | } 421 | } 422 | file_message_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { 423 | switch v := v.(*ChannelAction); i { 424 | case 0: 425 | return &v.state 426 | case 1: 427 | return &v.sizeCache 428 | case 2: 429 | return &v.unknownFields 430 | default: 431 | return nil 432 | } 433 | } 434 | } 435 | type x struct{} 436 | out := protoimpl.TypeBuilder{ 437 | File: protoimpl.DescBuilder{ 438 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 439 | RawDescriptor: file_message_proto_rawDesc, 440 | NumEnums: 2, 441 | NumMessages: 3, 442 | NumExtensions: 0, 443 | NumServices: 0, 444 | }, 445 | GoTypes: file_message_proto_goTypes, 446 | DependencyIndexes: file_message_proto_depIdxs, 447 | EnumInfos: file_message_proto_enumTypes, 448 | MessageInfos: file_message_proto_msgTypes, 449 | }.Build() 450 | File_message_proto = out.File 451 | file_message_proto_rawDesc = nil 452 | file_message_proto_goTypes = nil 453 | file_message_proto_depIdxs = nil 454 | } 455 | -------------------------------------------------------------------------------- /common/requestid_middleware.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/gin-contrib/requestid" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | var RequestIdMiddleware = requestid.New(requestid.Config{ 9 | Generator: func() string { 10 | return uuid.New().String() 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /common/resolve_options.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | type ResolveOptions struct { 4 | Connection string `form:"id"` 5 | User string `form:"user"` 6 | Session string `form:"session"` 7 | Channel string `form:"channel"` 8 | } 9 | -------------------------------------------------------------------------------- /common/token_middleware.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | /// Validates that token matches from query parameter or from Authorization header 6 | func TokenMiddleware(token string) gin.HandlerFunc { 7 | isAuthorized := func(c *gin.Context) bool { 8 | if token == c.Query("token") { 9 | return true 10 | } 11 | 12 | tokenHeader := c.GetHeader("Authorization") 13 | if len(tokenHeader) > 7 { 14 | // Removes "Bearer " 15 | if token == tokenHeader[7:] { 16 | return true 17 | } 18 | } 19 | 20 | return false 21 | } 22 | 23 | return func(c *gin.Context) { 24 | if !isAuthorized(c) { 25 | apiError := ApiError{ 26 | StatusCode: 400, 27 | ErrorCode: ErrorInvalidAuthorization, 28 | } 29 | 30 | apiError.Send(c) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /common/utils.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | func RemoveString(texts []string, text string) []string { 9 | for index, value := range texts { 10 | if value == text { 11 | return append(texts[:index], texts[index+1:]...) 12 | } 13 | } 14 | 15 | return texts 16 | } 17 | 18 | func UniqueString(texts []string) []string { 19 | keys := make(map[string]struct{}) 20 | 21 | removed := 0 22 | for _, text := range texts { 23 | if _, value := keys[text]; !value { 24 | keys[text] = struct{}{} 25 | texts[removed] = text 26 | removed++ 27 | } 28 | } 29 | return texts[:removed] 30 | } 31 | 32 | func IncludesString(texts []string, text string) bool { 33 | for _, value := range texts { 34 | if value == text { 35 | return true 36 | } 37 | } 38 | 39 | return false 40 | } 41 | 42 | func RemoveEmpty(texts []string) []string { 43 | removed := 0 44 | for _, text := range texts { 45 | if text != "" { 46 | texts[removed] = text 47 | removed++ 48 | } 49 | } 50 | return texts[:removed] 51 | } 52 | 53 | const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 54 | 55 | var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) 56 | 57 | func RandomString(length int) string { 58 | random := make([]byte, length) 59 | 60 | for index := range random { 61 | random[index] = charset[seededRand.Intn(len(charset))] 62 | } 63 | 64 | return string(random) 65 | } 66 | -------------------------------------------------------------------------------- /common/utils_test.go: -------------------------------------------------------------------------------- 1 | // Simple tests in here for simple functions 2 | package common_test 3 | 4 | import ( 5 | "github.com/Cretezy/dSock/common" 6 | "github.com/stretchr/testify/suite" 7 | "testing" 8 | ) 9 | 10 | type UtilsSuite struct { 11 | suite.Suite 12 | } 13 | 14 | func TestUtilsSuite(t *testing.T) { 15 | suite.Run(t, new(UtilsSuite)) 16 | } 17 | 18 | func (suite *UtilsSuite) TestRemoveString() { 19 | suite.Equal( 20 | []string{"b", "a"}, 21 | common.RemoveString([]string{"a", "b", "a"}, "a"), 22 | ) 23 | } 24 | 25 | func (suite *UtilsSuite) TestUniqueString() { 26 | suite.Equal( 27 | []string{"a", "b"}, 28 | common.UniqueString([]string{"a", "b", "a"}), 29 | ) 30 | } 31 | 32 | func (suite *UtilsSuite) TestRemoveEmpty() { 33 | suite.Equal( 34 | []string{"a", "b"}, 35 | common.RemoveEmpty([]string{"a", "b", ""}), 36 | ) 37 | } 38 | 39 | func (suite *UtilsSuite) TestRandomString() { 40 | suite.Len(common.RandomString(8), 8) 41 | } 42 | -------------------------------------------------------------------------------- /common/version.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | var DSockVersion = "v0.4.1" 4 | -------------------------------------------------------------------------------- /docker-compose.e2e.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | e2e: 5 | build: 6 | dockerfile: e2e/Dockerfile 7 | context: . 8 | volumes: 9 | - .:/app 10 | depends_on: 11 | - api 12 | - worker 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | api: 5 | build: 6 | dockerfile: api/Dockerfile 7 | context: . 8 | target: development 9 | volumes: 10 | - .:/app 11 | - /app/api/build/ 12 | ports: 13 | - 3000:80 14 | depends_on: 15 | - redis 16 | worker: 17 | build: 18 | dockerfile: worker/Dockerfile 19 | context: . 20 | target: development 21 | volumes: 22 | - .:/app 23 | - /app/worker/build/ 24 | ports: 25 | - 3001:80 26 | depends_on: 27 | - redis 28 | redis: 29 | image: redis:alpine 30 | ports: 31 | - 3002:6379 32 | -------------------------------------------------------------------------------- /docs/images/infra.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cretezy/dSock/cea9d8399c23d75d6668a0a7dfaeb8e5c1c01a80/docs/images/infra.png -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cretezy/dSock/cea9d8399c23d75d6668a0a7dfaeb8e5c1c01a80/docs/images/logo.png -------------------------------------------------------------------------------- /docs/releasing.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | ## Prerequisites 4 | 5 | - Bump version in `common/version.go` 6 | - Make sure `CHANGELOG.md` is up to date and contains version changelog 7 | - Make sure tests pass (`task tests`) 8 | 9 | ## Binaries 10 | 11 | - Create and push version tag 12 | - Run `task build:binaries` 13 | - Upload all files in `build` to GitHub release (and add release notes) 14 | 15 | ## Docker 16 | 17 | - Run `task build:docker` 18 | - Run `task push:docker TAG=` (excludes `v`) 19 | - Run `task push:docker TAG=latest` 20 | -------------------------------------------------------------------------------- /e2e/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.13 2 | 3 | RUN apt-get update && apt install wait-for-it 4 | 5 | WORKDIR /app 6 | 7 | ADD go.mod go.sum ./ 8 | RUN go mod download 9 | 10 | ADD common ./common 11 | ADD e2e ./e2e 12 | 13 | WORKDIR /app/e2e 14 | 15 | ENTRYPOINT ["wait-for-it", "api:80", "-t", "30", "--", "go", "test", "-timeout", "30s"] 16 | -------------------------------------------------------------------------------- /e2e/README.md: -------------------------------------------------------------------------------- 1 | # dSock E2E Tests 2 | 3 | The dSock end-to-end tests, written in Go (using `testing`). 4 | 5 | ## Notes 6 | 7 | User/sessions are related to the current test running. This is to prevent test collisions. 8 | 9 | The user is the name of the test file. 10 | 11 | The sessions is the name of the test. If no session is set, add the name in the user after a `_`. 12 | 13 | ## Usage 14 | 15 | Run `task tests:e2e` to run the E2E tests. 16 | 17 | Tests are ran inside Docker Compose, using an additional configuration (`docker-compose.e2e.yml`). 18 | This adds the `e2e` service on top of the normal development services. 19 | 20 | The script also runs `docker-compose down` before running to stop all services, which clears Redis' storage. 21 | This ensures that all tests are ran on a clean database. Redis isn't configured to persist in development. 22 | -------------------------------------------------------------------------------- /e2e/api_utils_test.go: -------------------------------------------------------------------------------- 1 | package dsock_test 2 | 3 | import ( 4 | "github.com/Cretezy/dSock-go" 5 | ) 6 | 7 | var dSockClient = dsock.NewClient("http://api", "abc123") 8 | -------------------------------------------------------------------------------- /e2e/channel_test.go: -------------------------------------------------------------------------------- 1 | package dsock_test 2 | 3 | import ( 4 | "github.com/Cretezy/dSock-go" 5 | "github.com/gorilla/websocket" 6 | "github.com/stretchr/testify/suite" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | type ChannelSuite struct { 12 | suite.Suite 13 | } 14 | 15 | func TestChannelSuite(t *testing.T) { 16 | suite.Run(t, new(ChannelSuite)) 17 | } 18 | 19 | func (suite *ChannelSuite) TestChannelSubscribe() { 20 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 21 | User: "channel", 22 | Session: "subscribe", 23 | }) 24 | if !checkRequestError(suite.Suite, err, "claim creation") { 25 | return 26 | } 27 | 28 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?claim="+claim.Id, nil) 29 | if !checkConnectionError(suite.Suite, err, resp) { 30 | return 31 | } 32 | 33 | defer conn.Close() 34 | 35 | err = dSockClient.SubscribeChannel(dsock.ChannelOptions{ 36 | Target: dsock.Target{ 37 | User: "channel", 38 | Session: "subscribe", 39 | }, 40 | Channel: "channel_subscribe", 41 | }) 42 | if !checkRequestError(suite.Suite, err, "subscribing channel") { 43 | return 44 | } 45 | 46 | // Give it some time to propagate 47 | time.Sleep(time.Millisecond * 100) 48 | 49 | info, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 50 | Target: dsock.Target{ 51 | Channel: "channel_subscribe", 52 | }, 53 | }) 54 | if !checkRequestError(suite.Suite, err, "getting info") { 55 | return 56 | } 57 | 58 | if !suite.Len(info.Connections, 1, "Incorrect number of connections") { 59 | return 60 | } 61 | 62 | connection := info.Connections[0] 63 | 64 | if !suite.Equal("channel", connection.User, "Incorrect connection user") { 65 | return 66 | } 67 | 68 | if !suite.Equal("subscribe", connection.Session, "Incorrect connection user session") { 69 | return 70 | } 71 | 72 | // Includes default_channels in info 73 | if !suite.Equal([]string{"global", "channel_subscribe"}, interfaceToStringSlice(connection.Channels), "Incorrect connection channels") { 74 | return 75 | } 76 | } 77 | 78 | func (suite *ChannelSuite) TestChannelUnsubscribe() { 79 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 80 | User: "channel", 81 | Session: "unsubscribe", 82 | Channels: []string{"channel_unsubscribe"}, 83 | }) 84 | if !checkRequestError(suite.Suite, err, "claim creation") { 85 | return 86 | } 87 | 88 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?claim="+claim.Id, nil) 89 | if !checkConnectionError(suite.Suite, err, resp) { 90 | return 91 | } 92 | 93 | defer conn.Close() 94 | 95 | err = dSockClient.UnsubscribeChannel(dsock.ChannelOptions{ 96 | Target: dsock.Target{ 97 | User: "channel", 98 | Session: "unsubscribe", 99 | }, 100 | Channel: "channel_unsubscribe", 101 | }) 102 | if !checkRequestError(suite.Suite, err, "unsubscribing channel") { 103 | return 104 | } 105 | 106 | info, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 107 | Target: dsock.Target{ 108 | User: "channel", 109 | Session: "unsubscribe", 110 | }, 111 | }) 112 | if !checkRequestError(suite.Suite, err, "getting info") { 113 | return 114 | } 115 | 116 | if !suite.Len(info.Connections, 1, "Incorrect number of connections") { 117 | return 118 | } 119 | 120 | connection := info.Connections[0] 121 | 122 | if !suite.Equal("channel", connection.User, "Incorrect connection user") { 123 | return 124 | } 125 | 126 | if !suite.Equal("unsubscribe", connection.Session, "Incorrect connection user session") { 127 | return 128 | } 129 | 130 | if !suite.Equal([]string{"global"}, interfaceToStringSlice(connection.Channels), "Incorrect connection channels") { 131 | return 132 | } 133 | } 134 | 135 | func (suite *ChannelSuite) TestChannelClaim() { 136 | _, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 137 | User: "channel", 138 | Session: "subscribe_claim", 139 | }) 140 | if !checkRequestError(suite.Suite, err, "claim creation") { 141 | return 142 | } 143 | 144 | err = dSockClient.SubscribeChannel(dsock.ChannelOptions{ 145 | Target: dsock.Target{ 146 | User: "channel", 147 | Session: "subscribe_claim", 148 | }, 149 | Channel: "channel_subscribe_claim", 150 | }) 151 | if !checkRequestError(suite.Suite, err, "subscribing channel") { 152 | return 153 | } 154 | 155 | info1, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 156 | Target: dsock.Target{ 157 | Channel: "channel_subscribe_claim", 158 | }, 159 | }) 160 | if !checkRequestError(suite.Suite, err, "getting info 1") { 161 | return 162 | } 163 | 164 | infoClaims1 := info1.Claims 165 | if !suite.Len(infoClaims1, 1, "Incorrect number of info claims") { 166 | return 167 | } 168 | 169 | infoClaim1 := infoClaims1[0] 170 | 171 | if !suite.Equal("channel", infoClaim1.User, "Incorrect info claim 1 user") { 172 | return 173 | } 174 | 175 | if !suite.Equal("subscribe_claim", infoClaim1.Session, "Incorrect info claim 1 user session") { 176 | return 177 | } 178 | 179 | if !suite.Equal([]string{"channel_subscribe_claim"}, interfaceToStringSlice(infoClaim1.Channels), "Incorrect info claim 1 channels") { 180 | return 181 | } 182 | 183 | err = dSockClient.UnsubscribeChannel(dsock.ChannelOptions{ 184 | Target: dsock.Target{ 185 | User: "channel", 186 | Session: "subscribe_claim", 187 | }, 188 | Channel: "channel_subscribe_claim", 189 | }) 190 | if !checkRequestError(suite.Suite, err, "unsubscribing channel") { 191 | return 192 | } 193 | 194 | info2, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 195 | Target: dsock.Target{ 196 | Channel: "channel_subscribe_claim", 197 | }, 198 | }) 199 | if !checkRequestError(suite.Suite, err, "getting info 2") { 200 | return 201 | } 202 | 203 | if !suite.Len(info2.Claims, 0, "Incorrect number of info claims") { 204 | return 205 | } 206 | } 207 | func (suite *ChannelSuite) TestChannelClaimIgnore() { 208 | _, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 209 | User: "channel", 210 | Session: "subscribe_claim_ignore", 211 | }) 212 | if !checkRequestError(suite.Suite, err, "claim creation") { 213 | return 214 | } 215 | 216 | err = dSockClient.SubscribeChannel(dsock.ChannelOptions{ 217 | Target: dsock.Target{ 218 | User: "channel", 219 | Session: "subscribe_claim_ignore", 220 | }, 221 | Channel: "channel_subscribe_claim_ignore", 222 | IgnoreClaims: true, 223 | }) 224 | if !checkRequestError(suite.Suite, err, "subscribing channel") { 225 | return 226 | } 227 | 228 | info, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 229 | Target: dsock.Target{ 230 | Channel: "channel_subscribe_claim_ignore", 231 | }, 232 | }) 233 | if !checkRequestError(suite.Suite, err, "getting info") { 234 | return 235 | } 236 | 237 | if !suite.Len(info.Claims, 0, "Incorrect number of info claims") { 238 | return 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /e2e/connect_test.go: -------------------------------------------------------------------------------- 1 | package dsock_test 2 | 3 | import ( 4 | "encoding/json" 5 | dsock "github.com/Cretezy/dSock-go" 6 | "github.com/gorilla/websocket" 7 | "github.com/stretchr/testify/suite" 8 | "io/ioutil" 9 | "testing" 10 | ) 11 | 12 | type ConnectSuite struct { 13 | suite.Suite 14 | } 15 | 16 | func TestConnectSuite(t *testing.T) { 17 | suite.Run(t, new(ConnectSuite)) 18 | } 19 | 20 | func (suite *ConnectSuite) TestClaimConnect() { 21 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 22 | User: "connect", 23 | Session: "claim", 24 | }) 25 | if !checkRequestError(suite.Suite, err, "claim creation") { 26 | return 27 | } 28 | 29 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?claim="+claim.Id, nil) 30 | if !checkConnectionError(suite.Suite, err, resp) { 31 | return 32 | } 33 | 34 | defer conn.Close() 35 | 36 | info, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 37 | Target: dsock.Target{ 38 | User: "connect", 39 | Session: "claim", 40 | }, 41 | }) 42 | if !checkRequestError(suite.Suite, err, "getting info") { 43 | return 44 | } 45 | 46 | connections := info.Connections 47 | if !suite.Len(connections, 1, "Incorrect number of connections") { 48 | return 49 | } 50 | 51 | connection := connections[0] 52 | 53 | suite.Equal("connect", claim.User, "Incorrect claim user") 54 | suite.Equal("connect", connection.User, "Incorrect connection user") 55 | 56 | suite.Equal("claim", claim.Session, "Incorrect claim user session") 57 | suite.Equal("claim", connection.Session, "Incorrect connection user session") 58 | } 59 | 60 | func (suite *ConnectSuite) TestInvalidClaim() { 61 | _, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?claim=invalid-claim", nil) 62 | if !suite.Error(err, "Did not error when expected during connection") { 63 | return 64 | } 65 | 66 | body, err := ioutil.ReadAll(resp.Body) 67 | if !suite.NoError(err, "Could not read body") { 68 | return 69 | } 70 | 71 | var parsedBody map[string]interface{} 72 | 73 | err = json.Unmarshal(body, &parsedBody) 74 | if !suite.NoError(err, "Could not parse body") { 75 | return 76 | } 77 | 78 | if !suite.Equal(false, parsedBody["success"], "Succeeded when should have failed") { 79 | return 80 | } 81 | 82 | if !suite.Equal("MISSING_CLAIM", parsedBody["errorCode"], "Incorrect error code") { 83 | return 84 | } 85 | } 86 | 87 | func (suite *ConnectSuite) TestJwtConnect() { 88 | // Hard coded JWT with max expiry: 89 | // { 90 | // "sub": "connect", 91 | // "sid": "jwt", 92 | // "exp": 2147485546 93 | //} 94 | jwt := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjb25uZWN0Iiwic2lkIjoiand0IiwiZXhwIjoyMTQ3NDg1NTQ2fQ.oMbgPfg86I1sWs6IK25AP0H4ftzUVt9asKr9W9binW0" 95 | 96 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?jwt="+jwt, nil) 97 | if !checkConnectionError(suite.Suite, err, resp) { 98 | return 99 | } 100 | 101 | defer conn.Close() 102 | 103 | info, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 104 | Target: dsock.Target{ 105 | User: "connect", 106 | Session: "jwt", 107 | }, 108 | }) 109 | if !checkRequestError(suite.Suite, err, "getting info") { 110 | return 111 | } 112 | 113 | connections := info.Connections 114 | if !suite.Len(connections, 1, "Incorrect number of connections") { 115 | return 116 | } 117 | 118 | connection := connections[0] 119 | 120 | suite.Equal("connect", connection.User, "Incorrect connection user") 121 | suite.Equal("jwt", connection.Session, "Incorrect connection user session") 122 | } 123 | 124 | func (suite *ConnectSuite) TestInvalidJwt() { 125 | // Hard coded JWT with invalid expiry: 126 | // { 127 | // "sub": "connect", 128 | // "sid": "invalid", 129 | // "exp": "invalid" 130 | //} 131 | jwt := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjb25uZWN0Iiwic2lkIjoiaW52YWxpZCIsImV4cCI6ImludmFsaWQifQ.afZ4Mi-K0FeS35n7sivpNlq41JUi-QKVEjkH6mGWOrk" 132 | 133 | _, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?jwt="+jwt, nil) 134 | if !suite.Error(err, "Did not error when expecting during connection") { 135 | return 136 | } 137 | 138 | body, err := ioutil.ReadAll(resp.Body) 139 | if !suite.NoError(err, "Could not read body") { 140 | return 141 | } 142 | 143 | var parsedBody map[string]interface{} 144 | 145 | err = json.Unmarshal(body, &parsedBody) 146 | if !suite.NoError(err, "Could not parse body") { 147 | return 148 | } 149 | 150 | if !suite.Equal(false, parsedBody["success"], "Application succeeded when expected to fail") { 151 | return 152 | } 153 | 154 | if !suite.Equal("INVALID_JWT", parsedBody["errorCode"], "Incorrect error code") { 155 | return 156 | } 157 | } 158 | 159 | func (suite *ConnectSuite) TestJwtConnectChannel() { 160 | // Hard coded JWT with max expiry: 161 | // { 162 | // "sub": "connect", 163 | // "sid": "jwt_channel", 164 | // "exp": 2147485546, 165 | // "channels": ["connect_jwt"] 166 | //} 167 | jwt := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjb25uZWN0Iiwic2lkIjoiand0X2NoYW5uZWwiLCJleHAiOjIxNDc0ODU1NDYsImNoYW5uZWxzIjpbImNvbm5lY3Rfand0Il19.LdKHWk1W6DLMR02T0g1lGfhPdyyKqDJHqvUL3YQ9tLQ" 168 | 169 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?jwt="+jwt, nil) 170 | if !checkConnectionError(suite.Suite, err, resp) { 171 | return 172 | } 173 | 174 | defer conn.Close() 175 | 176 | info, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 177 | Target: dsock.Target{ 178 | Channel: "connect_jwt", 179 | }, 180 | }) 181 | if !checkRequestError(suite.Suite, err, "getting info") { 182 | return 183 | } 184 | 185 | connections := info.Connections 186 | if !suite.Len(connections, 1, "Incorrect number of connections") { 187 | return 188 | } 189 | 190 | connection := connections[0] 191 | 192 | if !suite.Equal("connect", connection.User, "Incorrect connection user") { 193 | return 194 | } 195 | if !suite.Equal("jwt_channel", connection.Session, "Incorrect connection user session") { 196 | return 197 | } 198 | 199 | // Includes default_channels in info 200 | if !suite.Equal([]string{"connect_jwt", "global"}, interfaceToStringSlice(connection.Channels), "Incorrect connection channels") { 201 | return 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /e2e/disconnect_test.go: -------------------------------------------------------------------------------- 1 | package dsock_test 2 | 3 | import ( 4 | dsock "github.com/Cretezy/dSock-go" 5 | "github.com/gorilla/websocket" 6 | "github.com/stretchr/testify/suite" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | type DisconnectSuite struct { 12 | suite.Suite 13 | } 14 | 15 | func TestDisconnectSuite(t *testing.T) { 16 | suite.Run(t, new(DisconnectSuite)) 17 | } 18 | 19 | func (suite *DisconnectSuite) TestUserDisconnect() { 20 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 21 | User: "disconnect_user", 22 | }) 23 | if !checkRequestError(suite.Suite, err, "claim creation") { 24 | return 25 | } 26 | 27 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?claim="+claim.Id, nil) 28 | if !checkConnectionError(suite.Suite, err, resp) { 29 | return 30 | } 31 | 32 | defer conn.Close() 33 | 34 | err = dSockClient.Disconnect(dsock.DisconnectOptions{ 35 | Target: dsock.Target{ 36 | User: "disconnect_user", 37 | }, 38 | }) 39 | if !checkRequestError(suite.Suite, err, "disconnection") { 40 | return 41 | } 42 | 43 | _, _, err = conn.ReadMessage() 44 | if !suite.Error(err, "Didn't get error when was expecting") { 45 | return 46 | } 47 | 48 | if closeErr, ok := err.(*websocket.CloseError); ok { 49 | if !suite.Equal(websocket.CloseNormalClosure, closeErr.Code, "Incorrect close type") { 50 | return 51 | } 52 | } else { 53 | suite.Failf("Incorrect error type: %s", err.Error()) 54 | } 55 | } 56 | 57 | func (suite *DisconnectSuite) TestSessionDisconnect() { 58 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 59 | User: "disconnect", 60 | Session: "session", 61 | }) 62 | if !checkRequestError(suite.Suite, err, "claim creation") { 63 | return 64 | } 65 | 66 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?claim="+claim.Id, nil) 67 | if !checkConnectionError(suite.Suite, err, resp) { 68 | return 69 | } 70 | 71 | defer conn.Close() 72 | 73 | err = dSockClient.Disconnect(dsock.DisconnectOptions{ 74 | Target: dsock.Target{ 75 | User: "disconnect", 76 | Session: "session", 77 | }, 78 | }) 79 | if !checkRequestError(suite.Suite, err, "disconnection") { 80 | return 81 | } 82 | 83 | _, _, err = conn.ReadMessage() 84 | if !suite.Error(err, "Didn't get error when was expecting") { 85 | return 86 | } 87 | 88 | if closeErr, ok := err.(*websocket.CloseError); ok { 89 | if !suite.Equal(websocket.CloseNormalClosure, closeErr.Code, "Incorrect close type") { 90 | return 91 | } 92 | } else { 93 | suite.Failf("Incorrect error type: %s", err.Error()) 94 | } 95 | } 96 | 97 | func (suite *DisconnectSuite) TestConnectionDisconnect() { 98 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 99 | User: "disconnect", 100 | Session: "connection", 101 | }) 102 | if !checkRequestError(suite.Suite, err, "claim creation") { 103 | return 104 | } 105 | 106 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?claim="+claim.Id, nil) 107 | if !checkConnectionError(suite.Suite, err, resp) { 108 | return 109 | } 110 | 111 | defer conn.Close() 112 | 113 | info, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 114 | Target: dsock.Target{ 115 | User: "disconnect", 116 | Session: "connection", 117 | }, 118 | }) 119 | if !checkRequestError(suite.Suite, err, "getting info") { 120 | return 121 | } 122 | 123 | infoConnections := info.Connections 124 | if !suite.Len(infoConnections, 1, "Incorrect number of connections") { 125 | return 126 | } 127 | 128 | id := infoConnections[0].Id 129 | 130 | err = dSockClient.Disconnect(dsock.DisconnectOptions{ 131 | Target: dsock.Target{ 132 | Id: id, 133 | }, 134 | }) 135 | if !checkRequestError(suite.Suite, err, "disconnection") { 136 | return 137 | } 138 | 139 | _, _, err = conn.ReadMessage() 140 | if !suite.Error(err, "Didn't get error when was expecting") { 141 | return 142 | } 143 | 144 | if closeErr, ok := err.(*websocket.CloseError); ok { 145 | if !suite.Equal(websocket.CloseNormalClosure, closeErr.Code, "Incorrect close type") { 146 | return 147 | } 148 | } else { 149 | suite.Failf("Incorrect error type: %s", err.Error()) 150 | } 151 | } 152 | 153 | func (suite *DisconnectSuite) TestNoSessionDisconnect() { 154 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 155 | User: "disconnect", 156 | Session: "no_session", 157 | }) 158 | if !checkRequestError(suite.Suite, err, "claim creation") { 159 | return 160 | } 161 | 162 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?claim="+claim.Id, nil) 163 | if !checkConnectionError(suite.Suite, err, resp) { 164 | return 165 | } 166 | 167 | defer conn.Close() 168 | 169 | var websocketErr error 170 | 171 | go func() { 172 | _, _, websocketErr = conn.ReadMessage() 173 | }() 174 | 175 | err = dSockClient.Disconnect(dsock.DisconnectOptions{ 176 | Target: dsock.Target{ 177 | User: "disconnect", 178 | Session: "no_session_bad", 179 | }, 180 | }) 181 | if !checkRequestError(suite.Suite, err, "disconnection") { 182 | return 183 | } 184 | 185 | // Wait a bit to see if connection was closed 186 | time.Sleep(time.Millisecond * 100) 187 | 188 | if !suite.NoError(websocketErr, "Got Websocket error (disconnect) when wasn't expecting") { 189 | return 190 | } 191 | } 192 | 193 | func (suite *DisconnectSuite) TestDisconnectExpireClaim() { 194 | _, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 195 | User: "disconnect", 196 | Session: "expire_claim", 197 | }) 198 | if !checkRequestError(suite.Suite, err, "claim creation") { 199 | return 200 | } 201 | 202 | infoBefore, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 203 | Target: dsock.Target{ 204 | User: "disconnect", 205 | Session: "expire_claim", 206 | }, 207 | }) 208 | if !checkRequestError(suite.Suite, err, "getting before info") { 209 | return 210 | } 211 | 212 | if !suite.Len(infoBefore.Claims, 1, "Incorrect number of claims before") { 213 | return 214 | } 215 | 216 | err = dSockClient.Disconnect(dsock.DisconnectOptions{ 217 | Target: dsock.Target{ 218 | User: "disconnect", 219 | Session: "expire_claim", 220 | }, 221 | }) 222 | if !checkRequestError(suite.Suite, err, "disconnection") { 223 | return 224 | } 225 | 226 | infoAfter, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 227 | Target: dsock.Target{ 228 | User: "disconnect", 229 | Session: "expire_claim", 230 | }, 231 | }) 232 | if !checkRequestError(suite.Suite, err, "getting after info") { 233 | return 234 | } 235 | 236 | if !suite.Len(infoAfter.Claims, 0, "Incorrect number of claims after") { 237 | return 238 | } 239 | } 240 | 241 | func (suite *DisconnectSuite) TestDisconnectKeepClaim() { 242 | _, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 243 | User: "disconnect", 244 | Session: "keep_claim", 245 | }) 246 | if !checkRequestError(suite.Suite, err, "claim creation") { 247 | return 248 | } 249 | infoBefore, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 250 | Target: dsock.Target{ 251 | User: "disconnect", 252 | Session: "keep_claim", 253 | }, 254 | }) 255 | if !checkRequestError(suite.Suite, err, "getting info") { 256 | return 257 | } 258 | 259 | if !suite.Len(infoBefore.Claims, 1, "Incorrect number of claims before") { 260 | return 261 | } 262 | 263 | err = dSockClient.Disconnect(dsock.DisconnectOptions{ 264 | Target: dsock.Target{ 265 | User: "disconnect", 266 | Session: "keep_claim_invalid", 267 | }, 268 | KeepClaims: true, 269 | }) 270 | if !checkRequestError(suite.Suite, err, "disconnection") { 271 | return 272 | } 273 | 274 | infoAfter, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 275 | Target: dsock.Target{ 276 | User: "disconnect", 277 | Session: "keep_claim", 278 | }, 279 | }) 280 | if !checkRequestError(suite.Suite, err, "getting after info") { 281 | return 282 | } 283 | 284 | if !suite.Len(infoAfter.Claims, 1, "Incorrect number of claims after") { 285 | return 286 | } 287 | } 288 | 289 | func (suite *DisconnectSuite) TestChannelDisconnect() { 290 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 291 | User: "disconnect", 292 | Session: "channel", 293 | Channels: []string{"disconnect_channel"}, 294 | }) 295 | if !checkRequestError(suite.Suite, err, "claim creation") { 296 | return 297 | } 298 | 299 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?claim="+claim.Id, nil) 300 | if !checkConnectionError(suite.Suite, err, resp) { 301 | return 302 | } 303 | 304 | defer conn.Close() 305 | 306 | err = dSockClient.Disconnect(dsock.DisconnectOptions{ 307 | Target: dsock.Target{ 308 | Channel: "disconnect_channel", 309 | }, 310 | }) 311 | if !checkRequestError(suite.Suite, err, "disconnection") { 312 | return 313 | } 314 | 315 | _, _, err = conn.ReadMessage() 316 | if !suite.Error(err, "Didn't get error when was expecting") { 317 | return 318 | } 319 | 320 | if closeErr, ok := err.(*websocket.CloseError); ok { 321 | if !suite.Equal(websocket.CloseNormalClosure, closeErr.Code, "Incorrect close type") { 322 | return 323 | } 324 | } else { 325 | suite.Failf("Incorrect error type: %s", err.Error()) 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /e2e/info_test.go: -------------------------------------------------------------------------------- 1 | package dsock_test 2 | 3 | import ( 4 | dsock "github.com/Cretezy/dSock-go" 5 | "github.com/gorilla/websocket" 6 | "github.com/stretchr/testify/suite" 7 | "testing" 8 | ) 9 | 10 | type InfoSuite struct { 11 | suite.Suite 12 | } 13 | 14 | func TestInfoSuite(t *testing.T) { 15 | suite.Run(t, new(InfoSuite)) 16 | } 17 | 18 | func (suite *InfoSuite) TestInfoClaim() { 19 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 20 | User: "info", 21 | Session: "claim", 22 | Expiration: 2147485545, 23 | }) 24 | if !checkRequestError(suite.Suite, err, "claim creation") { 25 | return 26 | } 27 | 28 | info, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 29 | Target: dsock.Target{ 30 | User: "info", 31 | Session: "claim", 32 | }, 33 | }) 34 | if !checkRequestError(suite.Suite, err, "getting info") { 35 | return 36 | } 37 | 38 | infoClaims := info.Claims 39 | 40 | if !suite.Len(infoClaims, 1, "Incorrect number of claims") { 41 | return 42 | } 43 | 44 | infoClaim := infoClaims[0] 45 | 46 | if !suite.Equal(claim.Id, infoClaim.Id, "Info claim ID doesn't match claim") { 47 | return 48 | } 49 | 50 | if !suite.Equal("info", claim.User, "Incorrect claim user") { 51 | return 52 | } 53 | if !suite.Equal("info", infoClaim.User, "Incorrect info claim user") { 54 | return 55 | } 56 | 57 | if !suite.Equal("claim", claim.Session, "Incorrect claim user session") { 58 | return 59 | } 60 | if !suite.Equal("claim", infoClaim.Session, "Incorrect info claim user session") { 61 | return 62 | } 63 | 64 | // Has to do some weird casting 65 | if !suite.Equal(2147485545, infoClaim.Expiration, "Info claim expiration doesn't match") { 66 | return 67 | } 68 | } 69 | 70 | func (suite *InfoSuite) TestInfoClaimInvalidExpiration() { 71 | _, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 72 | User: "info", 73 | Session: "invalid_expiration", 74 | Expiration: 1, 75 | }) 76 | if !checkRequestNoError(suite.Suite, err, "INVALID_EXPIRATION", "claim creation") { 77 | return 78 | } 79 | } 80 | 81 | func (suite *InfoSuite) TestInfoClaimNegativeExpiration() { 82 | _, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 83 | User: "info", 84 | Session: "negative_expiration", 85 | Expiration: -1, 86 | }) 87 | if !checkRequestNoError(suite.Suite, err, "NEGATIVE_EXPIRATION", "claim creation") { 88 | return 89 | } 90 | } 91 | 92 | func (suite *InfoSuite) TestInfoClaimNegativeDuration() { 93 | _, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 94 | User: "info", 95 | Session: "negative_duration", 96 | Duration: -1, 97 | }) 98 | if !checkRequestNoError(suite.Suite, err, "NEGATIVE_DURATION", "claim creation") { 99 | return 100 | } 101 | } 102 | 103 | func (suite *InfoSuite) TestInfoConnection() { 104 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 105 | User: "info", 106 | Session: "connection", 107 | }) 108 | if !checkRequestError(suite.Suite, err, "claim creation") { 109 | return 110 | } 111 | 112 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?claim="+claim.Id, nil) 113 | if !checkConnectionError(suite.Suite, err, resp) { 114 | return 115 | } 116 | 117 | defer conn.Close() 118 | 119 | info, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 120 | Target: dsock.Target{ 121 | User: "info", 122 | Session: "connection", 123 | }, 124 | }) 125 | if !checkRequestError(suite.Suite, err, "getting info") { 126 | return 127 | } 128 | 129 | infoConnections := info.Connections 130 | if !suite.Len(infoConnections, 1, "Incorrect number of connections") { 131 | return 132 | } 133 | 134 | infoConnection := infoConnections[0] 135 | 136 | if !suite.Equal("info", claim.User, "Incorrect claim user") { 137 | return 138 | } 139 | if !suite.Equal("info", infoConnection.User, "Incorrect connection user") { 140 | return 141 | } 142 | 143 | if !suite.Equal("connection", claim.Session, "Incorrect claim user session") { 144 | return 145 | } 146 | if !suite.Equal("connection", infoConnection.Session, "Incorrect connection user session") { 147 | return 148 | } 149 | } 150 | 151 | // Broken test? `unexpected end of JSON input` 152 | //func (suite *InfoSuite) TestInfoMissing() { 153 | // info, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 154 | // Target: dsock.Target{ 155 | // User: "info", 156 | // Session: "missing", 157 | // }, 158 | // }) 159 | // if !checkRequestError(suite.Suite, err, "getting info") { 160 | // return 161 | // } 162 | // 163 | // if !suite.Len(info.Claims, 0, "Incorrect number of claims") { 164 | // return 165 | // } 166 | // 167 | // if !suite.Len(info.Connections, 0, "Incorrect number of connections") { 168 | // return 169 | // } 170 | //} 171 | 172 | func (suite *InfoSuite) TestInfoNoTarget() { 173 | _, err := dSockClient.GetInfo(dsock.GetInfoOptions{}) 174 | if !checkRequestNoError(suite.Suite, err, "MISSING_TARGET", "getting info") { 175 | return 176 | } 177 | } 178 | 179 | func (suite *InfoSuite) TestInfoChannel() { 180 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 181 | User: "info", 182 | Session: "channel", 183 | Channels: []string{"info_channel"}, 184 | }) 185 | if !checkRequestError(suite.Suite, err, "claim creation") { 186 | return 187 | } 188 | 189 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?claim="+claim.Id, nil) 190 | if !checkConnectionError(suite.Suite, err, resp) { 191 | return 192 | } 193 | 194 | defer conn.Close() 195 | 196 | info, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 197 | Target: dsock.Target{ 198 | Channel: "info_channel", 199 | }, 200 | }) 201 | if !checkRequestError(suite.Suite, err, "getting info") { 202 | return 203 | } 204 | 205 | infoConnections := info.Connections 206 | if !suite.Len(infoConnections, 1, "Incorrect number of connections") { 207 | return 208 | } 209 | 210 | infoConnection := infoConnections[0] 211 | 212 | if !suite.Equal("info", claim.User, "Incorrect claim user") { 213 | return 214 | } 215 | if !suite.Equal("info", infoConnection.User, "Incorrect connection user") { 216 | return 217 | } 218 | 219 | if !suite.Equal([]string{"info_channel"}, interfaceToStringSlice(claim.Channels), "Incorrect claim channels") { 220 | return 221 | } 222 | 223 | // Includes default_channels in info 224 | if !suite.Equal([]string{"info_channel", "global"}, interfaceToStringSlice(infoConnection.Channels), "Incorrect connection channels") { 225 | return 226 | } 227 | } 228 | 229 | func (suite *InfoSuite) TestInfoChannelClaim() { 230 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 231 | User: "info", 232 | Session: "channel_claim", 233 | Channels: []string{"info_channel_claim"}, 234 | }) 235 | if !checkRequestError(suite.Suite, err, "claim creation") { 236 | return 237 | } 238 | 239 | info, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 240 | Target: dsock.Target{ 241 | Channel: "info_channel_claim", 242 | }, 243 | }) 244 | if !checkRequestError(suite.Suite, err, "getting info") { 245 | return 246 | } 247 | 248 | infoClaims := info.Claims 249 | if !suite.Len(infoClaims, 1, "Incorrect number of claims") { 250 | return 251 | } 252 | 253 | infoClaim := infoClaims[0] 254 | 255 | if !suite.Equal("info", claim.User, "Incorrect claim user") { 256 | return 257 | } 258 | if !suite.Equal("info", infoClaim.User, "Incorrect info claim user") { 259 | return 260 | } 261 | 262 | if !suite.Equal([]string{"info_channel_claim"}, interfaceToStringSlice(claim.Channels), "Incorrect claim channels") { 263 | return 264 | } 265 | 266 | // Includes default_channels in info 267 | if !suite.Equal([]string{"info_channel_claim"}, interfaceToStringSlice(infoClaim.Channels), "Incorrect info claim channels") { 268 | return 269 | } 270 | } 271 | 272 | func (suite *InfoSuite) TestInfoClaimEmptyId() { 273 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 274 | Id: "", 275 | User: "info", 276 | Session: "claim_empty_id", 277 | }) 278 | if !checkRequestError(suite.Suite, err, "claim creation") { 279 | return 280 | } 281 | 282 | info, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 283 | Target: dsock.Target{ 284 | User: "info", 285 | Session: "claim_empty_id", 286 | }, 287 | }) 288 | if !checkRequestError(suite.Suite, err, "getting info") { 289 | return 290 | } 291 | 292 | infoClaims := info.Claims 293 | if !suite.Len(infoClaims, 1, "Incorrect number of claims") { 294 | return 295 | } 296 | 297 | infoClaim := infoClaims[0] 298 | 299 | if !suite.Equal(claim.Id, infoClaim.Id, "Incorrect claim ID") { 300 | return 301 | } 302 | if !suite.NotEqual("", claim.Id, "Empty claim ID") { 303 | return 304 | } 305 | } 306 | 307 | func (suite *InfoSuite) TestInfoClaimId() { 308 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 309 | Id: "test_claim_id", 310 | User: "info", 311 | Session: "claim_id", 312 | }) 313 | if !checkRequestError(suite.Suite, err, "claim creation") { 314 | return 315 | } 316 | 317 | info, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 318 | Target: dsock.Target{ 319 | User: "info", 320 | Session: "claim_id", 321 | }, 322 | }) 323 | if !checkRequestError(suite.Suite, err, "getting info") { 324 | return 325 | } 326 | 327 | infoClaims := info.Claims 328 | if !suite.Len(infoClaims, 1, "Incorrect number of claims") { 329 | return 330 | } 331 | 332 | infoClaim := infoClaims[0] 333 | 334 | if !suite.Equal(claim.Id, infoClaim.Id, "Incorrect claim ID") { 335 | return 336 | } 337 | if !suite.Equal("test_claim_id", claim.Id, "Incorrect claim ID") { 338 | return 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /e2e/send_test.go: -------------------------------------------------------------------------------- 1 | package dsock_test 2 | 3 | import ( 4 | dsock "github.com/Cretezy/dSock-go" 5 | "github.com/gorilla/websocket" 6 | "github.com/stretchr/testify/suite" 7 | "testing" 8 | ) 9 | 10 | type SendSuite struct { 11 | suite.Suite 12 | } 13 | 14 | func TestSendSuite(t *testing.T) { 15 | suite.Run(t, new(SendSuite)) 16 | } 17 | 18 | func (suite *SendSuite) TestUserSend() { 19 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 20 | User: "send_user", 21 | }) 22 | if !checkRequestError(suite.Suite, err, "claim creation") { 23 | return 24 | } 25 | 26 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?claim="+claim.Id, nil) 27 | if !checkConnectionError(suite.Suite, err, resp) { 28 | return 29 | } 30 | 31 | defer conn.Close() 32 | 33 | err = dSockClient.SendMessage(dsock.SendMessageOptions{ 34 | Target: dsock.Target{ 35 | User: "send_user", 36 | }, 37 | Type: "text", 38 | Message: []byte("Hello world!"), 39 | }) 40 | if !checkRequestError(suite.Suite, err, "sending") { 41 | return 42 | } 43 | 44 | messageType, data, err := conn.ReadMessage() 45 | if !suite.NoError(err, "Error during receiving message") { 46 | return 47 | } 48 | 49 | if !suite.Equal(websocket.TextMessage, messageType, "Incorrect message type") { 50 | return 51 | } 52 | 53 | if !suite.Equal("Hello world!", string(data), "Incorrect message data") { 54 | return 55 | } 56 | } 57 | 58 | func (suite *SendSuite) TestUserSessionSend() { 59 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 60 | User: "send", 61 | Session: "session", 62 | }) 63 | if !checkRequestError(suite.Suite, err, "claim creation") { 64 | return 65 | } 66 | 67 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?claim="+claim.Id, nil) 68 | if !checkConnectionError(suite.Suite, err, resp) { 69 | return 70 | } 71 | 72 | defer conn.Close() 73 | 74 | err = dSockClient.SendMessage(dsock.SendMessageOptions{ 75 | Target: dsock.Target{ 76 | User: "send", 77 | Session: "session", 78 | }, 79 | Type: "text", 80 | Message: []byte("Hello world!"), 81 | }) 82 | if !checkRequestError(suite.Suite, err, "sending") { 83 | return 84 | } 85 | 86 | messageType, data, err := conn.ReadMessage() 87 | if !suite.NoError(err, "Error during receiving message") { 88 | return 89 | } 90 | 91 | if !suite.Equal(websocket.TextMessage, messageType, "Incorrect message type") { 92 | return 93 | } 94 | 95 | if !suite.Equal("Hello world!", string(data), "Incorrect message data") { 96 | return 97 | } 98 | } 99 | 100 | func (suite *SendSuite) TestConnectionSend() { 101 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 102 | User: "send", 103 | Session: "connection", 104 | }) 105 | if !checkRequestError(suite.Suite, err, "claim creation") { 106 | return 107 | } 108 | 109 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?claim="+claim.Id, nil) 110 | if !checkConnectionError(suite.Suite, err, resp) { 111 | return 112 | } 113 | 114 | defer conn.Close() 115 | 116 | info, err := dSockClient.GetInfo(dsock.GetInfoOptions{ 117 | Target: dsock.Target{ 118 | User: "send", 119 | Session: "connection", 120 | }, 121 | }) 122 | if !checkRequestError(suite.Suite, err, "getting info") { 123 | return 124 | } 125 | 126 | infoConnections := info.Connections 127 | if !suite.Len(infoConnections, 1, "Invalid number of connections") { 128 | return 129 | } 130 | 131 | err = dSockClient.SendMessage(dsock.SendMessageOptions{ 132 | Target: dsock.Target{ 133 | Id: infoConnections[0].Id, 134 | }, 135 | Type: "binary", 136 | Message: []byte{1, 2, 3, 4}, 137 | }) 138 | if !checkRequestError(suite.Suite, err, "sending") { 139 | return 140 | } 141 | 142 | messageType, data, err := conn.ReadMessage() 143 | if !suite.NoError(err, "Error during receiving message") { 144 | return 145 | } 146 | 147 | if !suite.Equal(websocket.BinaryMessage, messageType, "Incorrect message type") { 148 | return 149 | } 150 | 151 | if !suite.Equal([]byte{1, 2, 3, 4}, data, "Incorrect message data") { 152 | return 153 | } 154 | } 155 | 156 | func (suite *SendSuite) TestSendNoTarget() { 157 | err := dSockClient.SendMessage(dsock.SendMessageOptions{}) 158 | if !checkRequestNoError(suite.Suite, err, "MISSING_TARGET", "sending") { 159 | return 160 | } 161 | } 162 | 163 | func (suite *SendSuite) TestSendNoType() { 164 | err := dSockClient.SendMessage(dsock.SendMessageOptions{ 165 | Target: dsock.Target{ 166 | Id: "a", 167 | }, 168 | }) 169 | if !checkRequestNoError(suite.Suite, err, "INVALID_MESSAGE_TYPE", "sending") { 170 | return 171 | } 172 | } 173 | 174 | func (suite *SendSuite) TestConnectionChannel() { 175 | claim, err := dSockClient.CreateClaim(dsock.CreateClaimOptions{ 176 | User: "send", 177 | Session: "channel", 178 | Channels: []string{"send_channel"}, 179 | }) 180 | if !checkRequestError(suite.Suite, err, "claim creation") { 181 | return 182 | } 183 | 184 | conn, resp, err := websocket.DefaultDialer.Dial("ws://worker/connect?claim="+claim.Id, nil) 185 | if !checkConnectionError(suite.Suite, err, resp) { 186 | return 187 | } 188 | 189 | defer conn.Close() 190 | 191 | err = dSockClient.SendMessage(dsock.SendMessageOptions{ 192 | Target: dsock.Target{ 193 | Channel: "send_channel", 194 | }, 195 | Type: "text", 196 | Message: []byte("Hello world!"), 197 | }) 198 | if !checkRequestError(suite.Suite, err, "sending") { 199 | return 200 | } 201 | 202 | messageType, data, err := conn.ReadMessage() 203 | if !suite.NoError(err, "Error during receiving message") { 204 | return 205 | } 206 | 207 | if !suite.Equal(websocket.TextMessage, messageType, "Incorrect message type") { 208 | return 209 | } 210 | 211 | if !suite.Equal([]byte("Hello world!"), data, "Incorrect message data") { 212 | return 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /e2e/test_utils_test.go: -------------------------------------------------------------------------------- 1 | package dsock_test 2 | 3 | import ( 4 | dsock "github.com/Cretezy/dSock-go" 5 | "github.com/stretchr/testify/suite" 6 | "io/ioutil" 7 | "net/http" 8 | "reflect" 9 | "time" 10 | ) 11 | 12 | func checkConnectionError(suite suite.Suite, err error, resp *http.Response) bool { 13 | if !suite.NoErrorf(err, "Error during connection (%s)", resp.Status) { 14 | body, err := ioutil.ReadAll(resp.Body) 15 | if err == nil { 16 | suite.T().Logf("Body: %s", string(body)) 17 | } else { 18 | suite.T().Log("Could not read body") 19 | } 20 | 21 | return false 22 | } 23 | 24 | // Connection was successful, wait a tiny bit to make sure connection is set in Redis 25 | time.Sleep(time.Millisecond) 26 | 27 | return true 28 | } 29 | 30 | func checkRequestError(suite suite.Suite, err error, during string) bool { 31 | if !suite.NoErrorf(err, "Error during %s", during) { 32 | return false 33 | } 34 | 35 | return true 36 | } 37 | 38 | func checkRequestNoError(suite suite.Suite, err error, errorCode, during string) bool { 39 | if dsockErr, ok := err.(*dsock.DSockError); ok { 40 | if !suite.Equalf(errorCode, dsockErr.Code, "Incorrect error code") { 41 | return false 42 | } 43 | 44 | return true 45 | } 46 | 47 | if !suite.NoErrorf(err, "Error during %s", during) { 48 | return false 49 | } 50 | 51 | return true 52 | } 53 | 54 | func interfaceToStringSlice(slice interface{}) []string { 55 | s := reflect.ValueOf(slice) 56 | if s.Kind() != reflect.Slice { 57 | panic("interfaceSlice() given a non-slice type") 58 | } 59 | 60 | ret := make([]string, s.Len()) 61 | 62 | for i := 0; i < s.Len(); i++ { 63 | ret[i] = s.Index(i).Interface().(string) 64 | } 65 | 66 | return ret 67 | } 68 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Cretezy/dSock 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/Cretezy/dSock-go v1.0.2 7 | github.com/cosmtrek/air v1.21.2 // indirect 8 | github.com/creack/pty v1.1.11 // indirect 9 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 10 | github.com/fatih/color v1.10.0 // indirect 11 | github.com/gin-contrib/requestid v0.0.0-20200512155051-855d6508f0f0 12 | github.com/gin-contrib/zap v0.0.1 13 | github.com/gin-gonic/gin v1.6.3 14 | github.com/go-redis/redis/v7 v7.2.0 15 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0 16 | github.com/google/uuid v1.1.1 17 | github.com/gorilla/websocket v1.4.2 18 | github.com/imdario/mergo v0.3.11 // indirect 19 | github.com/pelletier/go-toml v1.8.1 // indirect 20 | github.com/spf13/viper v1.6.3 21 | github.com/stretchr/testify v1.4.0 22 | go.uber.org/zap v1.10.0 23 | golang.org/x/sys v0.0.0-20210308170721-88b6017d0656 // indirect 24 | google.golang.org/protobuf v1.21.0 25 | ) 26 | 27 | replace github.com/Cretezy/common/protos => ./common/build/gen/protos/github.com/Cretezy/dSock/common/protos 28 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 3 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 4 | github.com/Cretezy/dSock-go v1.0.2 h1:q6UBq2zPF9o0jkKSioWAYaAIibnUX0HmY9HaRfzYVvo= 5 | github.com/Cretezy/dSock-go v1.0.2/go.mod h1:ZpUyRjL//Wy5I8djKhaKbAixTAlhzIxXotH4CRptjK8= 6 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 7 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 8 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 9 | github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= 10 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 11 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 12 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 13 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 14 | github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= 15 | github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 16 | github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 17 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 18 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= 19 | github.com/cosmtrek/air v1.21.2 h1:PwChdKs3qlSkKucKwwC04daw5eoy4SVgiEBQiHX5L9A= 20 | github.com/cosmtrek/air v1.21.2/go.mod h1:5EsgUqrBIHlW2ghNoevwPBEG1FQvF5XNulikjPte538= 21 | github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= 22 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 23 | github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= 24 | github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 25 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 27 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 28 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 29 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 30 | github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= 31 | github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= 32 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 33 | github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= 34 | github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= 35 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 36 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 37 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 38 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 39 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 40 | github.com/gin-contrib/requestid v0.0.0-20200512155051-855d6508f0f0 h1:05UXaOJP7ASqiq1Loxc9gu4oUd/EGpovwmj+xHx/pns= 41 | github.com/gin-contrib/requestid v0.0.0-20200512155051-855d6508f0f0/go.mod h1:zZOqhFJBrOv1CwUL1lz6dPXCNxOWmVPRPtQMYdYHadA= 42 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 43 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 44 | github.com/gin-contrib/zap v0.0.1 h1:wsX/ahRftxPiXpiUw0YqyHj+TQTKtv+DAFWH84G1Uvg= 45 | github.com/gin-contrib/zap v0.0.1/go.mod h1:vJJndZ8f44gsTHQrDPIB4YOZzwOwiEIdE0mMrZLOogk= 46 | github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= 47 | github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= 48 | github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= 49 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 50 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 51 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 52 | github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= 53 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 54 | github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= 55 | github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= 56 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 57 | github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= 58 | github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= 59 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 60 | github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= 61 | github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= 62 | github.com/go-redis/redis/v7 v7.2.0 h1:CrCexy/jYWZjW0AyVoHlcJUeZN19VWlbepTh1Vq6dJs= 63 | github.com/go-redis/redis/v7 v7.2.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg= 64 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 65 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 66 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 67 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 68 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 69 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 70 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 71 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 72 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 73 | github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I= 74 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 75 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 76 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 77 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 78 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0 h1:aRz0NBceriICVtjhCgKkDvl+RudKu1CT6h0ZvUTrNfE= 79 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 80 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 81 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 82 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 83 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 84 | github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= 85 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 86 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 87 | github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= 88 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 89 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 90 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 91 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 92 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 93 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 94 | github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= 95 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= 96 | github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= 97 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 98 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 99 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 100 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 101 | github.com/imdario/mergo v0.3.8 h1:CGgOkSJeqMRmt0D9XLWExdT4m4F1vd3FV3VPt+0VxkQ= 102 | github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= 103 | github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= 104 | github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= 105 | github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= 106 | github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 107 | github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= 108 | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 109 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 110 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 111 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 112 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 113 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 114 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 115 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 116 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 117 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 118 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 119 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 120 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 121 | github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= 122 | github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= 123 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 124 | github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= 125 | github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 126 | github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= 127 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 128 | github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= 129 | github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 130 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 131 | github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= 132 | github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= 133 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 134 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 135 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 136 | github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= 137 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 138 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= 139 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 140 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 141 | github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= 142 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 143 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 144 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 145 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 146 | github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo= 147 | github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 148 | github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME= 149 | github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 150 | github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= 151 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 152 | github.com/pelletier/go-toml v1.6.0 h1:aetoXYr0Tv7xRU/V4B4IZJ2QcbtMUFoNb3ORp7TzIK4= 153 | github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys= 154 | github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM= 155 | github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= 156 | github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= 157 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 158 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 159 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 160 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 161 | github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= 162 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 163 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 164 | github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 165 | github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 166 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 167 | github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 168 | github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= 169 | github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= 170 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 171 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= 172 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 173 | github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= 174 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 175 | github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= 176 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 177 | github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= 178 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 179 | github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= 180 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 181 | github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= 182 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 183 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 184 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 185 | github.com/spf13/viper v1.6.3 h1:pDDu1OyEDTKzpJwdq4TiuLyMsUgRa/BT5cn5O62NoHs= 186 | github.com/spf13/viper v1.6.3/go.mod h1:jUMtyi0/lB5yZH/FjyGAoH7IMNrIhlBf6pXZmbMDvzw= 187 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 188 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 189 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 190 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 191 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 192 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 193 | github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= 194 | github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= 195 | github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= 196 | github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= 197 | github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= 198 | github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= 199 | github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= 200 | github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= 201 | github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= 202 | go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= 203 | go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 204 | go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU= 205 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 206 | go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= 207 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 208 | go.uber.org/zap v1.10.0 h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM= 209 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 210 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 211 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 212 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 213 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 214 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 215 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 216 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 217 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 218 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 219 | golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 220 | golang.org/x/net v0.0.0-20190923162816-aa69164e4478 h1:l5EDrHhldLYb3ZRHDUhXF7Om7MvYXnkV9/iQNo1lX6g= 221 | golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 222 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 223 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 224 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 225 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 226 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 227 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 228 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 229 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 230 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 231 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 232 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 233 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 234 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 235 | golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 236 | golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 237 | golang.org/x/sys v0.0.0-20191110163157-d32e6e3b99c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 238 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg= 239 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 240 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 241 | golang.org/x/sys v0.0.0-20210308170721-88b6017d0656 h1:FuBaiPCiXkq4v+JY5JEGPU/HwEZwpVyDbu/KBz9fU+4= 242 | golang.org/x/sys v0.0.0-20210308170721-88b6017d0656/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 243 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 244 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 245 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 246 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 247 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 248 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 249 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 250 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 251 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 252 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 253 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 254 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 255 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 256 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 257 | google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 258 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 259 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 260 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 261 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 262 | google.golang.org/protobuf v1.21.0 h1:qdOKuR/EIArgaWNjetjgTzgVTAZ+S/WXVrq9HW9zimw= 263 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 264 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 265 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 266 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 267 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 268 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 269 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 270 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 271 | gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= 272 | gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= 273 | gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= 274 | gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 275 | gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= 276 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 277 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 278 | gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 279 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 280 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 281 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 282 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 283 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 284 | gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= 285 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 286 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 287 | -------------------------------------------------------------------------------- /protos/message.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | option go_package = "github.com/Cretezy/dSock/common/protos"; 3 | 4 | message Target { 5 | string connection = 1; 6 | string user = 2; 7 | string session = 3; 8 | string channel = 4; 9 | } 10 | 11 | message Message { 12 | enum MessageType { 13 | DISCONNECT = 0; 14 | // Must match RFC 6455, section 11.8 15 | TEXT = 1; 16 | BINARY = 2; 17 | } 18 | 19 | MessageType type = 1; 20 | bytes body = 2; 21 | 22 | Target target = 3; 23 | } 24 | 25 | 26 | message ChannelAction { 27 | enum ChannelActionType { 28 | SUBSCRIBE = 0; 29 | UNSUBSCRIBE = 1; 30 | } 31 | 32 | string channel = 1; 33 | Target target = 2; 34 | ChannelActionType type = 3; 35 | } 36 | -------------------------------------------------------------------------------- /worker/Dockerfile: -------------------------------------------------------------------------------- 1 | # Development stage with gin and all required files 2 | FROM golang:1.13 AS development 3 | 4 | RUN go get -u github.com/cosmtrek/air 5 | 6 | WORKDIR /app 7 | 8 | ADD go.mod go.sum ./ 9 | RUN go mod download 10 | 11 | ADD common ./common 12 | ADD worker ./worker 13 | 14 | WORKDIR /app/worker 15 | ENV PORT 80 16 | EXPOSE 80 17 | 18 | ENTRYPOINT ["air"] 19 | 20 | 21 | # Release builder stage, to build the output binary 22 | FROM development AS release_builder 23 | 24 | COPY --from=development /app /app 25 | 26 | ENV CGO_ENABLED=0 27 | RUN go build -o build/app -ldflags "-s -w" 28 | 29 | 30 | # Release stage, with only the binary 31 | FROM scratch AS release 32 | 33 | COPY --from=release_builder /app/worker/build/app /app 34 | 35 | ENV PORT 80 36 | EXPOSE 80 37 | 38 | ENTRYPOINT ["/app"] 39 | -------------------------------------------------------------------------------- /worker/authentication.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Cretezy/dSock/common" 5 | "github.com/dgrijalva/jwt-go" 6 | "github.com/gin-contrib/requestid" 7 | "github.com/gin-gonic/gin" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | type Authentication struct { 13 | User string 14 | Session string 15 | Channels []string 16 | } 17 | 18 | func authenticate(c *gin.Context) (*Authentication, *common.ApiError) { 19 | if claim := c.Query("claim"); claim != "" { 20 | // Validate claim 21 | claimData := redisClient.HGetAll("claim:" + claim) 22 | 23 | if claimData.Err() != nil { 24 | return nil, &common.ApiError{ 25 | InternalError: claimData.Err(), 26 | ErrorCode: common.ErrorGettingClaim, 27 | StatusCode: 500, 28 | RequestId: requestid.Get(c), 29 | } 30 | } 31 | 32 | if len(claimData.Val()) == 0 { 33 | // Claim doesn't exist 34 | return nil, &common.ApiError{ 35 | ErrorCode: common.ErrorMissingClaim, 36 | StatusCode: 400, 37 | } 38 | } 39 | 40 | user, hasUser := claimData.Val()["user"] 41 | if !hasUser { 42 | // Invalid claim (missing user) 43 | return nil, &common.ApiError{ 44 | ErrorCode: common.ErrorMissingClaim, 45 | StatusCode: 400, 46 | RequestId: requestid.Get(c), 47 | } 48 | } 49 | 50 | expirationTime, err := time.Parse(time.RFC3339, claimData.Val()["expiration"]) 51 | if err != nil { 52 | // Invalid expiration (can't parse) 53 | return nil, &common.ApiError{ 54 | InternalError: err, 55 | ErrorCode: common.ErrorInvalidExpiration, 56 | StatusCode: 500, 57 | RequestId: requestid.Get(c), 58 | } 59 | } 60 | 61 | // Double check that claim is not expired 62 | if expirationTime.Before(time.Now()) { 63 | return nil, &common.ApiError{ 64 | ErrorCode: common.ErrorExpiredClaim, 65 | StatusCode: 400, 66 | RequestId: requestid.Get(c), 67 | } 68 | } 69 | 70 | session := claimData.Val()["session"] 71 | 72 | // Expire claim instantly 73 | redisClient.Del("claim:" + claim) 74 | redisClient.SRem("claim-user:"+user, claim) 75 | if session != "" { 76 | redisClient.SRem("claim-user-session:"+user+"-"+session, claim) 77 | } 78 | for _, channel := range strings.Split(claimData.Val()["channels"], ",") { 79 | redisClient.SRem("claim-channel:"+channel, claim) 80 | } 81 | 82 | return &Authentication{ 83 | User: user, 84 | Session: session, 85 | Channels: common.RemoveEmpty(strings.Split(claimData.Val()["channels"], ",")), 86 | }, nil 87 | } else if jwtToken := c.Query("jwt"); jwtToken != "" && options.Jwt.JwtSecret != "" { 88 | // Valid JWT (only enabled if `jwt_secret` is set) 89 | token, err := jwt.ParseWithClaims(jwtToken, &JwtClaims{}, func(token *jwt.Token) (interface{}, error) { 90 | return []byte(options.Jwt.JwtSecret), nil 91 | }) 92 | if err != nil { 93 | return nil, &common.ApiError{ 94 | InternalError: err, 95 | ErrorCode: common.ErrorInvalidJwt, 96 | StatusCode: 400, 97 | RequestId: requestid.Get(c), 98 | } 99 | } 100 | 101 | // JWT claims, not "claim" as above 102 | claims := token.Claims.(*JwtClaims) 103 | 104 | return &Authentication{ 105 | User: claims.Subject, 106 | Session: claims.Session, 107 | Channels: common.UniqueString(common.RemoveEmpty( 108 | claims.Channels, 109 | )), 110 | }, nil 111 | } else { 112 | return nil, &common.ApiError{ 113 | ErrorCode: common.ErrorMissingAuthentication, 114 | StatusCode: 400, 115 | RequestId: requestid.Get(c), 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /worker/build/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cretezy/dSock/cea9d8399c23d75d6668a0a7dfaeb8e5c1c01a80/worker/build/.gitkeep -------------------------------------------------------------------------------- /worker/channel_handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Cretezy/dSock/common" 5 | "github.com/Cretezy/dSock/common/protos" 6 | "github.com/gin-contrib/requestid" 7 | "github.com/gin-gonic/gin" 8 | "google.golang.org/protobuf/proto" 9 | "io/ioutil" 10 | "strings" 11 | ) 12 | 13 | func handleChannel(channelAction *protos.ChannelAction) { 14 | // Resolve all local connections for message target 15 | connections, ok := resolveConnections(common.ResolveOptions{ 16 | Connection: channelAction.Target.Connection, 17 | User: channelAction.Target.User, 18 | Session: channelAction.Target.Session, 19 | Channel: channelAction.Target.Channel, 20 | }) 21 | 22 | if !ok { 23 | return 24 | } 25 | 26 | // Apply to all connections for target 27 | for _, connection := range connections { 28 | connectionChannels := connection.GetChannels() 29 | if channelAction.Type == protos.ChannelAction_SUBSCRIBE && !common.IncludesString(connectionChannels, channelAction.Channel) { 30 | connection.SetChannels(append(connectionChannels, channelAction.Channel)) 31 | 32 | channels.Add(channelAction.Channel, connection.Id) 33 | 34 | redisClient.SAdd("channel:"+channelAction.Channel, connection.Id) 35 | } else if channelAction.Type == protos.ChannelAction_UNSUBSCRIBE && common.IncludesString(connectionChannels, channelAction.Channel) { 36 | connection.SetChannels(common.RemoveString(connectionChannels, channelAction.Channel)) 37 | 38 | channels.Remove(channelAction.Channel, connection.Id) 39 | 40 | redisClient.SRem("channel:"+channelAction.Channel, connection.Id) 41 | } else { 42 | // Don't set in Redis 43 | return 44 | } 45 | 46 | redisClient.HSet("conn:"+connection.Id, "channels", strings.Join(connection.GetChannels(), ",")) 47 | } 48 | } 49 | 50 | func channelMessageHandler(c *gin.Context) { 51 | requestId := requestid.Get(c) 52 | 53 | if c.ContentType() != common.ProtobufContentType { 54 | apiError := &common.ApiError{ 55 | ErrorCode: common.ErrorInvalidContentType, 56 | StatusCode: 400, 57 | RequestId: requestId, 58 | } 59 | apiError.Send(c) 60 | return 61 | } 62 | 63 | body, err := ioutil.ReadAll(c.Request.Body) 64 | if err != nil { 65 | apiError := &common.ApiError{ 66 | InternalError: err, 67 | ErrorCode: common.ErrorReadingBody, 68 | StatusCode: 400, 69 | RequestId: requestId, 70 | } 71 | apiError.Send(c) 72 | return 73 | } 74 | 75 | var message protos.ChannelAction 76 | 77 | err = proto.Unmarshal(body, &message) 78 | 79 | if err != nil { 80 | // Couldn't parse message 81 | apiError := &common.ApiError{ 82 | InternalError: err, 83 | ErrorCode: common.ErrorReadingBody, 84 | StatusCode: 400, 85 | RequestId: requestId, 86 | } 87 | apiError.Send(c) 88 | return 89 | } 90 | 91 | handleChannel(&message) 92 | 93 | c.AbortWithStatusJSON(200, gin.H{ 94 | "success": true, 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /worker/config.toml: -------------------------------------------------------------------------------- 1 | debug = true 2 | log_requests = true 3 | redis_db = 0 4 | redis_host = "redis:6379" 5 | redis_password = "" 6 | token = "abc123" 7 | jwt_secret = "abc123" 8 | default_channels = "global" 9 | messaging_method = "direct" 10 | 11 | # Worker only 12 | direct_message_hostname = "worker" 13 | direct_message_port = "80" 14 | ttl_duration = "60s" 15 | -------------------------------------------------------------------------------- /worker/connect_handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Cretezy/dSock/common/protos" 5 | "github.com/gin-contrib/requestid" 6 | "github.com/gin-gonic/gin" 7 | "github.com/go-redis/redis/v7" 8 | "github.com/google/uuid" 9 | "github.com/gorilla/websocket" 10 | "go.uber.org/zap" 11 | "strings" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | func connectHandler(c *gin.Context) { 17 | logger.Info("Getting new connection request", 18 | zap.String("requestId", requestid.Get(c)), 19 | zap.String("claim", c.Query("claim")), 20 | zap.String("jwt", c.Query("jwt")), 21 | ) 22 | 23 | // Authenticate client and get user/session 24 | authentication, apiError := authenticate(c) 25 | if apiError != nil { 26 | apiError.Send(c) 27 | return 28 | } 29 | 30 | logger.Info("Authenticated connection request", 31 | zap.String("requestId", requestid.Get(c)), 32 | zap.String("user", authentication.User), 33 | zap.String("session", authentication.Session), 34 | zap.Strings("channels", authentication.Channels), 35 | ) 36 | 37 | authentication.Channels = append(authentication.Channels, options.DefaultChannels...) 38 | 39 | // Upgrade to a WebSocket connection 40 | conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) 41 | if err != nil { 42 | logger.Warn("Could not upgrade request to WebSocket", 43 | zap.String("requestId", requestid.Get(c)), 44 | zap.Error(err), 45 | ) 46 | return 47 | } 48 | 49 | // Generate connection ID (random UUIDv4, can't be guessed) 50 | connId := uuid.New().String() 51 | 52 | logger.Info("Upgraded connection request", 53 | zap.String("requestId", requestid.Get(c)), 54 | zap.String("id", connId), 55 | ) 56 | 57 | // Channel that will be used to handleSend messages to the client 58 | sender := make(chan *protos.Message) 59 | 60 | // Add to memory cache 61 | connection := SockConnection{ 62 | Conn: conn, 63 | Id: connId, 64 | User: authentication.User, 65 | Session: authentication.Session, 66 | Sender: sender, 67 | CloseChannel: make(chan struct{}), 68 | channels: authentication.Channels, 69 | lastPing: time.Now(), 70 | } 71 | 72 | connections.Add(&connection) 73 | users.Add(connection.User, connId) 74 | for _, channel := range connection.channels { 75 | channels.Add(channel, connId) 76 | } 77 | 78 | connection.Refresh(redisClient) 79 | 80 | sendMutex := sync.Mutex{} 81 | 82 | // Send ping every minute 83 | go func() { 84 | for { 85 | time.Sleep(time.Second * 30) 86 | 87 | if connection.CloseChannel == nil { 88 | break 89 | } 90 | 91 | sendMutex.Lock() 92 | _ = conn.WriteMessage(websocket.PingMessage, []byte{}) 93 | sendMutex.Unlock() 94 | } 95 | }() 96 | 97 | // Message receiving loop (from client) 98 | go func() { 99 | ReceiveLoop: 100 | for { 101 | messageType, _, err := conn.ReadMessage() 102 | 103 | if err != nil { 104 | // Disconnect on error 105 | if connection.CloseChannel != nil { 106 | connection.CloseChannel <- struct{}{} 107 | } 108 | break 109 | } 110 | 111 | switch messageType { 112 | case websocket.CloseMessage: 113 | if connection.CloseChannel != nil { 114 | connection.CloseChannel <- struct{}{} 115 | } 116 | break ReceiveLoop 117 | // Handling receiving ping/pong 118 | case websocket.PingMessage: 119 | fallthrough 120 | case websocket.PongMessage: 121 | connection.lock.Lock() 122 | connection.lastPing = time.Now() 123 | connection.lock.Unlock() 124 | 125 | connection.Refresh(redisClient) 126 | break 127 | } 128 | 129 | } 130 | }() 131 | 132 | // Message sending loop (to client, from sending channel) 133 | SendLoop: 134 | for { 135 | select { 136 | case message := <-sender: 137 | sendMutex.Lock() 138 | _ = conn.WriteMessage(int(message.Type), message.Body) 139 | sendMutex.Unlock() 140 | break 141 | case <-connection.CloseChannel: 142 | logger.Info("Disconnecting user", 143 | zap.String("requestId", requestid.Get(c)), 144 | zap.String("id", connId), 145 | ) 146 | 147 | connection.CloseChannel = nil 148 | 149 | sendMutex.Lock() 150 | 151 | // Send close message with 1000 152 | _ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) 153 | // Sleep a tiny bit to allow message to be sent before closing connection 154 | time.Sleep(time.Millisecond) 155 | _ = conn.Close() 156 | 157 | redisClient.Del("conn:" + connId) 158 | redisClient.SRem("user:"+connection.User, connId) 159 | if connection.Session != "" { 160 | redisClient.SRem("user-session:"+connection.User+"-"+connection.Session, connId) 161 | } 162 | 163 | connections.Remove(connId) 164 | 165 | users.Remove(connection.User, connId) 166 | 167 | for _, channel := range connection.GetChannels() { 168 | channels.Remove(channel, connId) 169 | 170 | redisClient.SRem("channel:"+channel, connId) 171 | } 172 | 173 | break SendLoop 174 | } 175 | } 176 | } 177 | 178 | type SockConnection struct { 179 | /// WebSocket connection 180 | Conn *websocket.Conn 181 | Id string 182 | User string 183 | Session string 184 | /// Message sending channel. Messages sent to it will be sent to the connection 185 | Sender chan *protos.Message 186 | /// Channel to close the connect. nil when connection is closed/closing 187 | CloseChannel chan struct{} 188 | channels []string 189 | lastPing time.Time 190 | lock sync.RWMutex 191 | } 192 | 193 | func (connection *SockConnection) SetChannels(channels []string) { 194 | connection.lock.Lock() 195 | defer connection.lock.Unlock() 196 | 197 | connection.channels = channels 198 | } 199 | 200 | func (connection *SockConnection) GetChannels() []string { 201 | connection.lock.RLock() 202 | defer connection.lock.RUnlock() 203 | 204 | return connection.channels 205 | } 206 | 207 | func (connection *SockConnection) Refresh(redisCmdable redis.Cmdable) { 208 | connection.lock.RLock() 209 | defer connection.lock.RUnlock() 210 | 211 | redisConnection := map[string]interface{}{ 212 | "user": connection.User, 213 | "workerId": workerId, 214 | "lastPing": connection.lastPing.Format(time.RFC3339), 215 | "channels": strings.Join(connection.channels, ","), 216 | } 217 | if connection.Session != "" { 218 | redisConnection["session"] = connection.Session 219 | } 220 | 221 | redisCmdable.HSet("conn:"+connection.Id, redisConnection) 222 | redisCmdable.Expire("conn:"+connection.Id, options.TtlDuration*2) 223 | 224 | // Add user/session to Redis 225 | for _, channel := range connection.channels { 226 | redisCmdable.SAdd("channel:"+channel, connection.Id) 227 | } 228 | redisCmdable.SAdd("user:"+connection.User, connection.Id) 229 | if connection.Session != "" { 230 | redisCmdable.SAdd("user-session:"+connection.User+"-"+connection.Session, connection.Id) 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /worker/jwt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/dgrijalva/jwt-go" 4 | 5 | type JwtClaims struct { 6 | jwt.StandardClaims 7 | Session string `json:"sid,omitempty"` 8 | Channels []string `json:"channels,omitempty"` 9 | } 10 | -------------------------------------------------------------------------------- /worker/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/Cretezy/dSock/common" 6 | "github.com/Cretezy/dSock/common/protos" 7 | "github.com/gin-gonic/gin" 8 | "github.com/go-redis/redis/v7" 9 | "github.com/google/uuid" 10 | "github.com/gorilla/websocket" 11 | "go.uber.org/zap" 12 | "google.golang.org/protobuf/proto" 13 | "net/http" 14 | "os" 15 | "os/signal" 16 | "strconv" 17 | "syscall" 18 | "time" 19 | ) 20 | 21 | var upgrader = websocket.Upgrader{ 22 | ReadBufferSize: 1024, 23 | WriteBufferSize: 1024, 24 | CheckOrigin: func(r *http.Request) bool { 25 | return true 26 | }, 27 | EnableCompression: true, 28 | } 29 | 30 | var workerId = uuid.New().String() 31 | 32 | var users = usersState{ 33 | state: make(map[string][]string), 34 | } 35 | var channels = channelsState{ 36 | state: make(map[string][]string), 37 | } 38 | var connections = connectionsState{ 39 | state: make(map[string]*SockConnection), 40 | } 41 | 42 | var options *common.DSockOptions 43 | var logger *zap.Logger 44 | var redisClient *redis.Client 45 | 46 | func init() { 47 | var err error 48 | 49 | options, err = common.GetOptions(true) 50 | 51 | if err != nil { 52 | println("Could not get options. Make sure your config is valid!") 53 | panic(err) 54 | } 55 | 56 | if options.Debug { 57 | logger, err = zap.NewDevelopment() 58 | } else { 59 | logger, err = zap.NewProduction() 60 | } 61 | 62 | if err != nil { 63 | println("Could not create logger") 64 | panic(err) 65 | } 66 | } 67 | 68 | func main() { 69 | logger.Info("Starting dSock worker", 70 | zap.String("version", common.DSockVersion), 71 | zap.String("workerId", workerId), 72 | zap.Int("port", options.Port), 73 | zap.String("DEPRECATED.address", options.Address), 74 | ) 75 | 76 | // Setup application 77 | redisClient = redis.NewClient(options.RedisOptions) 78 | 79 | _, err := redisClient.Ping().Result() 80 | if err != nil { 81 | logger.Error("Could not connect to Redis (ping)", 82 | zap.Error(err), 83 | ) 84 | } 85 | 86 | if options.Debug { 87 | gin.SetMode(gin.DebugMode) 88 | } else { 89 | gin.SetMode(gin.ReleaseMode) 90 | } 91 | 92 | router := common.NewGinEngine(logger, options) 93 | router.Use(common.RequestIdMiddleware) 94 | 95 | router.Any(common.PathPing, common.PingHandler) 96 | router.GET(common.PathConnect, connectHandler) 97 | 98 | // Start HTTP server 99 | srv := &http.Server{ 100 | Addr: options.Address, 101 | Handler: router, 102 | } 103 | 104 | signalQuit := make(chan os.Signal, 1) 105 | 106 | RefreshWorker(redisClient) 107 | 108 | closeMessaging := func() {} 109 | 110 | go RefreshTtls() 111 | 112 | if options.MessagingMethod == common.MessageMethodRedis { 113 | logger.Info("Starting Redis messaging method", 114 | zap.String("workerId", workerId), 115 | ) 116 | 117 | // Loop receiving messages from Redis 118 | messageSubscription := redisClient.Subscribe(workerId) 119 | go func() { 120 | for { 121 | redisMessage, err := messageSubscription.ReceiveMessage() 122 | if err != nil { 123 | // TODO: Possibly add better handling 124 | logger.Error("Error receiving message from Redis", 125 | zap.Error(err), 126 | zap.String("workerId", workerId), 127 | ) 128 | break 129 | } 130 | 131 | go func() { 132 | var message protos.Message 133 | 134 | err = proto.Unmarshal([]byte(redisMessage.Payload), &message) 135 | 136 | if err != nil { 137 | // Couldn't parse message 138 | logger.Error("Invalid message received from Redis", 139 | zap.Error(err), 140 | zap.String("workerId", workerId), 141 | ) 142 | return 143 | } 144 | 145 | handleSend(&message) 146 | }() 147 | 148 | if signalQuit == nil { 149 | break 150 | } 151 | } 152 | }() 153 | 154 | // Loop receiving channel actions from Redis 155 | channelSubscription := redisClient.Subscribe(workerId + ":channel") 156 | go func() { 157 | for { 158 | redisMessage, err := channelSubscription.ReceiveMessage() 159 | if err != nil { 160 | // TODO: Possibly add better handling 161 | logger.Error("Error receiving message from Redis", 162 | zap.Error(err), 163 | zap.String("workerId", workerId), 164 | ) 165 | break 166 | } 167 | 168 | go func() { 169 | var channelAction protos.ChannelAction 170 | 171 | err = proto.Unmarshal([]byte(redisMessage.Payload), &channelAction) 172 | 173 | if err != nil { 174 | // Couldn't parse channel action 175 | logger.Error("Invalid message received from Redis", 176 | zap.Error(err), 177 | zap.String("workerId", workerId), 178 | ) 179 | return 180 | } 181 | 182 | handleChannel(&channelAction) 183 | }() 184 | 185 | if signalQuit == nil { 186 | break 187 | } 188 | } 189 | }() 190 | 191 | closeMessaging = func() { 192 | _ = messageSubscription.Close() 193 | _ = channelSubscription.Close() 194 | } 195 | } else { 196 | logger.Info("Starting direct messaging method", 197 | zap.String("workerId", workerId), 198 | zap.String("directHostname", options.DirectHostname), 199 | zap.Int("directPort", options.DirectPort), 200 | ) 201 | 202 | router.POST(common.PathReceiveMessage, sendMessageHandler) 203 | router.POST(common.PathReceiveChannelMessage, channelMessageHandler) 204 | } 205 | 206 | go func() { 207 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 208 | logger.Error("Failed listening", 209 | zap.Error(err), 210 | zap.String("workerId", workerId), 211 | ) 212 | options.QuitChannel <- struct{}{} 213 | } 214 | }() 215 | 216 | logger.Info("Listening", 217 | zap.String("address", options.Address), 218 | zap.String("workerId", workerId), 219 | ) 220 | 221 | // Listen for signal or message in quit channel 222 | signal.Notify(signalQuit, syscall.SIGINT, syscall.SIGTERM) 223 | 224 | select { 225 | case <-options.QuitChannel: 226 | case <-signalQuit: 227 | } 228 | 229 | // Server shutdown 230 | logger.Info("Shutting down", 231 | zap.String("workerId", workerId), 232 | ) 233 | 234 | // Cleanup 235 | closeMessaging() 236 | redisClient.Del("worker:" + workerId) 237 | 238 | // Disconnect all connections 239 | for _, connection := range connections.state { 240 | connection.CloseChannel <- struct{}{} 241 | } 242 | 243 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 244 | defer cancel() 245 | if err := srv.Shutdown(ctx); err != nil { 246 | logger.Error("Error during server shutdown", 247 | zap.Error(err), 248 | zap.String("workerId", workerId), 249 | ) 250 | } 251 | 252 | // Allow time to disconnect & clear from Redis 253 | time.Sleep(time.Second) 254 | 255 | logger.Info("Stopped", 256 | zap.String("workerId", workerId), 257 | ) 258 | _ = logger.Sync() 259 | } 260 | 261 | func RefreshWorker(redisCmdable redis.Cmdable) { 262 | redisWorker := map[string]interface{}{ 263 | "lastPing": time.Now().Format(time.RFC3339), 264 | } 265 | if options.MessagingMethod == common.MessageMethodDirect { 266 | redisWorker["ip"] = options.DirectHostname + ":" + strconv.Itoa(options.DirectPort) 267 | } 268 | 269 | redisClient.HSet("worker:"+workerId, redisWorker) 270 | redisClient.Expire("worker:"+workerId, options.TtlDuration*2) 271 | 272 | } 273 | -------------------------------------------------------------------------------- /worker/resolve_connections.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/Cretezy/dSock/common" 4 | 5 | func resolveConnections(options common.ResolveOptions) ([]*SockConnection, bool) { 6 | if options.Connection != "" { 7 | connectionEntry, connectionExists := connections.Get(options.Connection) 8 | 9 | if !connectionExists { 10 | // Connection doesnt' exist 11 | return []*SockConnection{}, true 12 | } 13 | 14 | return []*SockConnection{connectionEntry}, true 15 | } else if options.Channel != "" { 16 | channelEntry, exists := channels.Get(options.Channel) 17 | 18 | if !exists { 19 | // User doesn't exist 20 | return []*SockConnection{}, true 21 | } 22 | 23 | senders := make([]*SockConnection, 0) 24 | 25 | for _, connectionId := range channelEntry { 26 | connection, connectionExists := connections.Get(connectionId) 27 | // Target a specific session for a user if set 28 | if connectionExists && (options.Session == "" || connection.Session == options.Session) { 29 | senders = append(senders, connection) 30 | } 31 | } 32 | 33 | return senders, true 34 | } else if options.User != "" { 35 | usersEntry, exists := users.Get(options.User) 36 | 37 | if !exists { 38 | // User doesn't exist 39 | return []*SockConnection{}, true 40 | } 41 | 42 | senders := make([]*SockConnection, 0) 43 | 44 | for _, connectionId := range usersEntry { 45 | connection, connectionExists := connections.Get(connectionId) 46 | // Target a specific session for a user if set 47 | if connectionExists && (options.Session == "" || connection.Session == options.Session) { 48 | senders = append(senders, connection) 49 | } 50 | } 51 | 52 | return senders, true 53 | } else { 54 | // No target 55 | return []*SockConnection{}, false 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /worker/send_handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Cretezy/dSock/common" 5 | "github.com/Cretezy/dSock/common/protos" 6 | "github.com/gin-contrib/requestid" 7 | "github.com/gin-gonic/gin" 8 | "go.uber.org/zap" 9 | "google.golang.org/protobuf/proto" 10 | "io/ioutil" 11 | ) 12 | 13 | func handleSend(message *protos.Message) { 14 | logger.Info("Received send message", 15 | zap.String("target.connection", message.Target.Connection), 16 | zap.String("target.user", message.Target.User), 17 | zap.String("target.session", message.Target.Session), 18 | zap.String("target.channel", message.Target.Channel), 19 | zap.String("type", message.Type.String()), 20 | ) 21 | 22 | // Resolve all local connections for message target 23 | connections, ok := resolveConnections(common.ResolveOptions{ 24 | Connection: message.Target.Connection, 25 | User: message.Target.User, 26 | Session: message.Target.Session, 27 | Channel: message.Target.Channel, 28 | }) 29 | 30 | if !ok { 31 | return 32 | } 33 | 34 | // Send to all connections for target 35 | for _, connection := range connections { 36 | if connection.Sender == nil || connection.CloseChannel == nil { 37 | continue 38 | } 39 | 40 | connection := connection 41 | 42 | go func() { 43 | if message.Type == protos.Message_DISCONNECT { 44 | connection.CloseChannel <- struct{}{} 45 | } else { 46 | connection.Sender <- message 47 | } 48 | }() 49 | } 50 | } 51 | 52 | func sendMessageHandler(c *gin.Context) { 53 | requestId := requestid.Get(c) 54 | 55 | if c.ContentType() != common.ProtobufContentType { 56 | apiError := &common.ApiError{ 57 | ErrorCode: common.ErrorInvalidContentType, 58 | StatusCode: 400, 59 | RequestId: requestId, 60 | } 61 | apiError.Send(c) 62 | return 63 | } 64 | 65 | body, err := ioutil.ReadAll(c.Request.Body) 66 | if err != nil { 67 | apiError := &common.ApiError{ 68 | InternalError: err, 69 | ErrorCode: common.ErrorReadingBody, 70 | StatusCode: 400, 71 | RequestId: requestId, 72 | } 73 | apiError.Send(c) 74 | return 75 | } 76 | 77 | var message protos.Message 78 | 79 | err = proto.Unmarshal(body, &message) 80 | 81 | if err != nil { 82 | // Couldn't parse message 83 | apiError := &common.ApiError{ 84 | InternalError: err, 85 | ErrorCode: common.ErrorReadingBody, 86 | StatusCode: 400, 87 | RequestId: requestId, 88 | } 89 | apiError.Send(c) 90 | return 91 | } 92 | 93 | handleSend(&message) 94 | 95 | c.AbortWithStatusJSON(200, gin.H{ 96 | "success": true, 97 | }) 98 | } 99 | -------------------------------------------------------------------------------- /worker/state.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Cretezy/dSock/common" 5 | "sync" 6 | ) 7 | 8 | type connectionsState struct { 9 | state map[string]*SockConnection 10 | mutex sync.RWMutex 11 | } 12 | 13 | func (connections *connectionsState) Add(connection *SockConnection) { 14 | connections.mutex.Lock() 15 | defer connections.mutex.Unlock() 16 | 17 | connections.state[connection.Id] = connection 18 | } 19 | 20 | func (connections *connectionsState) Remove(id string) { 21 | connections.mutex.Lock() 22 | defer connections.mutex.Unlock() 23 | 24 | delete(connections.state, id) 25 | } 26 | 27 | func (connections *connectionsState) Get(id string) (*SockConnection, bool) { 28 | connections.mutex.RLock() 29 | defer connections.mutex.RUnlock() 30 | 31 | connectionEntry, connectionExists := connections.state[id] 32 | return connectionEntry, connectionExists 33 | } 34 | 35 | type usersState struct { 36 | state map[string][]string 37 | mutex sync.RWMutex 38 | } 39 | 40 | func (users *usersState) Add(user string, connection string) { 41 | users.mutex.Lock() 42 | defer users.mutex.Unlock() 43 | 44 | usersEntry, usersExists := users.state[user] 45 | if usersExists { 46 | users.state[user] = append(usersEntry, connection) 47 | } else { 48 | users.state[user] = []string{connection} 49 | } 50 | } 51 | 52 | func (users *usersState) Remove(user string, connection string) { 53 | users.mutex.Lock() 54 | defer users.mutex.Unlock() 55 | 56 | usersEntry, usersExists := users.state[user] 57 | if usersExists { 58 | users.state[user] = common.RemoveString(usersEntry, connection) 59 | } 60 | } 61 | 62 | func (users *usersState) Get(user string) ([]string, bool) { 63 | users.mutex.RLock() 64 | defer users.mutex.RUnlock() 65 | 66 | userEntry, userExists := users.state[user] 67 | return userEntry, userExists 68 | } 69 | 70 | type channelsState struct { 71 | state map[string][]string 72 | mutex sync.RWMutex 73 | } 74 | 75 | func (channels *channelsState) Add(channel string, connection string) { 76 | channels.mutex.Lock() 77 | defer channels.mutex.Unlock() 78 | 79 | channelEntry, channelExists := channels.state[channel] 80 | if channelExists { 81 | channels.state[channel] = append(channelEntry, connection) 82 | } else { 83 | channels.state[channel] = []string{connection} 84 | } 85 | } 86 | 87 | func (channels *channelsState) Remove(channel string, connection string) { 88 | channels.mutex.Lock() 89 | defer channels.mutex.Unlock() 90 | 91 | channelEntry, channelExists := channels.state[channel] 92 | if channelExists { 93 | channels.state[channel] = common.RemoveString(channelEntry, connection) 94 | } 95 | } 96 | 97 | func (channels *channelsState) Get(channel string) ([]string, bool) { 98 | channels.mutex.RLock() 99 | defer channels.mutex.RUnlock() 100 | 101 | channelEntry, channelExists := channels.state[channel] 102 | return channelEntry, channelExists 103 | } 104 | -------------------------------------------------------------------------------- /worker/ttl.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/go-redis/redis/v7" 5 | "go.uber.org/zap" 6 | "time" 7 | ) 8 | 9 | func RefreshTtls() { 10 | nextTime := time.Now() 11 | 12 | for { 13 | nextTime = nextTime.Add(options.TtlDuration) 14 | time.Sleep(time.Until(nextTime)) 15 | 16 | _, err := redisClient.Pipelined(func(pipeliner redis.Pipeliner) error { 17 | RefreshWorker(pipeliner) 18 | 19 | for _, connection := range connections.state { 20 | connection.Refresh(pipeliner) 21 | } 22 | 23 | return nil 24 | }) 25 | 26 | if err != nil { 27 | logger.Error("Could not refresh TTLs", 28 | zap.Error(err), 29 | zap.String("workerId", workerId), 30 | ) 31 | 32 | continue 33 | } 34 | 35 | logger.Info("Refreshed TTLs", 36 | zap.String("workerId", workerId), 37 | ) 38 | } 39 | } 40 | --------------------------------------------------------------------------------