dSock
dSock is a distributed WebSocket broker (in Go, using Redis).
Clients can authenticate & connect, and you can send text/binary message as an API.
Features
Multiple clients per user & authentication
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).
Distributed
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.
Text & binary messaging
dSock is designed for text and binary messaging, enabling JSON (UTF-8), Protocol Buffers, or any custom protocol.
Lightweight & fast
dSock utilized Go's concurrency for great performance at scale, with easy distribution and safety. It is available as Docker images for convenience.
Disconnects
Disconnect clients from an external event (logout) from a session ID or for all user connections.
Uses
The main use case for dSock is having stateful WebSocket connections act as a stateless API.
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.
Chat service
Clients connect to dSock, and your back-end can broadcast messages to a specific user's clients
More!
Clients
Use a client to interact with the dSock API easily. Your language missing? Open a ticket!
Architecture
dSock is separated into 2 main services:
-
dSock Worker This is the main server clients connect to. The worker distributed the messages to the clients ("last mile")
-
dSock API The API receives messages and distributes it to the workers for target clients
This allows the worker (connections) and API (gateway) to scale independently and horizontally.
dSock uses Redis as a backend data store, to store connection locations and claims.
Terminology
Word | |
---|---|
WebSocket | Sockets "over" HTTP(S) |
JWT | JSON Web Token |
Claim | dSock authentication mention using a pre-registered claim ("token") |
Redis | Open-source in-memory key-value database |
Flow
- Authentication:
- Client does request to your API, you either:
- Hit the dSock API to create a claim for the user
- Generate & sign a JWT for the user
- You return the claim or JWT to client
- Client does request to your API, you either:
- Connection:
- User connects to a worker with claim or JWT
- Sending:
- You hit the dSock API (
POST /send
) with the target (user
,session
optionally) and the message as body - Message sent to target(s)
- You hit the dSock API (
Setup
Installation
dSock is published as binaries and as Docker images.
Binaries
Binaries are available on the releases pages.
You can simply run the binary for your architecture/OS.
You can configure dSock using environment variables or a config (see below).
Docker images
Docker images are published on Docker Hub:
The images are small (~15MB) and expose on port 80
by default (controllable by setting the PORT
environment variable).
It is recommended to use the environment variables to configure dSock instead of a config when using the images.
Configs are still supported (can be mounted to /config.toml
or /config.$EXT
, see below).
Options
dSock can be configured using a config file or using environment variables.
PORT
(port
, integer, orDSOCK_PORT
environment variable): Port to listen to. Defaults to6241
DSOCK_ADDRESS
(address
, string, deprecated):: Address to listen to. Defaults to:6241
. Uses port if empty.- Redis:
DSOCK_REDIS_HOST
(redis_host
, string): Redis host. Defaults tolocalhost:6379
DSOCK_REDIS_PASSWORD
(redis_password
, string): Redis password. Defaults to no passwordDSOCK_REDIS_DB
(redis_db
, integer): Redis database. Defaults to0
DSOCK_REDIS_MAX_RETRIES
(redis_max_retries
, integer): Maximum retries before failing Redis connection. Defaults to10
DSOCK_REDIS_TLS
(redis_tls
, boolean): Whether to enable TLS for Redis. Defaults tofalse
DSOCK_DEFAULT_CHANNELS
(default_channels
, comma-delimited string, optional): When set, clients will be automatically subscribed to these channels- Authentication:
DSOCK_TOKEN
(token
, string): Authentication token to do requests to the APIDSOCK_JWT_SECRET
(jwt_secret
, string, optional): When set, enables JWT authentication
DSOCK_DEBUG
(debug
, boolean): Enables debugging, useful for development. Defaults tofalse
DSOCK_LOG_REQUESTS
(log_requests
, boolean): Enables request logging. Defaults tofalse
DSOCK_MESSAGING_METHOD
(messaging_method
, string): The messages method for communication from API to worker. Can be:redis
,direct
. Defaults toredis
Worker only
DSOCK_DIRECT_MESSAGE_HOSTNAME
(direct_message_hostname
, string, worker only): Ifmethod_method
is set todirect
, this is the hostname of the worker accessible from the API. Defaults to first local non-loopback IPv4DSOCK_DIRECT_MESSAGE_PORT
(direct_message_port
, string, worker only): Ifmethod_method
is set todirect
, this is the port that the worker is listening on. Defaults to portDSOCK_TTL_DURATION
(ttl_duration
, string duration, worker only): How often to refresh worker/connection keys in Redis. Uses Go duration parsing. Defaults to60s
(should not be lower than10s
)
You can write your config file in TOML (recommended), JSON, YAML, or any format supported by viper
Configs are loaded from (in order):
$PWD/config.$EXT
$HOME/.config/dsock/config.$EXT
/etc/dsock/config.$EXT
A default config will be created at $PWD/config.toml
if no config is found.
Usage
All API calls will return a success
boolean.
If it is false
, it will also add error
(message) and errorCode
(constant from common/errors.go
).
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
.
Having an invalid or missing token will result in the INVALID_AUTHORIZATION
error code.
Most errors starting with ERROR_
are downstream errors, usually from Redis. Check if your Redis connection is valid!
When targeting, the precedence order is: id
, channel
, user
.
Client authentication
Claims
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.
Once a client has a claim, it can then connect to the worker using the claim
query parameter.
You can create them by accessing the API as POST /claim
with the following query options:
user
(required, string): The user IDsession
(optional, string): The session ID (scoped per user)
channels
(optional, comma-delimited string): Channels to subscribe on join (merged withdefault_channels
)- Time-related (not required, default expiration is 1 minute after the claim is created, only one used):
expiration
(integer, seconds from epoch): Time the claim expires (takes precedence overduration
)duration
(integer, seconds): Duration of the claim
token
(required, string): Authorization token for API set in config. Can also be aAuthorization
Bearer tokenid
(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)
The returned body will contain the following keys:
claim
: The claim dataid
: The claim IDexpiration
: The expiration in seconds from epochuser
: The user for the claimsession
(if session is provided): The user session for the claimchannels
: The channels to subscribe on join (excludes defaults)
A claim is single-use, so once a client connects, it will instantly expire.
Examples
Create a claim for a user (1
) expiring in 10 seconds, with 2 channels:
POST /claim?token=abcxyz&user=1&duration=10&channels=group-1,group-2
Create a claim for a user (1
) with a session (a
) with a claim ID (a1b2c3
) expiring at some time:
POST /claim?user=1&session=a&expiration=1588473164&id=a1b2c3
Authorization: Bearer abcxyz
Errors
Creating a claim has the follow possible errors:
USER_ID_REQUIRED
: If theuser
parameter is not setINVALID_EXPIRATION
: If the expiration is invalid (not parsable as integer)NEGATIVE_EXPIRATION
: If the expiration is negativeINVALID_DURATION
: If the duration is invalid (not parsable as integer)NEGATIVE_DURATION
: If the duration is negativeERROR_CHECKING_CLAIM
: If an error occurred during checking if a claim exist (Redis error)CLAIM_ID_ALREADY_USED
: If the claim ID is set and is already used
JWT
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)
Payload options:
sub
(required, string): The user IDsid
(optional, string): The session ID (scoped per user)channels
(optional, array of string): Channels to subscribe on join (merged withdefault_channels
)- Time-related (one is required):
Client connections
Connect using a WebSocket to ws://worker/connect
with the one of the following query parameter options:
claim
: The authentication claim created previously (takes precedence overjwt
)jwt
: JWT created previously
You can load-balance a cluster of workers, as long as the load-balancer supports WebSockets.
Errors
The following errors can happen during connection:
ERROR_GETTING_CLAIM
: If an error occurred during fetching the claim (Redis error)MISSING_CLAIM
: If the claim ID doesn't exists. This can also happen if the claim has expiredINVALID_EXPIRATION
: If the claim has an invalid expiration (shouldn't happen unless Redis error)EXPIRED_CLAIM
: If the claim has expired, but Redis hasn't expired the claim on it's ownINVALID_JWT
: If the JWT is malformed (bad JSON/JWT format) or is not signed with proper keyMISSING_AUTHENTICATION
: If no authentication is provided (no claim/JWT)
Sending message
Sending a message is done through the POST /send
API endpoint.
Query param options:
- Targeting (one is required):
user
(string): The user ID to targetsession
(optional, string, whenuser
is set): The specific session(s) to target from the user
id
(string UUID): The specific internal connection IDchannel
(string): The channel to target
type
(required, string): Message (body) type. Can betext
(UTF-8 text) orbinary
. This becomes the WebSocket message type.token
(required, string): Authorization token for API set in config. Can also be aAuthorization
Bearer token
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).
Examples
Send a JSON message to a user (1
)
POST /send?token=abcxyz&user=1&type=text
{"message":"Hello world!","from":"Charles"}
Send a text value to a user (1
) with a session (a
)
POST /send?user=1&session=a&type=text
Authorization: Bearer abcxyz
<Cretezy> Hey!
Send a binary value to all clients subscribed in a channel:
POST /send?channel=group-1&type=binary
Authorization: Bearer abcxyz
# Binary...
Errors
The following errors can happen during sending a message:
INVALID_AUTHORIZATION
: Invalid authentication (token). See errors section under usageERROR_GETTING_CONNECTION
: If could not fetch connection(s) (Redis error)ERROR_GETTING_USER
: Ifuser
is set and could not fetch user (Redis error)ERROR_GETTING_CHANNEL
: Ifchannel
is set and could not fetch channel (Redis error)MISSING_TARGET
: If target is not providerINVALID_MESSAGE_TYPE
: If thetype
is invalidERROR_READING_MESSAGE
: If an error occurred during reading the request bodyERROR_MARSHALLING_MESSAGE
: If an error occurred during preparing to send the message to the workers (shouldn't happen)
Disconnecting
You can disconnect a client by user (and optionally session) ID.
This is useful when logging out a user, to make sure it also disconnects any connections. Make sure to include a session in your claim/JWT to be able to disconnect only some of a user's connections.
The API endpoint is POST /disconnect
, with the following query params:
- Targeting (one is required):
user
(string): The user ID to targetsession
(optional, string, whenuser
is set): The specific session(s) to target from the user
id
(string UUID): The specific internal connection IDchannel
(string): The channel to target
token
(required, string): Authorization token for API set in config. Can also be aAuthorization
Bearer tokenkeepClaims
(optional, boolean): When set totrue
, keeps active claims for the target. By default, dSock will remove claims for the target to prevent race conditions
Examples
Disconnect a user (1
) with a session (a
):
POST /send?token=abcxyz&user=1&session=a
Errors
The following errors can happen during disconnection:
INVALID_AUTHORIZATION
: Invalid authentication (token). See errors section under usageERROR_GETTING_CONNECTION
: If could not fetch connection(s) (Redis error)ERROR_GETTING_USER
: Ifuser
is set and could not fetch user (Redis error)ERROR_GETTING_CHANNEL
: Ifchannel
is set and could not fetch channel (Redis error)MISSING_TARGET
: If target is not providerERROR_GETTING_CLAIM
: If an error occurred during fetching the claim(s) (Redis error)ERROR_MARSHALLING_MESSAGE
: If an error occurred during preparing to send the message to the workers (shouldn't happen)
Info
You can access info about connections and claims using the GET /info
API endpoint. The following query params are supported:
- Targeting (one is required):
user
(string): The user ID to querysession
(optional, string, whenuser
is set): The specific session(s) to query from the user
id
(string UUID): The specific internal connection IDchannel
(string): The channel to query
token
(required, string): Authorization token for API set in config. Can also be aAuthorization
Bearer token
The API will return all opened connections and non-expired claims for the target.
The returned object contains:
connections
(array of objects): List of open connections for the targetid
: Internal connection IDworker
: Internal worker holding the connectionlastPing
: Last ping from client in seconds from epochuser
: The connection's usersession
(optional): The connection's sessionchannels
: The connection's subscribe channels (includesdefault_channels
)
claims
(array of objects): List of non-expired claims for the target:id
: Claim ID (what a client would connect with)expiration
: Claim expiration in seconds from epochuser
: The claim's usersession
(optional): The claim's session
Examples
Get info for a user (1
) with a session (a
):
GET /info?token=abcxyz&user=1&session=a
Errors
The following errors can happen during getting info:
INVALID_AUTHORIZATION
: Invalid authentication (token). See errors section under usageERROR_GETTING_CLAIM
: If an error occurred during fetching the claim(s) (Redis error)ERROR_GETTING_CONNECTION
: If could not fetch connection(s) (Redis error)ERROR_GETTING_USER
: Ifuser
is set and could not fetch user (Redis error)ERROR_GETTING_CHANNEL
: Ifchannel
is set and could not fetch channel (Redis error)MISSING_TARGET
: If target is not provider
Channels
You can subscribe/unsubscribe clients to a channel using POST /channel/subscribe/$CHANNEL
or POST /channel/unsubscribe/$CHANNEL
.
This will subscribe the connections and claims (optional) for the target provided.
The follow query parameters are accepted:
- Targeting (one is required):
user
(string): The user ID to querysession
(optional, string, whenuser
is set): The specific session(s) to query from the user
id
(string UUID): The specific internal connection IDchannel
(string): The channel to query
ignoreClaims
(optional, boolean): When set totrue
, 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)token
(required, string): Authorization token for API set in config. Can also be aAuthorization
Bearer token
Examples
Subscribe a user (1
) to a channel (a
):
POST /channel/subscribe/a?token=abcxyz&user=1
Unsubscribe all clients in a channel from a channel (a
):
POST /channel/unsubscribe/a?token=abcxyz&channel=a
Errors
The following errors can happen during channel subscription/unsubscription:
INVALID_AUTHORIZATION
: Invalid authentication (token). See errors section under usageERROR_GETTING_CONNECTION
: If could not fetch connection(s) (Redis error)ERROR_GETTING_USER
: Ifuser
is set and could not fetch user (Redis error)ERROR_GETTING_CHANNEL
: Ifchannel
is set and could not fetch channel (Redis error)MISSING_TARGET
: If target is not providerERROR_MARSHALLING_MESSAGE
: If an error occurred during preparing to send the message to the workers (shouldn't happen)
Internals
dSock uses Redis as it's database (for claims and connection information) and for it's publish/subscribe capabilities. Redis was chosen because it is widely used, is performant, and supports all requried features.
Claims
When creating a claim, dSock does the following operations:
- Set
claim:$id
to the claim information (user, session, expiration) - Add the claim ID to
claim-user:$user
(to be able to lookup all of a user's claims) - 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) - Add the claim ID to
claim-channel:$channel
if channel is passed (to be able to lookup all of a channel's claims)
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.
When getting information or disconnecting, it retrieves or deletes the claim(s).
Connections
When a user connects and authenticates, dSock does the following operations:
- Set
conn:$id
to the connection's information (using a random UUID, with user, session, worker ID, and last ping) - Add the connection ID to
user:$user
(to be able to lookup all of a user's connections) - 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) - Add the connection ID to
channel:$channel
(for each channel in authentication, to be able to lookup all of a channel's connections)
When receiving a ping or pong from the client, it updates the last ping time. A ping is sent from the server every minute.
Connections are kept alive until a client disconnects, or is forcibly disconnected using POST /disconnect
Sending
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
).
API to worker messages are encoded using Protocol Buffer for efficiency; they are fast to encode/decode, and binary messages to not need to be encoded as strings during communication.
Channels
Channels are assosiated to claims/JWTs (before a client connects) and connections.
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),
and broadcasts to workers with connections that are connected through the $workerId:channel
Redis channel.
The worker then resolves all connections for the target and adds them to the channel.
Channels are found under channel:$channel
and contain the list of connection IDs which are subscribed.
Claim channels are found under claim-channel:$channel
and contain the list of claim IDs which will become subscribed,
and is also stored under channels
in the claim.
FAQ
Why is built-in HTTPS not supported?
To remove complexity inside dSock, TLS is not implemented. It is expected that the API and worker nodes are behind load-balancers, which would be able to do TLS termination.
If you need TLS, you can either add a TLS-terminating load-balancer, or a reverse proxy (such as nginx or Caddy).
How can I do a health-check on dSock?
You can use the /ping
endpoint on the API & worker to monitor if the service is up. It will response pong
.
Development
Setup
- Install Go
- Install Docker and Docker Compose
- Install Task
- Pull the dSock repository
- Run
docker-compose up
- Develop! API is available at
:3000
, and worker at:3001
. Configs are in their respective folders
Protocol Buffers
If making changes to the Protocol Buffer definitions (under protos
), make sure you have the protoc
compiler and protoc-gen-go
.
Once changes are done to the definitions, run task build:protos
to generate the associated Go code.
Docker
You can build the Docker images by running task build:docker
. This will create the dsock-worker
and dsock-api
images.
Tests
dSock has multiple types of tests to ensure stability and maximum coverage.
You can run all tests by running task tests
. You can also run individual test suites (see below)
End-to-end (E2E)
You can run the E2E tests by running task tests:e2e
. The E2E tests are located inside the e2e
directory.
Unit
You can run the unit tests by running task tests:unit
. The units tests are located inside the common
/api
/worker
directories.
Contributing
Pull requests are encouraged!
License & support
dSock is MIT licensed (see license).
Community support is available through GitHub issues. For professional support, please contact [email protected].
Credit
Icon made by Freepik from flaticon.com.
Project was created & currently maintained by Charles Crete.