├── .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 | 
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 |
--------------------------------------------------------------------------------