production-related changes for video calls

This commit is contained in:
or-else
2022-06-12 15:33:41 -07:00
parent d476ef1ec2
commit b65161cf17
13 changed files with 191 additions and 57 deletions

View File

@ -179,6 +179,16 @@ A bash script [run-cluster.sh](./server/run-cluster.sh) may be found useful.
Follow [instructions](./docs/faq.md#q-how-to-setup-push-notifications-with-google-fcm).
### Enabling Video Calls
Video calls use [WebRTC](https://en.wikipedia.org/wiki/WebRTC). WebRTC is a peer to peer protocol: once the call is established, the client applications exchange data directly. Direct data exchange is efficient but creates a problem when the parties are not accessible from the internet. WebRTC solves it by means of [ICE](https://en.wikipedia.org/wiki/Interactive_Connectivity_Establishment) servers which implement protocols [TURN(S)](https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT) and [STUN](https://en.wikipedia.org/wiki/STUN) as fallback.
Tinode does not provide ICE servers out of the box. You must install and configure (or purchase) your own servers otherwise video and voice calling will not be available.
Once you obtain the ICE TURN/STUN configuration from your service provider, add it to `tinode.conf` section `"webrtc"` - `"ice_servers"` (or `"ice_servers_file"`). Also change `"webrtc"` - `"enabled"` to `true`. An example configuration is provided in the `tinode.conf` for illustration only. IT WILL NOT FUNCTION because it uses dummy values instead of actual server addresses.
### Note on Running the Server in Background
There is [no clean way](https://github.com/golang/go/issues/227) to daemonize a Go process internally. One must use external tools such as shell `&` operator, `systemd`, `launchd`, `SMF`, `daemon tools`, `runit`, etc. to run the process in the background.

View File

@ -83,6 +83,7 @@ When you register a new account you are asked for an email address to send valid
* Scriptable [command line](tn-cli/) (Python)
* User features:
* One-on-one and group messaging. Voice messages.
* Video and audio calling.
* Channels with unlimited number of read-only subscribers.
* Granular access control with permissions for various actions.
* User search/discovery.
@ -113,7 +114,6 @@ When you register a new account you are asked for an email address to send valid
### Planned
* [Federation](https://en.wikipedia.org/wiki/Federation_(information_technology)).
* Video or audio calling.
* Location sharing.
* Previews of attached videos, documents, links.
* Hot standby.

View File

@ -143,13 +143,14 @@ You can specify the following environment variables when issuing `docker run` co
| `DEBUG_EMAIL_VERIFICATION_CODE` | string | | Enable dummy email verification code, e.g. `123456`. Disabled by default (empty string). |
| `EXT_CONFIG` | string | | Path to external config file to use instead of the built-in one. If this parameter is used all other variables except `RESET_DB`, `FCM_SENDER_ID`, `FCM_VAPID_KEY` are ignored. |
| `EXT_STATIC_DIR` | string | | Path to external directory containing static data (e.g. Tinode Webapp files) |
| `FCM_CRED_FILE` | string | | Path to json file with FCM server-side service account credentials which will be used to send push notifications. |
| `FCM_CRED_FILE` | string | | Path to JSON file with FCM server-side service account credentials which will be used to send push notifications. |
| `FCM_API_KEY` | string | | Firebase API key; required for receiving push notifications in the web client |
| `FCM_APP_ID` | string | | Firebase web app ID; required for receiving push notifications in the web client |
| `FCM_PROJECT_ID` | string | | Firebase project ID; required for receiving push notifications in the web client |
| `FCM_SENDER_ID` | string | | Firebase FCM sender ID; required for receiving push notifications in the web client |
| `FCM_VAPID_KEY` | string | | Also called 'Web Client certificate' in the FCM console; required by the web client to receive push notifications. |
| `FCM_INCLUDE_ANDROID_NOTIFICATION` | boolean | true | If true, pushes a data + notification message, otherwise a data-only message. [More info](https://firebase.google.com/docs/cloud-messaging/concept-options). |
| `ICE_SERVERS_FILE` | string | | Path to JSON file with configuration of ICE servers to be used for video calls. |
| `MEDIA_HANDLER` | string | `fs` | Handler of large files, either `fs` or `s3` |
| `MYSQL_DSN` | string | `'root@tcp(mysql)/tinode'` | MySQL [DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name). |
| `PLUGIN_PYTHON_CHAT_BOT_ENABLED` | bool | `false` | Enable calling into the plugin provided by Python chatbot |

View File

@ -102,6 +102,10 @@ ENV TNPG_AUTH_TOKEN=
# Tinode Push Gateway organization name as registered at console.tinode.co
ENV TNPG_ORG=
# Video calls configuration.
ENV WEBRTC_ENABLED=false
ENV ICE_SERVERS_FILE=
# Use the target db by default.
# When TARGET_DB is "alldbs", it is the user's responsibility
# to set STORE_USE_ADAPTER to the desired db adapter correctly.

View File

@ -141,10 +141,15 @@
}
],
"webrtc": {
"enabled": $WEBRTC_ENABLED,
"call_establishment_timeout": 30,
"ice_servers_file": "$ICE_SERVERS_FILE"
},
"cluster_config": {
"self": "",
"nodes": [
// Name and TCP address of each node.
{"name": "tinode-0", "addr": "tinode-0:12000"},
{"name": "tinode-1", "addr": "tinode-1:12001"},
{"name": "tinode-2", "addr": "tinode-2:12002"}
@ -171,5 +176,4 @@
"service_addr": "tcp://localhost:40051"
}
]
}

View File

@ -39,6 +39,10 @@ else
TNPG_PUSH_ENABLED=true
fi
if [ ! -z "$ICE_SERVERS_FILE" ] ; then
WEBRTC_ENABLED=true
fi
# Generate a new 'working.config' from template and environment
while IFS='' read -r line || [[ -n $line ]] ; do
while [[ "$line" =~ (\$[A-Z_][A-Z_0-9]*) ]] ; do

View File

@ -64,7 +64,7 @@ If key is provided, it's a 0-based index into the `ent` array which contains ext
* `LN`: link (URL) [https://api.tinode.co](https://api.tinode.co).
* `MN`: mention such as [@tinode](#).
* `RW`: logical grouping of formats, a row; may also be represented as a basic decoration.
* `VC`: reserved for video and audio calls (not implemented yet).
* `VC`: video (and audio) calls.
Examples:
* `{ "at":8, "len":4, "tp":"ST"}`: apply formatting `ST` (strong/bold) to 4 characters starting at offset 8 into `txt`.
@ -291,3 +291,27 @@ Hashtag `data` contains a single `val` field with the hashtag value which the cl
```js
{ "tp":"HT", "data":{ "val":"tinode" } }
```
#### `VC`: video call control message
Video call `data` contains current state of the call and its duration:
```js
{
"tp": "VC",
"data": {
"duration": 10000,
"state": "disconnected",
"incoming": false
}
}
```
* `duration`: call duration in milliseconds.
* `state`: surrent call state; supported states:
* `accepted`: a call is established (ongoing).
* `finished`: a previously establied call has successfully finished.
* `disconnected`: the call is dropped for example because of an error.
* `missed`: the call is missed, i.e. the callee didn't pick up the phone.
* `declined`: the call is declined, i.e. the callee hung up before picking up.
* `incoming`: if the call is incoming or outgoing.
The `VC` may also be represented as a format `"fmt": [{"len": 1, "tp": "VC"}]` with no entity. In such a case all call information is contained in the `head` fields of the enclosing message.

View File

@ -7,11 +7,16 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store/types"
jcr "github.com/tinode/jsonco"
)
// Video call constants.
@ -44,6 +49,25 @@ const (
constCallMsgDeclined = "declined"
)
type callConfig struct {
// Enable video/voice calls.
Enabled bool `json:"enabled"`
// Timeout in seconds before a call is dropped if not answered.
CallEstablishmentTimeout int `json:"call_establishment_timeout"`
// ICE servers.
ICEServers []iceServer `json:"ice_servers"`
// Alternative config as an external file.
ICEServersFile string `json:"ice_servers_file"`
}
// ICE server config.
type iceServer struct {
Username string `json:"username,omitempty"`
Credential string `json:"credential,omitempty"`
CredentialType string `json:"credential_type,omitempty"`
Urls []string `json:"urls,omitempty"`
}
// videoCall describes video call that's being established or in progress.
type videoCall struct {
// Call participants.
@ -58,6 +82,62 @@ type videoCall struct {
acceptedAt time.Time
}
func initVideoCalls(jsconfig json.RawMessage) error {
var config callConfig
if len(jsconfig) == 0 {
return nil
}
if err := json.Unmarshal([]byte(jsconfig), &config); err != nil {
return fmt.Errorf("failed to parse config: %w", err)
}
if !config.Enabled {
logs.Info.Println("Video calls disabled")
return nil
}
if len(config.ICEServers) > 0 {
globals.iceServers = config.ICEServers
} else if config.ICEServersFile != "" {
var iceConfig []iceServer
if file, err := os.Open(config.ICEServersFile); err != nil {
return fmt.Errorf("failed to read ICE config: %w", err)
} else {
jr := jcr.New(file)
if err = json.NewDecoder(jr).Decode(&iceConfig); err != nil {
switch jerr := err.(type) {
case *json.UnmarshalTypeError:
lnum, cnum, _ := jr.LineAndChar(jerr.Offset)
return fmt.Errorf("unmarshall error in ICE config in %s at %d:%d (offset %d bytes): %w",
jerr.Field, lnum, cnum, jerr.Offset, jerr)
case *json.SyntaxError:
lnum, cnum, _ := jr.LineAndChar(jerr.Offset)
return fmt.Errorf("syntax error in config file at %d:%d (offset %d bytes): %w",
lnum, cnum, jerr.Offset, jerr)
default:
return fmt.Errorf("failed to parse config file: %w", err)
}
}
file.Close()
}
globals.iceServers = iceConfig
}
if len(globals.iceServers) == 0 {
return errors.New("no valid ICE cervers found")
}
globals.callEstablishmentTimeout = config.CallEstablishmentTimeout
if globals.callEstablishmentTimeout <= 0 {
globals.callEstablishmentTimeout = defaultCallEstablishmentTimeout
}
logs.Info.Println("Video calls enabled with ", len(globals.iceServers), "ICE servers")
return nil
}
func (call *videoCall) messageHead(newState string, duration int) map[string]interface{} {
head := map[string]interface{}{
"replace": ":" + strconv.Itoa(call.seq),

View File

@ -1620,6 +1620,11 @@ func ErrNotImplemented(id, topic string, serverTs, incomingReqTs time.Time) *Ser
}
}
// ErrNotImplementedReply feature not implemented error in response to a client request (501).
func ErrNotImplementedReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
return ErrNotImplemented(msg.Id, msg.Original, ts, msg.Timestamp)
}
// ErrClusterUnreachable in-cluster communication has failed (502).
func ErrClusterUnreachable(id, topic string, ts time.Time) *ServerComMessage {
return &ServerComMessage{

View File

@ -126,14 +126,6 @@ type credValidator struct {
addToTags bool
}
// ICE server config (video calling).
type iceServer struct {
Username string `json:"username,omitempty"`
Credential string `json:"credential,omitempty"`
CredentialType string `json:"credential_type,omitempty"`
Urls []string `json:"urls,omitempty"`
}
var globals struct {
// Topics cache and processing.
hub *Hub
@ -264,19 +256,17 @@ type configType struct {
// when the country isn't specified by the client explicitly and
// it's impossible to infer it.
DefaultCountryCode string `json:"default_country_code"`
// Timeout in seconds before a call is dropped if not answered.
CallEstablishmentTimeout int `json:"call_establishment_timeout"`
// Configs for subsystems
Cluster json.RawMessage `json:"cluster_config"`
Plugin json.RawMessage `json:"plugins"`
Store json.RawMessage `json:"store_config"`
Push json.RawMessage `json:"push"`
TLS json.RawMessage `json:"tls"`
Auth map[string]json.RawMessage `json:"auth_config"`
Validator map[string]*validatorConfig `json:"acc_validation"`
Media *mediaConfig `json:"media"`
ICEServers []iceServer `json:"ice_servers"`
Cluster json.RawMessage `json:"cluster_config"`
Plugin json.RawMessage `json:"plugins"`
Store json.RawMessage `json:"store_config"`
Push json.RawMessage `json:"push"`
TLS json.RawMessage `json:"tls"`
Auth map[string]json.RawMessage `json:"auth_config"`
Validator map[string]*validatorConfig `json:"acc_validation"`
Media *mediaConfig `json:"media"`
WebRTC json.RawMessage `json:"webrtc"`
}
func main() {
@ -530,11 +520,6 @@ func main() {
globals.defaultCountryCode = defaultCountryCode
}
globals.callEstablishmentTimeout = config.CallEstablishmentTimeout
if globals.callEstablishmentTimeout <= 0 {
globals.callEstablishmentTimeout = defaultCallEstablishmentTimeout
}
if config.Media != nil {
if config.Media.UseHandler == "" {
config.Media = nil
@ -569,7 +554,9 @@ func main() {
logs.Info.Println("Stopped push notifications")
}()
globals.iceServers = config.ICEServers
if err = initVideoCalls(config.WebRTC); err != nil {
logs.Err.Fatal("Failed to init video calls: %w", err)
}
// Keep inactive LP sessions for 15 seconds
globals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second)

View File

@ -754,8 +754,12 @@ func (s *Session) hello(msg *ClientComMessage) {
"maxTagLength": maxTagLength,
"maxTagCount": globals.maxTagCount,
"maxFileUploadSize": globals.maxFileUploadSize,
"iceServers": globals.iceServers,
"callTimeout": globals.callEstablishmentTimeout,
}
if len(globals.iceServers) > 0 {
params["iceServers"] = globals.iceServers
}
if globals.callEstablishmentTimeout > 0 {
params["callTimeout"] = globals.callEstablishmentTimeout
}
// Set ua & platform in the beginning of the session.

View File

@ -61,9 +61,6 @@
// If missing, the server will default to "US".
"default_country_code": "",
// Timeout in seconds before a video/voice call is dropped if not answered.
"call_establishment_timeout": 30,
// Large media/blob handlers: large files/images included in messages.
"media": {
// The name of the media handler to use.
@ -490,28 +487,37 @@
}
],
// Interactive Communication Establishment (ICE) STUN and TURN server configuration for video calls.
// You need to configure your own servers. https://xirsys.com/ offers a free tier for developers.
// Video calls will not work if both parties are behind NAT and no ICE servers are configured.
"ice_servers": [
{
"urls": [
"stun:stun.example.com"
]
},
{
"username": "user-name-to-use-for-authentication-with-the-server",
"credential": "your-password",
"urls": [
"turn:turn.example.com:80?transport=udp",
"turn:turn.example.com:3478?transport=udp",
"turn:turn.example.com:80?transport=tcp",
"turn:turn.example.com:3478?transport=tcp",
"turns:turn.example.com:443?transport=tcp",
"turns:turn.example.com:5349?transport=tcp"
]
}
],
// Configuration for voice and video calls.
"webrtc": {
// Disabled. Won't work without functioning ice_servers (see below).
"enabled": false,
// Timeout in seconds before a video/voice call is dropped if not answered.
"call_establishment_timeout": 30,
// Interactive Communication Establishment (ICE) STUN and TURN server configuration for video calls.
// You need to configure your own servers. https://xirsys.com/ offers a free tier for developers.
// Video calls will not work if both parties are behind NAT and no ICE servers are configured.
"ice_servers": [
{
"urls": [
"stun:stun.example.com"
]
},
{
"username": "user-name-to-use-for-authentication-with-the-server",
"credential": "your-password",
"urls": [
"turn:turn.example.com:80?transport=udp",
"turn:turn.example.com:3478?transport=udp",
"turn:turn.example.com:80?transport=tcp",
"turn:turn.example.com:3478?transport=tcp",
"turns:turn.example.com:443?transport=tcp",
"turns:turn.example.com:5349?transport=tcp"
]
}
],
// An alternative way to provide STUN/TURN configuration.
"ice_servers_file": "/path/to/ice-servers-config.json",
},
// Cluster-mode configuration.
"cluster_config": {

View File

@ -1030,6 +1030,10 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
isCall := msg.Pub.Head != nil && msg.Pub.Head["webrtc"] != nil
if isCall {
if len(globals.iceServers) == 0 {
msg.sess.queueOut(ErrNotImplementedReply(msg, types.TimeNow()))
return
}
if t.currentCall != nil {
msg.sess.queueOut(ErrCallBusyReply(msg, types.TimeNow()))
return
@ -1046,6 +1050,7 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
logs.Err.Printf("topic[%s]: failed to save messagge - %s", t.name, err)
return
}
if isCall {
t.handleCallInvite(msg, asUid)
}