From 07d4d93d3a8f84332af6f61db1ab5931c237f9cd Mon Sep 17 00:00:00 2001 From: aforge Date: Sat, 14 Mar 2020 01:57:38 -0700 Subject: [PATCH 001/142] Basic Tinode Push Gateway adapter. --- server/main.go | 1 + server/push/fcm/payload.go | 268 +++++++++++++++++++++++++++++++ server/push/fcm/push_fcm.go | 290 +++------------------------------- server/push/tnpg/push_tnpg.go | 115 ++++++++++++++ 4 files changed, 410 insertions(+), 264 deletions(-) create mode 100644 server/push/fcm/payload.go create mode 100644 server/push/tnpg/push_tnpg.go diff --git a/server/main.go b/server/main.go index 203e277d7..fa8876dce 100644 --- a/server/main.go +++ b/server/main.go @@ -43,6 +43,7 @@ import ( "github.com/tinode/chat/server/push" _ "github.com/tinode/chat/server/push/fcm" _ "github.com/tinode/chat/server/push/stdout" + _ "github.com/tinode/chat/server/push/tnpg" "github.com/tinode/chat/server/store" diff --git a/server/push/fcm/payload.go b/server/push/fcm/payload.go new file mode 100644 index 000000000..0751b0863 --- /dev/null +++ b/server/push/fcm/payload.go @@ -0,0 +1,268 @@ +package fcm + +import ( + "errors" + "log" + "strconv" + "time" + + fcm "firebase.google.com/go/messaging" + + "github.com/tinode/chat/server/drafty" + "github.com/tinode/chat/server/push" + "github.com/tinode/chat/server/store" + t "github.com/tinode/chat/server/store/types" +) + +// Configuration of AndroidNotification payload. +type AndroidConfig struct { + Enabled bool `json:"enabled,omitempty"` + // Common defauls for all push types. + androidPayload + // Configs for specific push types. + Msg androidPayload `json:"msg,omitempty"` + Sub androidPayload `json:"msg,omitempty"` +} + +func (ac *AndroidConfig) getTitleLocKey(what string) string { + var title string + if what == push.ActMsg { + title = ac.Msg.TitleLocKey + } else if what == push.ActSub { + title = ac.Sub.TitleLocKey + } + if title == "" { + title = ac.androidPayload.TitleLocKey + } + return title +} + +func (ac *AndroidConfig) getTitle(what string) string { + var title string + if what == push.ActMsg { + title = ac.Msg.Title + } else if what == push.ActSub { + title = ac.Sub.Title + } + if title == "" { + title = ac.androidPayload.Title + } + return title +} + +func (ac *AndroidConfig) getBodyLocKey(what string) string { + var body string + if what == push.ActMsg { + body = ac.Msg.BodyLocKey + } else if what == push.ActSub { + body = ac.Sub.BodyLocKey + } + if body == "" { + body = ac.androidPayload.BodyLocKey + } + return body +} + +func (ac *AndroidConfig) getBody(what string) string { + var body string + if what == push.ActMsg { + body = ac.Msg.Body + } else if what == push.ActSub { + body = ac.Sub.Body + } + if body == "" { + body = ac.androidPayload.Body + } + return body +} + +func (ac *AndroidConfig) getIcon(what string) string { + var icon string + if what == push.ActMsg { + icon = ac.Msg.Icon + } else if what == push.ActSub { + icon = ac.Sub.Icon + } + if icon == "" { + icon = ac.androidPayload.Icon + } + return icon +} + +func (ac *AndroidConfig) getIconColor(what string) string { + var color string + if what == push.ActMsg { + color = ac.Msg.IconColor + } else if what == push.ActSub { + color = ac.Sub.IconColor + } + if color == "" { + color = ac.androidPayload.IconColor + } + return color +} + +// Payload to be sent for a specific notification type. +type androidPayload struct { + TitleLocKey string `json:"title_loc_key,omitempty"` + Title string `json:"title,omitempty"` + BodyLocKey string `json:"body_loc_key,omitempty"` + Body string `json:"body,omitempty"` + Icon string `json:"icon,omitempty"` + IconColor string `json:"icon_color,omitempty"` + ClickAction string `json:"click_action,omitempty"` +} + +type messageData struct { + Uid t.Uid + DeviceId string + Message *fcm.Message +} + +func payloadToData(pl *push.Payload) (map[string]string, error) { + if pl == nil { + return nil, nil + } + + data := make(map[string]string) + var err error + data["what"] = pl.What + if pl.Silent { + data["silent"] = "true" + } + data["topic"] = pl.Topic + data["ts"] = pl.Timestamp.Format(time.RFC3339Nano) + // Must use "xfrom" because "from" is a reserved word. Google did not bother to document it anywhere. + data["xfrom"] = pl.From + if pl.What == push.ActMsg { + data["seq"] = strconv.Itoa(pl.SeqId) + data["mime"] = pl.ContentType + data["content"], err = drafty.ToPlainText(pl.Content) + if err != nil { + return nil, err + } + + // Trim long strings to 80 runes. + // Check byte length first and don't waste time converting short strings. + if len(data["content"]) > maxMessageLength { + runes := []rune(data["content"]) + if len(runes) > maxMessageLength { + data["content"] = string(runes[:maxMessageLength]) + "…" + } + } + } else if pl.What == push.ActSub { + data["modeWant"] = pl.ModeWant.String() + data["modeGiven"] = pl.ModeGiven.String() + } else { + return nil, errors.New("unknown push type") + } + return data, nil +} + +// PrepareNotifications creates notification payloads ready to be posted +// to push notification server for the provided receipt. +func PrepareNotifications(rcpt *push.Receipt, config *AndroidConfig) []messageData { + data, _ := payloadToData(&rcpt.Payload) + if data == nil { + log.Println("fcm push: could not parse payload") + return nil + } + + // List of UIDs for querying the database + uids := make([]t.Uid, len(rcpt.To)) + skipDevices := make(map[string]bool) + i := 0 + for uid, to := range rcpt.To { + uids[i] = uid + i++ + + // Some devices were online and received the message. Skip them. + for _, deviceID := range to.Devices { + skipDevices[deviceID] = true + } + } + + devices, count, err := store.Devices.GetAll(uids...) + if err != nil { + log.Println("fcm push: db error", err) + return nil + } + if count == 0 { + return nil + } + + var titlelc, title, bodylc, body, icon, color string + if config.Enabled { + titlelc = config.getTitleLocKey(rcpt.Payload.What) + title = config.getTitle(rcpt.Payload.What) + bodylc = config.getBodyLocKey(rcpt.Payload.What) + body = config.getBody(rcpt.Payload.What) + if body == "$content" { + body = data["content"] + } + icon = config.getIcon(rcpt.Payload.What) + color = config.getIconColor(rcpt.Payload.What) + } + + var messages []messageData + for uid, devList := range devices { + for i := range devList { + d := &devList[i] + if _, ok := skipDevices[d.DeviceId]; !ok && d.DeviceId != "" { + msg := fcm.Message{ + Token: d.DeviceId, + Data: data, + } + + if d.Platform == "android" { + msg.Android = &fcm.AndroidConfig{ + Priority: "high", + } + if config.Enabled { + // When this notification type is included and the app is not in the foreground + // Android won't wake up the app and won't call FirebaseMessagingService:onMessageReceived. + // See dicussion: https://github.com/firebase/quickstart-js/issues/71 + msg.Android.Notification = &fcm.AndroidNotification{ + // Android uses Tag value to group notifications together: + // show just one notification per topic. + Tag: rcpt.Payload.Topic, + TitleLocKey: titlelc, + Title: title, + BodyLocKey: bodylc, + Body: body, + Icon: icon, + Color: color, + } + } + } else if d.Platform == "ios" { + // iOS uses Badge to show the total unread message count. + badge := rcpt.To[uid].Unread + // Need to duplicate these in APNS.Payload.Aps.Alert so + // iOS may call NotificationServiceExtension (if present). + title := "New message" + body := data["content"] + msg.APNS = &fcm.APNSConfig{ + Payload: &fcm.APNSPayload{ + Aps: &fcm.Aps{ + Badge: &badge, + ContentAvailable: true, + MutableContent: true, + Sound: "default", + Alert: &fcm.ApsAlert{ + Title: title, + Body: body, + }, + }, + }, + } + msg.Notification = &fcm.Notification{ + Title: title, + Body: body, + } + } + messages = append(messages, messageData{Uid: uid, DeviceId: d.DeviceId, Message: &msg}) + } + } + } + return messages +} diff --git a/server/push/fcm/push_fcm.go b/server/push/fcm/push_fcm.go index ebb132f0a..6bd940c1d 100644 --- a/server/push/fcm/push_fcm.go +++ b/server/push/fcm/push_fcm.go @@ -7,16 +7,12 @@ import ( "encoding/json" "errors" "log" - "strconv" - "time" fbase "firebase.google.com/go" fcm "firebase.google.com/go/messaging" - "github.com/tinode/chat/server/drafty" "github.com/tinode/chat/server/push" "github.com/tinode/chat/server/store" - t "github.com/tinode/chat/server/store/types" "golang.org/x/oauth2/google" "google.golang.org/api/option" @@ -37,112 +33,13 @@ type Handler struct { client *fcm.Client } -// Configuration of AndroidNotification payload. -type androidConfig struct { - Enabled bool `json:"enabled,omitempty"` - // Common defauls for all push types. - androidPayload - // Configs for specific push types. - Msg androidPayload `json:"msg,omitempty"` - Sub androidPayload `json:"msg,omitempty"` -} - -func (ac *androidConfig) getTitleLocKey(what string) string { - var title string - if what == push.ActMsg { - title = ac.Msg.TitleLocKey - } else if what == push.ActSub { - title = ac.Sub.TitleLocKey - } - if title == "" { - title = ac.androidPayload.TitleLocKey - } - return title -} - -func (ac *androidConfig) getTitle(what string) string { - var title string - if what == push.ActMsg { - title = ac.Msg.Title - } else if what == push.ActSub { - title = ac.Sub.Title - } - if title == "" { - title = ac.androidPayload.Title - } - return title -} - -func (ac *androidConfig) getBodyLocKey(what string) string { - var body string - if what == push.ActMsg { - body = ac.Msg.BodyLocKey - } else if what == push.ActSub { - body = ac.Sub.BodyLocKey - } - if body == "" { - body = ac.androidPayload.BodyLocKey - } - return body -} - -func (ac *androidConfig) getBody(what string) string { - var body string - if what == push.ActMsg { - body = ac.Msg.Body - } else if what == push.ActSub { - body = ac.Sub.Body - } - if body == "" { - body = ac.androidPayload.Body - } - return body -} - -func (ac *androidConfig) getIcon(what string) string { - var icon string - if what == push.ActMsg { - icon = ac.Msg.Icon - } else if what == push.ActSub { - icon = ac.Sub.Icon - } - if icon == "" { - icon = ac.androidPayload.Icon - } - return icon -} - -func (ac *androidConfig) getIconColor(what string) string { - var color string - if what == push.ActMsg { - color = ac.Msg.IconColor - } else if what == push.ActSub { - color = ac.Sub.IconColor - } - if color == "" { - color = ac.androidPayload.IconColor - } - return color -} - -// Payload to be sent for a specific notification type. -type androidPayload struct { - TitleLocKey string `json:"title_loc_key,omitempty"` - Title string `json:"title,omitempty"` - BodyLocKey string `json:"body_loc_key,omitempty"` - Body string `json:"body,omitempty"` - Icon string `json:"icon,omitempty"` - IconColor string `json:"icon_color,omitempty"` - ClickAction string `json:"click_action,omitempty"` -} - type configType struct { Enabled bool `json:"enabled"` Buffer int `json:"buffer"` Credentials json.RawMessage `json:"credentials"` CredentialsFile string `json:"credentials_file"` TimeToLive uint `json:"time_to_live,omitempty"` - Android androidConfig `json:"android,omitempty"` + Android AndroidConfig `json:"android,omitempty"` } // Init initializes the push handler @@ -206,178 +103,43 @@ func (Handler) Init(jsonconf string) error { func sendNotifications(rcpt *push.Receipt, config *configType) { ctx := context.Background() - - data, _ := payloadToData(&rcpt.Payload) - if data == nil { - log.Println("fcm push: could not parse payload") + messages := PrepareNotifications(rcpt, &config.Android) + if messages == nil { return } - // List of UIDs for querying the database - uids := make([]t.Uid, len(rcpt.To)) - skipDevices := make(map[string]bool) - i := 0 - for uid, to := range rcpt.To { - uids[i] = uid - i++ - - // Some devices were online and received the message. Skip them. - for _, deviceID := range to.Devices { - skipDevices[deviceID] = true - } - } - - devices, count, err := store.Devices.GetAll(uids...) - if err != nil { - log.Println("fcm push: db error", err) - return - } - if count == 0 { - return - } - - var titlelc, title, bodylc, body, icon, color string - if config.Android.Enabled { - titlelc = config.Android.getTitleLocKey(rcpt.Payload.What) - title = config.Android.getTitle(rcpt.Payload.What) - bodylc = config.Android.getBodyLocKey(rcpt.Payload.What) - body = config.Android.getBody(rcpt.Payload.What) - if body == "$content" { - body = data["content"] - } - icon = config.Android.getIcon(rcpt.Payload.What) - color = config.Android.getIconColor(rcpt.Payload.What) - } - - for uid, devList := range devices { - for i := range devList { - d := &devList[i] - if _, ok := skipDevices[d.DeviceId]; !ok && d.DeviceId != "" { - msg := fcm.Message{ - Token: d.DeviceId, - Data: data, - } + for _, m := range messages { + _, err := handler.client.Send(ctx, m.Message) + if err != nil { + if fcm.IsMessageRateExceeded(err) || + fcm.IsServerUnavailable(err) || + fcm.IsInternal(err) || + fcm.IsUnknown(err) { + // Transient errors. Stop sending this batch. + log.Println("fcm transient failure", err) + return + } - if d.Platform == "android" { - msg.Android = &fcm.AndroidConfig{ - Priority: "high", - } - if config.Android.Enabled { - // When this notification type is included and the app is not in the foreground - // Android won't wake up the app and won't call FirebaseMessagingService:onMessageReceived. - // See dicussion: https://github.com/firebase/quickstart-js/issues/71 - msg.Android.Notification = &fcm.AndroidNotification{ - // Android uses Tag value to group notifications together: - // show just one notification per topic. - Tag: rcpt.Payload.Topic, - TitleLocKey: titlelc, - Title: title, - BodyLocKey: bodylc, - Body: body, - Icon: icon, - Color: color, - } - } - } else if d.Platform == "ios" { - // iOS uses Badge to show the total unread message count. - badge := rcpt.To[uid].Unread - // Need to duplicate these in APNS.Payload.Aps.Alert so - // iOS may call NotificationServiceExtension (if present). - title := "New message" - body := data["content"] - msg.APNS = &fcm.APNSConfig{ - Payload: &fcm.APNSPayload{ - Aps: &fcm.Aps{ - Badge: &badge, - ContentAvailable: true, - MutableContent: true, - Sound: "default", - Alert: &fcm.ApsAlert{ - Title: title, - Body: body, - }, - }, - }, - } - msg.Notification = &fcm.Notification{ - Title: title, - Body: body, - } - } + if fcm.IsMismatchedCredential(err) || fcm.IsInvalidArgument(err) { + // Config errors + log.Println("fcm push: failed", err) + return + } - _, err := handler.client.Send(ctx, &msg) + if fcm.IsRegistrationTokenNotRegistered(err) { + // Token is no longer valid. + log.Println("fcm push: invalid token", err) + err = store.Devices.Delete(m.Uid, m.DeviceId) if err != nil { - if fcm.IsMessageRateExceeded(err) || - fcm.IsServerUnavailable(err) || - fcm.IsInternal(err) || - fcm.IsUnknown(err) { - // Transient errors. Stop sending this batch. - log.Println("fcm transient failure", err) - return - } - - if fcm.IsMismatchedCredential(err) || fcm.IsInvalidArgument(err) { - // Config errors - log.Println("fcm push: failed", err) - return - } - - if fcm.IsRegistrationTokenNotRegistered(err) { - // Token is no longer valid. - log.Println("fcm push: invalid token", err) - err = store.Devices.Delete(uid, d.DeviceId) - if err != nil { - log.Println("fcm push: failed to delete invalid token", err) - } - } else { - log.Println("fcm push:", err) - } + log.Println("fcm push: failed to delete invalid token", err) } + } else { + log.Println("fcm push:", err) } } } } -func payloadToData(pl *push.Payload) (map[string]string, error) { - if pl == nil { - return nil, nil - } - - data := make(map[string]string) - var err error - data["what"] = pl.What - if pl.Silent { - data["silent"] = "true" - } - data["topic"] = pl.Topic - data["ts"] = pl.Timestamp.Format(time.RFC3339Nano) - // Must use "xfrom" because "from" is a reserved word. Google did not bother to document it anywhere. - data["xfrom"] = pl.From - if pl.What == push.ActMsg { - data["seq"] = strconv.Itoa(pl.SeqId) - data["mime"] = pl.ContentType - data["content"], err = drafty.ToPlainText(pl.Content) - if err != nil { - return nil, err - } - - // Trim long strings to 80 runes. - // Check byte length first and don't waste time converting short strings. - if len(data["content"]) > maxMessageLength { - runes := []rune(data["content"]) - if len(runes) > maxMessageLength { - data["content"] = string(runes[:maxMessageLength]) + "…" - } - } - } else if pl.What == push.ActSub { - data["modeWant"] = pl.ModeWant.String() - data["modeGiven"] = pl.ModeGiven.String() - } else { - return nil, errors.New("unknown push type") - } - return data, nil -} - // IsReady checks if the push handler has been initialized. func (Handler) IsReady() bool { return handler.input != nil diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go new file mode 100644 index 000000000..5e7cc19e4 --- /dev/null +++ b/server/push/tnpg/push_tnpg.go @@ -0,0 +1,115 @@ +// Package tnpg implements push notification plugin for Tinode Push Gateway. +package tnpg + +import ( + "bytes" + "encoding/json" + "errors" + "log" + "net/http" + + "github.com/tinode/chat/server/push" + "github.com/tinode/chat/server/push/fcm" +) + +var handler Handler + +type Handler struct { + input chan *push.Receipt + stop chan bool +} + +type configType struct { + Enabled bool `json:"enabled"` + Buffer int `json:"buffer"` + TargetAddress string `json:"target_address"` + AuthToken string `json:"auth_token"` + Android fcm.AndroidConfig `json:"android,omitempty"` +} + +// Init initializes the handler +func (Handler) Init(jsonconf string) error { + var config configType + if err := json.Unmarshal([]byte(jsonconf), &config); err != nil { + return errors.New("failed to parse config: " + err.Error()) + } + + if !config.Enabled { + return nil + } + + handler.input = make(chan *push.Receipt, config.Buffer) + handler.stop = make(chan bool, 1) + + go func() { + for { + select { + case rcpt := <-handler.input: + go sendPushes(rcpt, &config) + case <-handler.stop: + return + } + } + }() + + return nil +} + +func postMessage(body []byte, config *configType) (int, string, error) { + reader := bytes.NewReader(body) + req, err := http.NewRequest("POST", config.TargetAddress, reader) + if err != nil { + return -1, "", err + } + req.Header.Add("Authorization", config.AuthToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return -1, "", err + } + defer resp.Body.Close() + return resp.StatusCode, resp.Status, nil +} + +func sendPushes(rcpt *push.Receipt, config *configType) { + messages := fcm.PrepareNotifications(rcpt, &config.Android) + if messages == nil { + return + } + + // TODO: + // 1. Send multiple payloads in one request. + // 2. Compress payloads. + for _, m := range messages { + msg, err := json.Marshal(m.Message) + if err != nil { + log.Println("tnpg push: cannot serialize message", err) + return + } + if code, status, err := postMessage(msg, config); err != nil { + log.Println("tnpg push failed:", err) + } else if code >= 300 { + log.Println("tnpg push rejected:", status, err) + break + } + } +} + +// IsReady checks if the handler is initialized. +func (Handler) IsReady() bool { + return handler.input != nil +} + +// Push returns a channel that the server will use to send messages to. +// If the adapter blocks, the message will be dropped. +func (Handler) Push() chan<- *push.Receipt { + return handler.input +} + +// Stop terminates the handler's worker and stops sending pushes. +func (Handler) Stop() { + handler.stop <- true +} + +func init() { + push.Register("tnpg", &handler) +} From d22d3277af524e4f715f43a6702200039d31b4cc Mon Sep 17 00:00:00 2001 From: aforge Date: Sat, 14 Mar 2020 02:01:23 -0700 Subject: [PATCH 002/142] Formatting. --- server/push/tnpg/push_tnpg.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go index 5e7cc19e4..3fd53e217 100644 --- a/server/push/tnpg/push_tnpg.go +++ b/server/push/tnpg/push_tnpg.go @@ -67,7 +67,7 @@ func postMessage(body []byte, config *configType) (int, string, error) { return -1, "", err } defer resp.Body.Close() - return resp.StatusCode, resp.Status, nil + return resp.StatusCode, resp.Status, nil } func sendPushes(rcpt *push.Receipt, config *configType) { @@ -80,17 +80,18 @@ func sendPushes(rcpt *push.Receipt, config *configType) { // 1. Send multiple payloads in one request. // 2. Compress payloads. for _, m := range messages { - msg, err := json.Marshal(m.Message) + msg, err := json.Marshal(m.Message) if err != nil { - log.Println("tnpg push: cannot serialize message", err) + log.Println("tnpg push: cannot serialize message", err) return } - if code, status, err := postMessage(msg, config); err != nil { - log.Println("tnpg push failed:", err) - } else if code >= 300 { - log.Println("tnpg push rejected:", status, err) - break - } + if code, status, err := postMessage(msg, config); err != nil { + log.Println("tnpg push failed:", err) + break + } else if code >= 300 { + log.Println("tnpg push rejected:", status, err) + break + } } } From 17a9f5dcc6eb0f434637e124d3178b8bc4d844e9 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 14 Mar 2020 13:44:51 +0300 Subject: [PATCH 003/142] typo in docker build --- docker-build.sh | 2 +- server/tinode.conf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-build.sh b/docker-build.sh index 5f9e40c15..d7d042627 100755 --- a/docker-build.sh +++ b/docker-build.sh @@ -28,7 +28,7 @@ for dbtag in "${dbtags[@]}" do if [ "$dbtag" == "alldbs" ]; then # For alldbs, container name is tinode/tinode. - name="tiniode/tinode" + name="tinode/tinode" else # Otherwise, tinode/tinode-$dbtag. name="tinode/tinode-${dbtag}" diff --git a/server/tinode.conf b/server/tinode.conf index 52002af80..9a36c5dab 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -154,7 +154,7 @@ // Database configuration "store_config": { // XTEA encryption key for user IDs and topic names. 16 random bytes base64-encoded. - // Generate your own and keep it secret. Otherwise your user IDswill be predictable + // Generate your own and keep it secret. Otherwise your user IDs will be predictable // and it will be easy to spam your users. "uid_key": "la6YsO+bNX/+XIkOqc5Svw==", From 9d41906975db65cf47a5e250510391b4404da4f1 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 14 Mar 2020 16:27:17 +0300 Subject: [PATCH 004/142] docker changed URLs --- docker-release.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docker-release.sh b/docker-release.sh index f4f8eb516..f706b17ab 100755 --- a/docker-release.sh +++ b/docker-release.sh @@ -45,23 +45,23 @@ do name="$(containerName $dbtag)" if [ -n "$FULLRELEASE" ]; then curl -u $user:$pass -i -X DELETE \ - https://cloud.docker.com/v2/repositories/tinode/${name}/tags/latest/ + https://hub.docker.com/v2/repositories/tinode/${name}/tags/latest/ curl -u $user:$pass -i -X DELETE \ - https://cloud.docker.com/v2/repositories/tinode/${name}/tags/${ver[0]}.${ver[1]}/ + https://hub.docker.com/v2/repositories/tinode/${name}/tags/${ver[0]}.${ver[1]}/ fi curl -u $user:$pass -i -X DELETE \ - https://cloud.docker.com/v2/repositories/tinode/${name}/tags/${ver[0]}.${ver[1]}.${ver[2]}/ + https://hub.docker.com/v2/repositories/tinode/${name}/tags/${ver[0]}.${ver[1]}.${ver[2]}/ done if [ -n "$FULLRELEASE" ]; then curl -u $user:$pass -i -X DELETE \ - https://cloud.docker.com/v2/repositories/tinode/chatbot/tags/latest/ + https://hub.docker.com/v2/repositories/tinode/chatbot/tags/latest/ curl -u $user:$pass -i -X DELETE \ - https://cloud.docker.com/v2/repositories/tinode/chatbot/tags/${ver[0]}.${ver[1]}/ + https://hub.docker.com/v2/repositories/tinode/chatbot/tags/${ver[0]}.${ver[1]}/ fi curl -u $user:$pass -i -X DELETE \ - https://cloud.docker.com/v2/repositories/tinode/chatbot/tags/${ver[0]}.${ver[1]}.${ver[2]}/ + https://hub.docker.com/v2/repositories/tinode/chatbot/tags/${ver[0]}.${ver[1]}.${ver[2]}/ # Deploy images for various DB backends for dbtag in "${dbtags[@]}" From f3027f1a6d9931a58c3c86ade4c42f8625a27aba Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 14 Mar 2020 16:27:51 +0300 Subject: [PATCH 005/142] add build stamp to main.go --- monitoring/exporter/main.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/monitoring/exporter/main.go b/monitoring/exporter/main.go index 45fa3e7fb..c86d7e243 100644 --- a/monitoring/exporter/main.go +++ b/monitoring/exporter/main.go @@ -26,6 +26,16 @@ func (l promHTTPLogger) Println(v ...interface{}) { log.Println(v...) } +// Build version number defined by the compiler: +// -ldflags "-X main.buildstamp=value_to_assign_to_buildstamp" +// Reported to clients in response to {hi} message. +// For instance, to define the buildstamp as a timestamp of when the server was built add a +// flag to compiler command line: +// -ldflags "-X main.buildstamp=`date -u '+%Y%m%dT%H:%M:%SZ'`" +// or to set it to git tag: +// -ldflags "-X main.buildstamp=`git describe --tags`" +var buildstamp = "undef" + func main() { log.Printf("Tinode metrics exporter.") From e4c5f5a9195cf874f982e51c1e0854b49814edd8 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 14 Mar 2020 16:28:17 +0300 Subject: [PATCH 006/142] adding build script --- monitoring/exporter/build.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100755 monitoring/exporter/build.sh diff --git a/monitoring/exporter/build.sh b/monitoring/exporter/build.sh new file mode 100755 index 000000000..e69de29bb From 66c711c2491654396c04f9e16ce281fe798ae821 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 14 Mar 2020 17:02:28 +0300 Subject: [PATCH 007/142] adding cross-compilation script for exporter --- monitoring/exporter/build.sh | 66 ++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/monitoring/exporter/build.sh b/monitoring/exporter/build.sh index e69de29bb..a0b4af3b2 100755 --- a/monitoring/exporter/build.sh +++ b/monitoring/exporter/build.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# Cross-compiling script using https://github.com/mitchellh/gox +# This scripts build and archives binaries and supporting files. + +# Check if gox is installed. Abort otherwise. +command -v gox >/dev/null 2>&1 || { + echo >&2 "This script requires https://github.com/mitchellh/gox. Please install it before running."; exit 1; +} + +# Supported OSs: darwin windows linux +goplat=( darwin windows linux ) +# Supported CPU architectures: amd64 +goarc=( amd64 ) + +for line in $@; do + eval "$line" +done + +# Strip 'v' prefix as in v0.16.4 -> 0.16.4. +version=${tag#?} + +if [ -z "$version" ]; then + # Get last git tag as release version. Tag looks like 'v.1.2.3', so strip 'v'. + version=`git describe --tags` + version=${version#?} +fi + +echo "Releasing exporter $version" + +GOSRC=${GOPATH}/src/github.com/tinode + +pushd ${GOSRC}/chat > /dev/null + +# Make sure earlier build is deleted +rm -f ./releases/${version}/exporter* + +for plat in "${goplat[@]}" +do + for arc in "${goarc[@]}" + do + # Remove previous build + rm -f $GOPATH/bin/exporter + # Build + gox -osarch="${plat}/${arc}" \ + -ldflags "-s -w -X main.buildstamp=`git describe --tags`" \ + -output $GOPATH/bin/exporter ./monitoring/exporter > /dev/null + + # Copy binary to release folder for pushing to Github. + if [ "$plat" = "windows" ]; then + # Copy binaries + cp $GOPATH/bin/exporter.exe ./releases/${version}/exporter."${plat}-${arc}".exe + else + plat2=$plat + # Rename 'darwin' tp 'mac' + if [ "$plat" = "darwin" ]; then + plat2=mac + fi + # Copy binaries + cp $GOPATH/bin/exporter ./releases/${version}/exporter."${plat2}-${arc}" + fi + + done +done + +popd > /dev/null From 90d0e37313b76845c2534eacdd64b170c616fb10 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 14 Mar 2020 19:40:42 +0300 Subject: [PATCH 008/142] possible fix for OperationAborted: A conflicting conditional operation is currently in progress against this resource. Please try again. --- server/media/s3/s3.go | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/server/media/s3/s3.go b/server/media/s3/s3.go index ab192afc4..8c937b0b9 100644 --- a/server/media/s3/s3.go +++ b/server/media/s3/s3.go @@ -95,31 +95,33 @@ func (ah *awshandler) Init(jsconf string) error { // Check if the bucket exists, create one if not. _, err = ah.svc.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(ah.conf.BucketName)}) if err != nil { + // Bucket exists or a genuine error. if aerr, ok := err.(awserr.Error); !ok || (aerr.Code() != s3.ErrCodeBucketAlreadyExists && aerr.Code() != s3.ErrCodeBucketAlreadyOwnedByYou) { return err } + } else { + // This is a new bucket. + + // The following serves two purposes: + // 1. Setup CORS policy to be able to serve media directly from S3. + // 2. Verify that the bucket is accessible to the current user. + origins := ah.conf.CorsOrigins + if len(origins) == 0 { + origins = append(origins, "*") + } + _, err = ah.svc.PutBucketCors(&s3.PutBucketCorsInput{ + Bucket: aws.String(ah.conf.BucketName), + CORSConfiguration: &s3.CORSConfiguration{ + CORSRules: []*s3.CORSRule{{ + AllowedMethods: aws.StringSlice([]string{http.MethodGet, http.MethodHead}), + AllowedOrigins: aws.StringSlice(origins), + AllowedHeaders: aws.StringSlice([]string{"*"}), + }}, + }, + }) } - - // The following serves two purposes: - // 1. Setup CORS policy to be able to serve media directly from S3. - // 2. Verify that the bucket is accessible to the current user. - origins := ah.conf.CorsOrigins - if len(origins) == 0 { - origins = append(origins, "*") - } - _, err = ah.svc.PutBucketCors(&s3.PutBucketCorsInput{ - Bucket: aws.String(ah.conf.BucketName), - CORSConfiguration: &s3.CORSConfiguration{ - CORSRules: []*s3.CORSRule{{ - AllowedMethods: aws.StringSlice([]string{http.MethodGet, http.MethodHead}), - AllowedOrigins: aws.StringSlice(origins), - AllowedHeaders: aws.StringSlice([]string{"*"}), - }}, - }, - }) - return err } From 823709fe138d6a956363298801065b5002f0d948 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 14 Mar 2020 19:44:09 +0300 Subject: [PATCH 009/142] gofmt and a required comment with package description --- server/push/fcm/payload.go | 4 +++- server/push/fcm/push_fcm.go | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/server/push/fcm/payload.go b/server/push/fcm/payload.go index 0751b0863..0169da397 100644 --- a/server/push/fcm/payload.go +++ b/server/push/fcm/payload.go @@ -1,3 +1,5 @@ +// Package fcm is push notification plugin using Google FCM. +// https://firebase.google.com/docs/cloud-messaging package fcm import ( @@ -260,7 +262,7 @@ func PrepareNotifications(rcpt *push.Receipt, config *AndroidConfig) []messageDa Body: body, } } - messages = append(messages, messageData{Uid: uid, DeviceId: d.DeviceId, Message: &msg}) + messages = append(messages, messageData{Uid: uid, DeviceId: d.DeviceId, Message: &msg}) } } } diff --git a/server/push/fcm/push_fcm.go b/server/push/fcm/push_fcm.go index 6bd940c1d..5c8b0d9df 100644 --- a/server/push/fcm/push_fcm.go +++ b/server/push/fcm/push_fcm.go @@ -1,5 +1,7 @@ // Package fcm implements push notification plugin for Google FCM backend. // Push notifications for Android, iOS and web clients are sent through Google's Firebase Cloud Messaging service. +// Package fcm is push notification plugin using Google FCM. +// https://firebase.google.com/docs/cloud-messaging package fcm import ( From a135268c5f29c3c744ec4049270a18c0672bcbf6 Mon Sep 17 00:00:00 2001 From: aforge Date: Sat, 14 Mar 2020 21:45:56 -0700 Subject: [PATCH 010/142] TNPG: push all messages in one request and compress request payloads. --- server/push/tnpg/push_tnpg.go | 63 ++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go index 3fd53e217..53d6052fa 100644 --- a/server/push/tnpg/push_tnpg.go +++ b/server/push/tnpg/push_tnpg.go @@ -3,8 +3,11 @@ package tnpg import ( "bytes" + "compress/gzip" "encoding/json" "errors" + "fmt" + "io" "log" "net/http" @@ -12,6 +15,8 @@ import ( "github.com/tinode/chat/server/push/fcm" ) +const targetAddress = "https://pushgw.tinode.co/push" + var handler Handler type Handler struct { @@ -20,11 +25,11 @@ type Handler struct { } type configType struct { - Enabled bool `json:"enabled"` - Buffer int `json:"buffer"` - TargetAddress string `json:"target_address"` - AuthToken string `json:"auth_token"` - Android fcm.AndroidConfig `json:"android,omitempty"` + Enabled bool `json:"enabled"` + Buffer int `json:"buffer"` + CompressPayloads bool `json:"compress_payloads"` + AuthToken string `json:"auth_token"` + Android fcm.AndroidConfig `json:"android,omitempty"` } // Init initializes the handler @@ -56,12 +61,27 @@ func (Handler) Init(jsonconf string) error { } func postMessage(body []byte, config *configType) (int, string, error) { - reader := bytes.NewReader(body) - req, err := http.NewRequest("POST", config.TargetAddress, reader) + var reader io.Reader + if config.CompressPayloads { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + if _, err := gz.Write(body); err != nil { + return -1, "", err + } + gz.Close() + reader = &buf + } else { + reader = bytes.NewReader(body) + } + req, err := http.NewRequest("POST", targetAddress, reader) if err != nil { return -1, "", err } req.Header.Add("Authorization", config.AuthToken) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + if config.CompressPayloads { + req.Header.Add("Content-Encoding", "gzip") + } resp, err := http.DefaultClient.Do(req) if err != nil { return -1, "", err @@ -76,22 +96,19 @@ func sendPushes(rcpt *push.Receipt, config *configType) { return } - // TODO: - // 1. Send multiple payloads in one request. - // 2. Compress payloads. - for _, m := range messages { - msg, err := json.Marshal(m.Message) - if err != nil { - log.Println("tnpg push: cannot serialize message", err) - return - } - if code, status, err := postMessage(msg, config); err != nil { - log.Println("tnpg push failed:", err) - break - } else if code >= 300 { - log.Println("tnpg push rejected:", status, err) - break - } + messageMap := make(map[string]interface{}) + for i, m := range messages { + messageMap[fmt.Sprintf("message-%d", i)] = m + } + msgs, err := json.Marshal(messageMap) + if err != nil { + log.Println("tnpg push: cannot serialize push messages -", err) + return + } + if code, status, err := postMessage(msgs, config); err != nil { + log.Println("tnpg push failed:", err) + } else if code >= 300 { + log.Println("tnpg push rejected:", status, err) } } From 6ec2134a179383e4ad711f0668d2de97a907fe1b Mon Sep 17 00:00:00 2001 From: aforge Date: Sat, 14 Mar 2020 23:06:44 -0700 Subject: [PATCH 011/142] In TNPG serialized messages directly. --- server/push/tnpg/push_tnpg.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go index 53d6052fa..8c9b2ca8c 100644 --- a/server/push/tnpg/push_tnpg.go +++ b/server/push/tnpg/push_tnpg.go @@ -6,7 +6,6 @@ import ( "compress/gzip" "encoding/json" "errors" - "fmt" "io" "log" "net/http" @@ -96,11 +95,7 @@ func sendPushes(rcpt *push.Receipt, config *configType) { return } - messageMap := make(map[string]interface{}) - for i, m := range messages { - messageMap[fmt.Sprintf("message-%d", i)] = m - } - msgs, err := json.Marshal(messageMap) + msgs, err := json.Marshal(messages) if err != nil { log.Println("tnpg push: cannot serialize push messages -", err) return From 1db6c4e60b4288a67fcd14847a9a9ee674b29008 Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 15 Mar 2020 12:19:30 +0300 Subject: [PATCH 012/142] fix for double routing of {sub topic=new} in cluster --- server/cluster.go | 17 ++++++++++++++++- server/datamodel.go | 2 +- server/push/fcm/payload.go | 2 -- server/session.go | 6 +++--- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/server/cluster.go b/server/cluster.go index b4035b866..056d92899 100644 --- a/server/cluster.go +++ b/server/cluster.go @@ -323,7 +323,7 @@ type Cluster struct { // Master at topic's master node receives C2S messages from topic's proxy nodes. // The message is treated like it came from a session: find or create a session locally, -// dispatch the message to it like it came from a normal ws/lp connection. +// dispatch the message to it like it came from a normal ws/lp/gRPC connection. // Called by a remote node. func (c *Cluster) Master(msg *ClusterReq, rejected *bool) error { // Find the local session associated with the given remote session. @@ -523,6 +523,21 @@ func (c *Cluster) isRemoteTopic(topic string) bool { return c.ring.Get(topic) != c.thisNodeName } +// genLocalTopicName is just like genTopicName(), but the generated name belongs to the current cluster node. +func (c *Cluster) genLocalTopicName() string { + topic := genTopicName() + if c == nil { + // Cluster not initialized, all topics are local + return topic + } + + // FIXME: if cluster is large it may become too inefficient. + for c.ring.Get(topic) != c.thisNodeName { + topic = genTopicName() + } + return topic +} + // Returns remote node name where the topic is hosted. // If the topic is hosted locally, returns an empty string. func (c *Cluster) nodeNameForTopicIfRemote(topic string) string { diff --git a/server/datamodel.go b/server/datamodel.go index 33bf599f6..f1044d475 100644 --- a/server/datamodel.go +++ b/server/datamodel.go @@ -307,7 +307,7 @@ type ClientComMessage struct { // Message ID denormalized id string - // Topic denormalized + // Un-routable (original) topic name denormalized from XXX.Topic. topic string // Sender's UserId as string from string diff --git a/server/push/fcm/payload.go b/server/push/fcm/payload.go index 0169da397..bf8068ca2 100644 --- a/server/push/fcm/payload.go +++ b/server/push/fcm/payload.go @@ -1,5 +1,3 @@ -// Package fcm is push notification plugin using Google FCM. -// https://firebase.google.com/docs/cloud-messaging package fcm import ( diff --git a/server/session.go b/server/session.go index b9f50ee71..b73580943 100644 --- a/server/session.go +++ b/server/session.go @@ -403,10 +403,10 @@ func (s *Session) subscribe(msg *ClientComMessage) { var expanded string isNewTopic := false if strings.HasPrefix(msg.topic, "new") { - // Request to create a new named topic - expanded = genTopicName() + // Request to create a new named topic. + // If we are in a cluster, make sure the new topic belongs to the current node. + expanded = globals.cluster.genLocalTopicName() isNewTopic = true - // msg.topic = expanded } else { var resp *ServerComMessage expanded, resp = s.expandTopicName(msg) From 1b36ace0956c8ab6c1d2c38b7cf68572b2d24838 Mon Sep 17 00:00:00 2001 From: aforge Date: Sun, 15 Mar 2020 14:19:53 -0700 Subject: [PATCH 013/142] Send only []fcm.Message to TNPG. --- server/push/tnpg/push_tnpg.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go index 8c9b2ca8c..c82dd2a95 100644 --- a/server/push/tnpg/push_tnpg.go +++ b/server/push/tnpg/push_tnpg.go @@ -95,7 +95,11 @@ func sendPushes(rcpt *push.Receipt, config *configType) { return } - msgs, err := json.Marshal(messages) + var payloads []interface{} + for _, m := range messages { + payloads = append(payloads, m.Message) + } + msgs, err := json.Marshal(payloads) if err != nil { log.Println("tnpg push: cannot serialize push messages -", err) return From 5657d513753895e497fc221e419b9ab68f350820 Mon Sep 17 00:00:00 2001 From: aforge Date: Mon, 16 Mar 2020 00:21:08 -0700 Subject: [PATCH 014/142] Apply json.Encoder.Encode directly on push payloads. --- server/push/tnpg/push_tnpg.go | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go index c82dd2a95..8d4ec2848 100644 --- a/server/push/tnpg/push_tnpg.go +++ b/server/push/tnpg/push_tnpg.go @@ -6,7 +6,6 @@ import ( "compress/gzip" "encoding/json" "errors" - "io" "log" "net/http" @@ -59,20 +58,16 @@ func (Handler) Init(jsonconf string) error { return nil } -func postMessage(body []byte, config *configType) (int, string, error) { - var reader io.Reader +func postMessage(body interface{}, config *configType) (int, string, error) { + buf := new(bytes.Buffer) if config.CompressPayloads { - var buf bytes.Buffer - gz := gzip.NewWriter(&buf) - if _, err := gz.Write(body); err != nil { - return -1, "", err - } + gz := gzip.NewWriter(buf) + json.NewEncoder(gz).Encode(body) gz.Close() - reader = &buf } else { - reader = bytes.NewReader(body) + json.NewEncoder(buf).Encode(body) } - req, err := http.NewRequest("POST", targetAddress, reader) + req, err := http.NewRequest("POST", targetAddress, buf) if err != nil { return -1, "", err } @@ -99,12 +94,7 @@ func sendPushes(rcpt *push.Receipt, config *configType) { for _, m := range messages { payloads = append(payloads, m.Message) } - msgs, err := json.Marshal(payloads) - if err != nil { - log.Println("tnpg push: cannot serialize push messages -", err) - return - } - if code, status, err := postMessage(msgs, config); err != nil { + if code, status, err := postMessage(payloads, config); err != nil { log.Println("tnpg push failed:", err) } else if code >= 300 { log.Println("tnpg push rejected:", status, err) From 36717d8d7af2f3a74df2a98affd640b7ca21c03b Mon Sep 17 00:00:00 2001 From: aforge Date: Mon, 16 Mar 2020 18:20:55 -0700 Subject: [PATCH 015/142] TNPG: send pushes in batches. --- server/push/tnpg/push_tnpg.go | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go index 8d4ec2848..5700a6d1c 100644 --- a/server/push/tnpg/push_tnpg.go +++ b/server/push/tnpg/push_tnpg.go @@ -26,6 +26,7 @@ type configType struct { Enabled bool `json:"enabled"` Buffer int `json:"buffer"` CompressPayloads bool `json:"compress_payloads"` + BatchSize int `json:"batch_size"` AuthToken string `json:"auth_token"` Android fcm.AndroidConfig `json:"android,omitempty"` } @@ -41,6 +42,10 @@ func (Handler) Init(jsonconf string) error { return nil } + if config.BatchSize <= 0 { + return errors.New("push.tnpg.batch_size should be greater than zero.") + } + handler.input = make(chan *push.Receipt, config.Buffer) handler.stop = make(chan bool, 1) @@ -90,14 +95,23 @@ func sendPushes(rcpt *push.Receipt, config *configType) { return } - var payloads []interface{} - for _, m := range messages { - payloads = append(payloads, m.Message) - } - if code, status, err := postMessage(payloads, config); err != nil { - log.Println("tnpg push failed:", err) - } else if code >= 300 { - log.Println("tnpg push rejected:", status, err) + n := len(messages) + for i := 0; i < n; i += config.BatchSize { + upper := i + config.BatchSize + if upper > n { + upper = n + } + var payloads []interface{} + for j := i; j < upper; j++ { + payloads = append(payloads, messages[j].Message) + } + if code, status, err := postMessage(payloads, config); err != nil { + log.Println("tnpg push failed:", err) + break + } else if code >= 300 { + log.Println("tnpg push rejected:", status, err) + break + } } } From 277cb639d1dac17a27d0e3a443024c19c6979c38 Mon Sep 17 00:00:00 2001 From: aforge Date: Mon, 16 Mar 2020 20:36:46 -0700 Subject: [PATCH 016/142] Correct Authorization header and push path in TNPG. --- server/push/tnpg/push_tnpg.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go index 5700a6d1c..c5a95c3c1 100644 --- a/server/push/tnpg/push_tnpg.go +++ b/server/push/tnpg/push_tnpg.go @@ -13,7 +13,7 @@ import ( "github.com/tinode/chat/server/push/fcm" ) -const targetAddress = "https://pushgw.tinode.co/push" +const baseTargetAddress = "https://pushgw.tinode.co/push/" var handler Handler @@ -23,10 +23,11 @@ type Handler struct { } type configType struct { - Enabled bool `json:"enabled"` - Buffer int `json:"buffer"` - CompressPayloads bool `json:"compress_payloads"` - BatchSize int `json:"batch_size"` + Enabled bool `json:"enabled"` + Buffer int `json:"buffer"` + CompressPayloads bool `json:"compress_payloads"` + BatchSize int `json:"batch_size"` + User string `json:"user"` AuthToken string `json:"auth_token"` Android fcm.AndroidConfig `json:"android,omitempty"` } @@ -46,6 +47,10 @@ func (Handler) Init(jsonconf string) error { return errors.New("push.tnpg.batch_size should be greater than zero.") } + if len(config.User) == 0 { + return errors.New("push.tnpg.user not specified.") + } + handler.input = make(chan *push.Receipt, config.Buffer) handler.stop = make(chan bool, 1) @@ -72,11 +77,12 @@ func postMessage(body interface{}, config *configType) (int, string, error) { } else { json.NewEncoder(buf).Encode(body) } + targetAddress := baseTargetAddress + config.User req, err := http.NewRequest("POST", targetAddress, buf) if err != nil { return -1, "", err } - req.Header.Add("Authorization", config.AuthToken) + req.Header.Add("Authorization", "Bearer " + config.AuthToken) req.Header.Set("Content-Type", "application/json; charset=utf-8") if config.CompressPayloads { req.Header.Add("Content-Encoding", "gzip") From ca7e96e3e07ebf516d36bba36cffa72dcd70d941 Mon Sep 17 00:00:00 2001 From: aforge Date: Mon, 16 Mar 2020 20:40:20 -0700 Subject: [PATCH 017/142] TNPG: hardcode batch size. --- server/push/tnpg/push_tnpg.go | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go index c5a95c3c1..5282bc49c 100644 --- a/server/push/tnpg/push_tnpg.go +++ b/server/push/tnpg/push_tnpg.go @@ -13,7 +13,10 @@ import ( "github.com/tinode/chat/server/push/fcm" ) -const baseTargetAddress = "https://pushgw.tinode.co/push/" +const ( + baseTargetAddress = "https://pushgw.tinode.co/push/" + batchSize = 100 +) var handler Handler @@ -26,7 +29,6 @@ type configType struct { Enabled bool `json:"enabled"` Buffer int `json:"buffer"` CompressPayloads bool `json:"compress_payloads"` - BatchSize int `json:"batch_size"` User string `json:"user"` AuthToken string `json:"auth_token"` Android fcm.AndroidConfig `json:"android,omitempty"` @@ -43,10 +45,6 @@ func (Handler) Init(jsonconf string) error { return nil } - if config.BatchSize <= 0 { - return errors.New("push.tnpg.batch_size should be greater than zero.") - } - if len(config.User) == 0 { return errors.New("push.tnpg.user not specified.") } @@ -102,8 +100,8 @@ func sendPushes(rcpt *push.Receipt, config *configType) { } n := len(messages) - for i := 0; i < n; i += config.BatchSize { - upper := i + config.BatchSize + for i := 0; i < n; i += batchSize { + upper := i + batchSize if upper > n { upper = n } From 76cad4a7ed6810d46e1fb4015dd439d19f60c682 Mon Sep 17 00:00:00 2001 From: aforge Date: Tue, 17 Mar 2020 01:19:12 -0700 Subject: [PATCH 018/142] Update README file for metrics exporters. --- monitoring/exporter/README.md | 63 ++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/monitoring/exporter/README.md b/monitoring/exporter/README.md index 38e08389f..ba0238e19 100644 --- a/monitoring/exporter/README.md +++ b/monitoring/exporter/README.md @@ -1,18 +1,63 @@ -# Prometheus `expvar` Exporter +# Tinode Metric Exporter -This is a [prometheus](https://prometheus.io/) [exporter](https://prometheus.io/docs/instrumenting/exporters/): a service which reads JSON monitoring data exposed by Tinode server using [expvar](https://golang.org/pkg/expvar/) and re-publishes it in [prometheus format](https://prometheus.io/docs/concepts/data_model/). +This is a simple service which reads JSON monitoring data exposed by Tinode server using [expvar](https://golang.org/pkg/expvar/) and re-publishes it in other formats. +Currently, supported are: +* [Prometheus](https://prometheus.io/) [exporter](https://prometheus.io/docs/instrumenting/exporters/) which exports data in [prometheus format](https://prometheus.io/docs/concepts/data_model/). +Note that the monitoring service is expected to pull/scrape data from Prometheus exporter. +* [InfluxDB](https://www.influxdata.com/) [exporter](https://docs.influxdata.com/influxdb/v1.7/tools/api/#write-http-endpoint), on the contrary, pushes data to its target backend. ## Usage -Run this service as -``` -./prometheus --tinode_addr=http://localhost:6060/stats/expvar \ - --namespace=tinode --listen_at=:6222 --metrics_path=/metrics -``` +Exporters are expected to run next to (pair with) Tinode servers: one Exporter per one Tinode server, i.e. a single Exporter provides metrics from a single Tinode server. +Currently, the Exporter is fully configured via command line flags. There are three sets of flags: +### Common flags +* `serve_for` specifies which monitoring service the Exporter will gather metrics for. * `tinode_addr` is the address where the Tinode instance publishes `expvar` data to scrape. -* `namespace` is a prefix to use for metrics names. If you are monitoring multiple tinode instances you may want to use different namespaces. * `listen_at` is the hostname to bind to for serving the metrics. -* `metrics_path` path under which to expose the metrics. +* `instance` is the Exporter instance name (it may be exported to the upstream backend). +* `metric_list` is a comma-separated list of metrics to export. + +### Prometheus +* `prom_namespace` is a prefix to use for metrics names. If you are monitoring multiple tinode instances you may want to use different namespaces. +* `prom_metrics_path` is the path under which to expose the metrics for scraping. +### InfluxDB +* `influx_push_addr` is the address of InfluxDB target server where the data gets sent. +* `influx_db_version` is the version of InfluxDB (only 1.7 and 2.0 are supported). +* `influx_organization` specifies InfluxDB organization to push metrics as. +* `influx_bucket` is the name of InfluxDB storage bucket to store data in (used only in InfluxDB 2.0). +* `influx_auth_token` - InfluxDB authentication token. +* `influx_push_interval` - InfluxDB push interval in seconds. + +## Examples +Run Prometheus Exporter as +``` +./exporter \ + --serve_for=prometheus \ + --tinode_addr=http://localhost:6060/stats/expvar \ + --listen_at=:6222 \ + --instance=exp-0 \ + --prom_namespace=tinode \ + --prom_metrics_path=/metrics \ + --prom_timeout=15 +``` + +This exporter will serve data at path /metrics, on port 6222. Once running, configure your Prometheus monitoring installation to collect data from this exporter. + +Run InfluxDB Exporter as +``` +./exporter \ + --serve_for=influxdb \ + --tinode_addr=http://localhost:6060/stats/expvar \ + --listen_at=:6222 \ + --instance=exp-0 \ + --influx_push_addr=http://my-influxdb-backend.net/write \ + --influx_db_version=1.7 \ + --influx_organization=myOrg \ + --influx_auth_token=myAuthToken123 \ + --influx_push_interval=30 +``` + +This exporter will push the collected metrics to the specified backend once every 30 seconds. From 9881106c2760bb38c5d7ec242b2daa8a8b35e8ab Mon Sep 17 00:00:00 2001 From: aforge Date: Tue, 17 Mar 2020 20:56:13 -0700 Subject: [PATCH 019/142] More info in monitoring README.md. --- monitoring/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/monitoring/README.md b/monitoring/README.md index 4cdd12c6a..cf1867973 100644 --- a/monitoring/README.md +++ b/monitoring/README.md @@ -1,3 +1,7 @@ # Monitoring Support -This directory contains code related to monitoring Tinode server. Only [Prometheus](https://prometheus.io/) is [supported](./prometheus/) at this time. +This directory contains code related to monitoring Tinode server. Supported monitoring services are +* [Prometheus](https://prometheus.io/) +* [InfluxDB](https://www.influxdata.com/) + +See [exporter/README](./exporter/README.md) for more details. From 7a8f8bcc760a8c0b7916c1b4423190095df0b33c Mon Sep 17 00:00:00 2001 From: or-else Date: Wed, 18 Mar 2020 09:55:41 +0300 Subject: [PATCH 020/142] mention default values --- monitoring/exporter/README.md | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/monitoring/exporter/README.md b/monitoring/exporter/README.md index ba0238e19..261fd4226 100644 --- a/monitoring/exporter/README.md +++ b/monitoring/exporter/README.md @@ -12,23 +12,24 @@ Exporters are expected to run next to (pair with) Tinode servers: one Exporter p Currently, the Exporter is fully configured via command line flags. There are three sets of flags: ### Common flags -* `serve_for` specifies which monitoring service the Exporter will gather metrics for. -* `tinode_addr` is the address where the Tinode instance publishes `expvar` data to scrape. -* `listen_at` is the hostname to bind to for serving the metrics. -* `instance` is the Exporter instance name (it may be exported to the upstream backend). -* `metric_list` is a comma-separated list of metrics to export. +* `serve_for` specifies which monitoring service the Exporter will gather metrics for; accepted values: `influxdb`, `prometheus`; default: `influxdb`. +* `tinode_addr` is the address where the Tinode instance publishes `expvar` data to scrape; default: `http://localhost:6060/stats/expvar`. +* `listen_at` is the hostname to bind to for serving the metrics; default: `:6222`. +* `instance` is the Exporter instance name (it may be exported to the upstream backend); default: `exporter`. +* `metric_list` is a comma-separated list of metrics to export; default: `Version,LiveTopics,TotalTopics,LiveSessions,ClusterLeader,TotalClusterNodes,LiveClusterNodes,memstats.Alloc`. ### Prometheus -* `prom_namespace` is a prefix to use for metrics names. If you are monitoring multiple tinode instances you may want to use different namespaces. -* `prom_metrics_path` is the path under which to expose the metrics for scraping. +* `prom_namespace` is a prefix to use for metrics names. If you are monitoring multiple tinode instances you may want to use different namespaces; default: `tinode`. +* `prom_metrics_path` is the path under which to expose the metrics for scraping; default: `/metrics`. +* `prom_timeout` is the Tinode connection timeout in seconds in response to Prometheus scrapes; default: `15`. ### InfluxDB -* `influx_push_addr` is the address of InfluxDB target server where the data gets sent. -* `influx_db_version` is the version of InfluxDB (only 1.7 and 2.0 are supported). -* `influx_organization` specifies InfluxDB organization to push metrics as. -* `influx_bucket` is the name of InfluxDB storage bucket to store data in (used only in InfluxDB 2.0). -* `influx_auth_token` - InfluxDB authentication token. -* `influx_push_interval` - InfluxDB push interval in seconds. +* `influx_push_addr` is the address of InfluxDB target server where the data gets sent; default: `http://localhost:9999/write`. +* `influx_db_version` is the version of InfluxDB (only 1.7 and 2.0 are supported); default: `1.7`. +* `influx_organization` specifies InfluxDB organization to push metrics as; default: `test`; +* `influx_bucket` is the name of InfluxDB storage bucket to store data in (used only in InfluxDB 2.0); default: `test`. +* `influx_auth_token` - InfluxDB authentication token; no default value. +* `influx_push_interval` - InfluxDB push interval in seconds; default: `30`. ## Examples Run Prometheus Exporter as From e2409157b501902a4ac82b88b817977a3cf27cc9 Mon Sep 17 00:00:00 2001 From: or-else Date: Wed, 18 Mar 2020 17:37:31 +0300 Subject: [PATCH 021/142] add support for unix sockets --- server/hdl_grpc.go | 3 +-- server/http.go | 38 ++++++++++++++++++++++++++------------ server/tinode.conf | 11 +++++++---- server/utils.go | 17 +++++++++++++++++ 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/server/hdl_grpc.go b/server/hdl_grpc.go index bcdd6e186..cb72a8a10 100644 --- a/server/hdl_grpc.go +++ b/server/hdl_grpc.go @@ -13,7 +13,6 @@ import ( "crypto/tls" "io" "log" - "net" "time" "github.com/tinode/chat/pbx" @@ -115,7 +114,7 @@ func serveGrpc(addr string, kaEnabled bool, tlsConf *tls.Config) (*grpc.Server, return nil, nil } - lis, err := net.Listen("tcp", addr) + lis, err := netListener(addr) if err != nil { return nil, err } diff --git a/server/http.go b/server/http.go index 084ee7866..31c3d93da 100644 --- a/server/http.go +++ b/server/http.go @@ -13,6 +13,7 @@ import ( "crypto/tls" "encoding/base64" "encoding/json" + "errors" "log" "net" "net/http" @@ -34,7 +35,6 @@ func listenAndServe(addr string, mux *http.ServeMux, tlfConf *tls.Config, stop < httpdone := make(chan bool) server := &http.Server{ - Addr: addr, Handler: mux, } @@ -45,24 +45,38 @@ func listenAndServe(addr string, mux *http.ServeMux, tlfConf *tls.Config, stop < if server.TLSConfig != nil { // If port is not specified, use default https port (443), // otherwise it will default to 80 - if server.Addr == "" { - server.Addr = ":https" + if addr == "" { + addr = ":https" } if globals.tlsRedirectHTTP != "" { - log.Printf("Redirecting connections from HTTP at [%s] to HTTPS at [%s]", - globals.tlsRedirectHTTP, server.Addr) - - // This is a second HTTP server listenning on a different port. - go http.ListenAndServe(globals.tlsRedirectHTTP, tlsRedirect(server.Addr)) + // Serving redirects from a unix socket or to a unix socket makes no sense. + if isUnixAddr(globals.tlsRedirectHTTP) || isUnixAddr(addr) { + err = errors.New("HTTP to HTTPS redirect: unix sockets not supported.") + } else { + log.Printf("Redirecting connections from HTTP at [%s] to HTTPS at [%s]", + globals.tlsRedirectHTTP, addr) + + // This is a second HTTP server listenning on a different port. + go http.ListenAndServe(globals.tlsRedirectHTTP, tlsRedirect(addr)) + } } - log.Printf("Listening for client HTTPS connections on [%s]", server.Addr) - err = server.ListenAndServeTLS("", "") + if err == nil { + log.Printf("Listening for client HTTPS connections on [%s]", addr) + lis, err := netListener(addr) + if err == nil { + err = server.ServeTLS(lis, "", "") + } + } } else { - log.Printf("Listening for client HTTP connections on [%s]", server.Addr) - err = server.ListenAndServe() + log.Printf("Listening for client HTTP connections on [%s]", addr) + lis, err := netListener(addr) + if err == nil { + err = server.Serve(lis) + } } + if err != nil { if globals.shuttingDown { log.Println("HTTP server: stopped") diff --git a/server/tinode.conf b/server/tinode.conf index 9a36c5dab..333a80b1e 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -1,8 +1,9 @@ // The JSON comments are somewhat brittle. Don't try anything too fancy. { - // HTTP(S) address:port to listen on for websocket and long polling clients. Either a - // numeric value or a canonical name, e.g. ":80" or ":https". May include the host name, e.g. - // "localhost:80" or "hostname.example.com:https". + // HTTP(S) address to listen on for websocket and long polling clients. Either a TCP host:port pair + // or a path to Unix socket as "unix:/path/to/socket.sock". + // The TCP port is numeric value or a canonical name, e.g. ":80" or ":https". May include the host name, + ///e.g. "localhost:80" or "hostname.example.com:https". // It could be blank: if TLS is not configured it will default to ":80", otherwise to ":443". // Can be overridden from the command line, see option --listen. "listen": ":6060", @@ -17,7 +18,8 @@ // URL path for mounting the directory with static files. "static_mount": "/", - // Address:port to listen for gRPC clients. Leave blank to disable gRPC support. + // TCP host:port or unix:/path/to/socket to listen for gRPC clients. + // Leave blank to disable gRPC support. // Could be overridden from the command line with --grpc_listen. "grpc_listen": ":6061", @@ -85,6 +87,7 @@ "enabled": false, // Listen for connections on this port and redirect them to HTTPS port. + // Cannot be a Unix socket. "http_redirect": ":80", // Add Strict-Transport-Security to headers, the value signifies age. diff --git a/server/utils.go b/server/utils.go index 122202d78..560f643e4 100644 --- a/server/utils.go +++ b/server/utils.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "net" "path/filepath" "reflect" "regexp" @@ -779,3 +780,19 @@ func offsetToLineAndChar(r io.Reader, offset int64) (int, int, error) { return lnum, cnum, nil } + +// netListener creates net.Listener for tcp and unix domains: +// if addr is is in the form "unix:/run/tinode.sock" it's a unix socket, otherwise TCP host:port. +func netListener(addr string) (net.Listener, error) { + addrParts := strings.SplitN(addr, ":", 2) + if len(addrParts) == 2 && addrParts[0] == "unix" { + return net.Listen("unix", addrParts[1]) + } + return net.Listen("tcp", addr) +} + +// Check if specified address is a unix socket like "unix:/run/tinode.sock". +func isUnixAddr(addr string) bool { + addrParts := strings.SplitN(addr, ":", 2) + return len(addrParts) == 2 && addrParts[0] == "unix" +} From 7905bf5197d6bff59a7fa9f283eca16de8df7a9f Mon Sep 17 00:00:00 2001 From: or-else Date: Wed, 18 Mar 2020 17:37:51 +0300 Subject: [PATCH 022/142] better handling of s3 bucket errors --- server/media/s3/s3.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/server/media/s3/s3.go b/server/media/s3/s3.go index 8c937b0b9..f96e96f0b 100644 --- a/server/media/s3/s3.go +++ b/server/media/s3/s3.go @@ -95,11 +95,13 @@ func (ah *awshandler) Init(jsconf string) error { // Check if the bucket exists, create one if not. _, err = ah.svc.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(ah.conf.BucketName)}) if err != nil { - // Bucket exists or a genuine error. - if aerr, ok := err.(awserr.Error); !ok || - (aerr.Code() != s3.ErrCodeBucketAlreadyExists && - aerr.Code() != s3.ErrCodeBucketAlreadyOwnedByYou) { - return err + // Check if bucket already exists or a genuine error. + if aerr, ok := err.(awserr.Error); ok { + if aerr.Code() == s3.ErrCodeBucketAlreadyExists || + aerr.Code() == s3.ErrCodeBucketAlreadyOwnedByYou { + // Clear benign error + err = nil + } } } else { // This is a new bucket. From ccb57e168c45f1f3873ab63dc68f0fb7dd32bc51 Mon Sep 17 00:00:00 2001 From: aforge Date: Thu, 19 Mar 2020 00:51:32 -0700 Subject: [PATCH 023/142] Exporter Docker configuration. --- docker/README.md | 30 +++++++++++++++++- docker/exporter/Dockerfile | 37 +++++++++++++++++++++++ docker/exporter/entrypoint.sh | 57 +++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 docker/exporter/Dockerfile create mode 100755 docker/exporter/entrypoint.sh diff --git a/docker/README.md b/docker/README.md index 38f9f1296..263e40d55 100644 --- a/docker/README.md +++ b/docker/README.md @@ -126,7 +126,6 @@ See [instructions](../chatbot/python/). The chatbot password is generated only when the database is initialized or reset. It's saved to `/botdata` directory in the container. If you want to keep the data available between container changes, such as image upgrades, make sure the `/botdata` is a mounted volume (i.e. you always launch the container with `--volume botdata:/botdata` option). - ## Supported environment variables You can specify the following environment variables when issuing `docker run` command: @@ -172,3 +171,32 @@ A convenient way to generate a desired number of random bytes and base64-encode ``` $ openssl rand -base64 ``` + +## Metrics Exporter + +See [monitoring/exporter/README](../../monitoring/exporter/README.md) for information on the Exporter. +Container is also available as a part of the Tinode docker distribution: `tinode/exporter`. +Run it with + +``` +$ docker run -p 6222:6222 -d --name tinode-exporter --network tinode-net \ + --env SERVE_FOR= \ + --env TINODE_ADDR= \ + --env LISTEN_AT=":6222" \ + ... \ + tinode/exporter:latest +``` + +Available variables: +| Variable | Type | Default | Function | +| --- | --- | --- | --- | +| `SERVE_FOR` | string | `` | Either `prometheus` or `influxdb` | +| `TINODE_ADDR` | string | `http://localhost/stats/expvar/` | Tinode metrics path | +| `LISTEN_AT` | string | `:6222` | Exporter web server host and port | +| `INFLUXDB_VERSION` | string | `1.7` | InfluxDB version (`1.7` or `2.0`) | +| `INFLUXDB_ORGANIZATION` | string | `org` | InfluxDB organization | +| `INFLUXDB_PUSH_INTERVAL` | int | `60` | Exporter's metrics push interval in seconds | +| `INFLUXDB_PUSH_ADDRESS` | string | `https://mon.tinode.co/intake` | InfluxDB backend url | +| `INFLUXDB_AUTH_TOKEN` | string | `Your-token` | InfluxDB auth token | +| `PROM_NAMESPACE` | string | `tinode` | Prometheus namespace | +| `PROM_METRICS_PATH` | string | `/metrics` | Exporter webserver path that Prometheus server scrapes | diff --git a/docker/exporter/Dockerfile b/docker/exporter/Dockerfile new file mode 100644 index 000000000..6032f79ae --- /dev/null +++ b/docker/exporter/Dockerfile @@ -0,0 +1,37 @@ +FROM alpine:latest + +ARG VERSION=0.16.4 +ENV VERSION=$VERSION + +ENV SERVE_FOR="" + +ENV TINODE_ADDR=http://localhost/stats/expvar/ +ENV INSTANCE="exporter-instance" +ENV LISTEN_AT=":6222" + +ENV INFLUXDB_VERSION=1.7 +ENV INFLUXDB_ORGANIZATION="org" +ENV INFLUXDB_PUSH_INTERVAL=60 +ENV INFLUXDB_PUSH_ADDRESS=http://localhost:6222/write +ENV INFLUXDB_AUTH_TOKEN="Your-token" + +ENV PROM_NAMESPACE="tinode" +ENV PROM_METRICS_PATH="/metrics" + +LABEL maintainer="Tinode Team " +LABEL name="TinodeMetricExporter" +LABEL version=$VERSION + +WORKDIR /opt/tinode + +RUN apk add --no-cache bash + +# Fetch exporter build from Github. +ADD https://github.com/tinode/chat/releases/download/v$VERSION/exporter.linux-amd64 ./exporter + +COPY entrypoint.sh . +RUN chmod +x exporter && chmod +x entrypoint.sh + +ENTRYPOINT ./entrypoint.sh + +EXPOSE 6222 diff --git a/docker/exporter/entrypoint.sh b/docker/exporter/entrypoint.sh new file mode 100755 index 000000000..1523edb12 --- /dev/null +++ b/docker/exporter/entrypoint.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +# Check if environment variables (provided as argument list) are set. +function check_vars() { + local varnames=( "$@" ) + for varname in "${varnames[@]}" + do + eval value=\$${varname} + if [ -z "$value" ] ; then + echo "$varname env var must be specified." + exit 1 + fi + done +} + +echo "hosts: files dns" > /etc/nsswitch.conf + +# Required env vars. +common_vars=( TINODE_ADDR INSTANCE LISTEN_AT SERVE_FOR ) + +influx_varnames=( INFLUXDB_VERSION INFLUXDB_ORGANIZATION INFLUXDB_PUSH_INTERVAL \ + INFLUXDB_PUSH_ADDRESS INFLUXDB_AUTH_TOKEN ) + +prometheus_varnames=( PROM_NAMESPACE PROM_METRICS_PATH ) + +check_vars "${common_vars[@]}" + +# Common arguments. +args=("--tinode_addr=${TINODE_ADDR}" "--instance=${INSTANCE}" "--listen_at=${LISTEN_AT}" "--serve_for=${SERVE_FOR}") + +# Platform-specific arguments. +case "$SERVE_FOR" in +"prometheus") + check_vars "${prometheus_varnames[@]}" + args+=("--prom_namespace=${PROM_NAMESPACE}" "--prom_metrics_path=${PROM_METRICS_PATH}") + if [ ! -z "$PROM_TIMEOUT" ]; then + args+=("--prom_timeout=${PROM_TIMEOUT}") + fi + ;; +"influxdb") + check_vars "${influxdb_varnames[@]}" + args+=("--influx_db_version=${INFLUXDB_VERSION}" \ + "--influx_organization=${INFLUXDB_ORGANIZATION}" \ + "--influx_push_interval=${INFLUXDB_PUSH_INTERVAL}" \ + "--influx_push_addr=${INFLUXDB_PUSH_ADDRESS}" \ + "--influx_auth_token=${INFLUXDB_AUTH_TOKEN}") + if [ ! -z "$INFLUXDB_BUCKET" ]; then + args+=("--influx_bucket=${INFLUXDB_BUCKET}") + fi + ;; +*) + echo "\$SERVE_FOR must be set to either 'prometheus' or 'influxdb'" + exit 1 + ;; +esac + +./exporter "${args[@]}" From 0f7738afa02d45d19d85ba40aa3b09118a78a55e Mon Sep 17 00:00:00 2001 From: aforge Date: Thu, 19 Mar 2020 00:57:31 -0700 Subject: [PATCH 024/142] Add tinode/exporter docker to docker build and release scripts. --- docker-build.sh | 10 ++++++++++ docker-release.sh | 16 ++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/docker-build.sh b/docker-build.sh index d7d042627..7a649ca21 100755 --- a/docker-build.sh +++ b/docker-build.sh @@ -53,3 +53,13 @@ if [ -n "$FULLRELEASE" ]; then fi docker rmi ${rmitags} docker build --build-arg VERSION=$tag ${buildtags} docker/chatbot + +# Build exporter image +buildtags="--tag tinode/exporter:${ver[0]}.${ver[1]}.${ver[2]}" +rmitags="tinode/exporter:${ver[0]}.${ver[1]}.${ver[2]}" +if [ -n "$FULLRELEASE" ]; then + rmitags="${rmitags} tinode/exporter:latest tinode/exporter:${ver[0]}.${ver[1]}" + buildtags="${buildtags} --tag tinode/exporter:latest --tag tinode/exporter:${ver[0]}.${ver[1]}" +fi +docker rmi ${rmitags} +docker build --build-arg VERSION=$tag ${buildtags} docker/exporter diff --git a/docker-release.sh b/docker-release.sh index f706b17ab..09a0be3d7 100755 --- a/docker-release.sh +++ b/docker-release.sh @@ -63,6 +63,15 @@ fi curl -u $user:$pass -i -X DELETE \ https://hub.docker.com/v2/repositories/tinode/chatbot/tags/${ver[0]}.${ver[1]}.${ver[2]}/ +if [ -n "$FULLRELEASE" ]; then + curl -u $user:$pass -i -X DELETE \ + https://hub.docker.com/v2/repositories/tinode/exporter/tags/latest/ + curl -u $user:$pass -i -X DELETE \ + https://hub.docker.com/v2/repositories/tinode/exporter/tags/${ver[0]}.${ver[1]}/ +fi +curl -u $user:$pass -i -X DELETE \ + https://hub.docker.com/v2/repositories/tinode/exporter/tags/${ver[0]}.${ver[1]}.${ver[2]}/ + # Deploy images for various DB backends for dbtag in "${dbtags[@]}" do @@ -82,4 +91,11 @@ if [ -n "$FULLRELEASE" ]; then fi docker push tinode/chatbot:"${ver[0]}.${ver[1]}.${ver[2]}" +# Deploy exporter images +if [ -n "$FULLRELEASE" ]; then + docker push tinode/exporter:latest + docker push tinode/exporter:"${ver[0]}.${ver[1]}" +fi +docker push tinode/exporter:"${ver[0]}.${ver[1]}.${ver[2]}" + docker logout From d5ddd5537864d3d2325a387032e502659970798d Mon Sep 17 00:00:00 2001 From: aforge Date: Thu, 19 Mar 2020 01:00:26 -0700 Subject: [PATCH 025/142] Fix exporter README path. --- docker/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/README.md b/docker/README.md index 263e40d55..52a920bff 100644 --- a/docker/README.md +++ b/docker/README.md @@ -174,7 +174,7 @@ $ openssl rand -base64 ## Metrics Exporter -See [monitoring/exporter/README](../../monitoring/exporter/README.md) for information on the Exporter. +See [monitoring/exporter/README](../monitoring/exporter/README.md) for information on the Exporter. Container is also available as a part of the Tinode docker distribution: `tinode/exporter`. Run it with From 01b6a1554276caf0ff8251b20a2d0cb84efcf4b8 Mon Sep 17 00:00:00 2001 From: aforge Date: Thu, 19 Mar 2020 01:02:20 -0700 Subject: [PATCH 026/142] Formatting. --- docker/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/README.md b/docker/README.md index 52a920bff..e7bd79bc7 100644 --- a/docker/README.md +++ b/docker/README.md @@ -181,21 +181,21 @@ Run it with ``` $ docker run -p 6222:6222 -d --name tinode-exporter --network tinode-net \ --env SERVE_FOR= \ - --env TINODE_ADDR= \ - --env LISTEN_AT=":6222" \ - ... \ + --env TINODE_ADDR= \ + --env LISTEN_AT=":6222" \ + ... \ tinode/exporter:latest ``` Available variables: | Variable | Type | Default | Function | | --- | --- | --- | --- | -| `SERVE_FOR` | string | `` | Either `prometheus` or `influxdb` | +| `SERVE_FOR` | string | `` | Monitoring service: `prometheus` or `influxdb` | | `TINODE_ADDR` | string | `http://localhost/stats/expvar/` | Tinode metrics path | | `LISTEN_AT` | string | `:6222` | Exporter web server host and port | | `INFLUXDB_VERSION` | string | `1.7` | InfluxDB version (`1.7` or `2.0`) | | `INFLUXDB_ORGANIZATION` | string | `org` | InfluxDB organization | -| `INFLUXDB_PUSH_INTERVAL` | int | `60` | Exporter's metrics push interval in seconds | +| `INFLUXDB_PUSH_INTERVAL` | int | `60` | Exporter metrics push interval in seconds | | `INFLUXDB_PUSH_ADDRESS` | string | `https://mon.tinode.co/intake` | InfluxDB backend url | | `INFLUXDB_AUTH_TOKEN` | string | `Your-token` | InfluxDB auth token | | `PROM_NAMESPACE` | string | `tinode` | Prometheus namespace | From aa952f92af670880776fd0351da375d27c32b3cb Mon Sep 17 00:00:00 2001 From: or-else Date: Thu, 19 Mar 2020 13:39:55 +0300 Subject: [PATCH 027/142] enforce sane minumum push interval + gofmt --- monitoring/exporter/main.go | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/monitoring/exporter/main.go b/monitoring/exporter/main.go index c86d7e243..c4fe43833 100644 --- a/monitoring/exporter/main.go +++ b/monitoring/exporter/main.go @@ -20,6 +20,11 @@ const ( InfluxDB MonitoringService = 2 ) +const ( + // Minimum interval between InfluxDB pushes in seconds. + minPushInterval = 10 +) + type promHTTPLogger struct{} func (l promHTTPLogger) Println(v ...interface{}) { @@ -40,16 +45,16 @@ func main() { log.Printf("Tinode metrics exporter.") var ( - serveFor = flag.String("serve_for", "influxdb", "Monitoring service to gather metrics for. Available: influxdb, prometheus.") - tinodeAddr = flag.String("tinode_addr", "http://localhost:6060/stats/expvar", "Address of the Tinode instance to scrape.") - listenAt = flag.String("listen_at", ":6222", "Host name and port to listen for incoming requests on.") - metricList = flag.String("metric_list", "Version,LiveTopics,TotalTopics,LiveSessions,ClusterLeader,TotalClusterNodes,LiveClusterNodes,memstats.Alloc", "Comma-separated list of metrics to scrape and export.") - instance = flag.String("instance", "exporter", "Exporter instance name.") + serveFor = flag.String("serve_for", "influxdb", "Monitoring service to gather metrics for. Available: influxdb, prometheus.") + tinodeAddr = flag.String("tinode_addr", "http://localhost:6060/stats/expvar", "Address of the Tinode instance to scrape.") + listenAt = flag.String("listen_at", ":6222", "Host name and port to listen for incoming requests on.") + metricList = flag.String("metric_list", "Version,LiveTopics,TotalTopics,LiveSessions,ClusterLeader,TotalClusterNodes,LiveClusterNodes,memstats.Alloc", "Comma-separated list of metrics to scrape and export.") + instance = flag.String("instance", "exporter", "Exporter instance name.") // Prometheus-specific arguments. - promNamespace = flag.String("prom_namespace", "tinode", "Prometheus namespace for metrics '_...'") - promMetricsPath = flag.String("prom_metrics_path", "/metrics", "Path under which to expose metrics for Prometheus scrapes.") - promTimeout = flag.Int("prom_timeout", 15, "Tinode connection timeout in seconds in response to Prometheus scrapes.") + promNamespace = flag.String("prom_namespace", "tinode", "Prometheus namespace for metrics '_...'") + promMetricsPath = flag.String("prom_metrics_path", "/metrics", "Path under which to expose metrics for Prometheus scrapes.") + promTimeout = flag.Int("prom_timeout", 15, "Tinode connection timeout in seconds in response to Prometheus scrapes.") // InfluxDB-specific arguments. influxPushAddr = flag.String("influx_push_addr", "http://localhost:9999/write", "Address of InfluxDB target server where the data gets sent.") @@ -88,6 +93,10 @@ func main() { if *influxDBVersion != "1.7" && *influxDBVersion != "2.0" { log.Fatal("Please, set --influx_db_version to either 1.7 or 2.0") } + if *influxPushInterval > 0 && *influxPushInterval < minPushInterval { + *influxPushInterval = minPushInterval + log.Println("The --influx_push_interval is too low, reset to", minPushInterval) + } } // Index page at web root. @@ -103,7 +112,7 @@ func main() { w.Write([]byte(`Tinode Exporter

Tinode Exporter

Server type` + *serveFor + `

` + servingPath + -`

Build

+ `

Build

` + version.Info() + ` ` + version.BuildContext() + `
`)) }) From b8c5b2a29d8ddf4d8fb8efae5a9f499dae261e52 Mon Sep 17 00:00:00 2001 From: aforge Date: Thu, 19 Mar 2020 19:33:29 -0700 Subject: [PATCH 028/142] Address comments. --- docker/README.md | 4 +--- docker/exporter/Dockerfile | 13 ++++++------- docker/exporter/entrypoint.sh | 9 ++++++++- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docker/README.md b/docker/README.md index e7bd79bc7..8780923af 100644 --- a/docker/README.md +++ b/docker/README.md @@ -182,7 +182,6 @@ Run it with $ docker run -p 6222:6222 -d --name tinode-exporter --network tinode-net \ --env SERVE_FOR= \ --env TINODE_ADDR= \ - --env LISTEN_AT=":6222" \ ... \ tinode/exporter:latest ``` @@ -192,11 +191,10 @@ Available variables: | --- | --- | --- | --- | | `SERVE_FOR` | string | `` | Monitoring service: `prometheus` or `influxdb` | | `TINODE_ADDR` | string | `http://localhost/stats/expvar/` | Tinode metrics path | -| `LISTEN_AT` | string | `:6222` | Exporter web server host and port | | `INFLUXDB_VERSION` | string | `1.7` | InfluxDB version (`1.7` or `2.0`) | | `INFLUXDB_ORGANIZATION` | string | `org` | InfluxDB organization | | `INFLUXDB_PUSH_INTERVAL` | int | `60` | Exporter metrics push interval in seconds | | `INFLUXDB_PUSH_ADDRESS` | string | `https://mon.tinode.co/intake` | InfluxDB backend url | -| `INFLUXDB_AUTH_TOKEN` | string | `Your-token` | InfluxDB auth token | +| `INFLUXDB_AUTH_TOKEN` | string | `` | InfluxDB auth token | | `PROM_NAMESPACE` | string | `tinode` | Prometheus namespace | | `PROM_METRICS_PATH` | string | `/metrics` | Exporter webserver path that Prometheus server scrapes | diff --git a/docker/exporter/Dockerfile b/docker/exporter/Dockerfile index 6032f79ae..1602021a1 100644 --- a/docker/exporter/Dockerfile +++ b/docker/exporter/Dockerfile @@ -3,25 +3,24 @@ FROM alpine:latest ARG VERSION=0.16.4 ENV VERSION=$VERSION +LABEL maintainer="Tinode Team " +LABEL name="TinodeMetricExporter" +LABEL version=$VERSION + ENV SERVE_FOR="" ENV TINODE_ADDR=http://localhost/stats/expvar/ ENV INSTANCE="exporter-instance" -ENV LISTEN_AT=":6222" ENV INFLUXDB_VERSION=1.7 ENV INFLUXDB_ORGANIZATION="org" ENV INFLUXDB_PUSH_INTERVAL=60 -ENV INFLUXDB_PUSH_ADDRESS=http://localhost:6222/write -ENV INFLUXDB_AUTH_TOKEN="Your-token" +ENV INFLUXDB_PUSH_ADDRESS="" +ENV INFLUXDB_AUTH_TOKEN="" ENV PROM_NAMESPACE="tinode" ENV PROM_METRICS_PATH="/metrics" -LABEL maintainer="Tinode Team " -LABEL name="TinodeMetricExporter" -LABEL version=$VERSION - WORKDIR /opt/tinode RUN apk add --no-cache bash diff --git a/docker/exporter/entrypoint.sh b/docker/exporter/entrypoint.sh index 1523edb12..6de9940ca 100755 --- a/docker/exporter/entrypoint.sh +++ b/docker/exporter/entrypoint.sh @@ -13,10 +13,17 @@ function check_vars() { done } +# Make sure the system uses /etc/hosts when resolving domain names +# (needed for docker-compose's `extra_hosts` param to work correctly). +# See https://github.com/gliderlabs/docker-alpine/issues/367, +# https://github.com/golang/go/issues/35305 for details. echo "hosts: files dns" > /etc/nsswitch.conf +# Accept http requests at. +LISTEN_AT=":6222" + # Required env vars. -common_vars=( TINODE_ADDR INSTANCE LISTEN_AT SERVE_FOR ) +common_vars=( TINODE_ADDR INSTANCE SERVE_FOR ) influx_varnames=( INFLUXDB_VERSION INFLUXDB_ORGANIZATION INFLUXDB_PUSH_INTERVAL \ INFLUXDB_PUSH_ADDRESS INFLUXDB_AUTH_TOKEN ) From aec45ed04d6e52131340cbe7e0f0d51fc96ddf8b Mon Sep 17 00:00:00 2001 From: aforge Date: Thu, 19 Mar 2020 20:50:33 -0700 Subject: [PATCH 029/142] Tinode docker consolidation. --- docker/tinode/Dockerfile | 4 ++ docker/tinode/config.template | 2 +- docker/tinode/entrypoint.sh | 71 +++++++++++++++++++++++++++++++++-- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index f1150c27e..6e438a1b2 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -24,11 +24,15 @@ LABEL version=$VERSION # Alternatively use # `--build-arg TARGET_DB=mysql` to build for MySQL or # `--build-arg TARGET_DB=mongodb` to build for MongoDB. +# `--build-arg TARGET_DB=alldbs` to build a generic Tinode docker image. ARG TARGET_DB=rethinkdb ENV TARGET_DB=$TARGET_DB # Runtime options. +# Specifies what jobs to run: init-db, tinode or both. +ENV SERVICES_TO_RUN='both' + # An option to reset database. ENV RESET_DB=false diff --git a/docker/tinode/config.template b/docker/tinode/config.template index 872b7d20b..9c13c681a 100644 --- a/docker/tinode/config.template +++ b/docker/tinode/config.template @@ -8,7 +8,7 @@ "max_message_size": 4194304, "max_subscriber_count": 32, "max_tag_count": 16, - "expvar": "", + "expvar": "/stats/expvar/", "media": { "use_handler": "$MEDIA_HANDLER", diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index c6b244bf0..e784cc2b7 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -71,8 +71,56 @@ else echo "" > $STATIC_DIR/firebase-init.js fi -# Initialize the database if it has not been initialized yet or if data reset/upgrade has been requested. -./init-db --reset=${RESET_DB} --upgrade=${UPGRADE_DB} --config=${CONFIG} --data=${SAMPLE_DATA} | grep "usr;tino;" > /botdata/tino-password +if [ ! -z "$IOS_UNIV_LINKS_APP_ID" ] ; then + # Write config to $STATIC_DIR/apple-app-site-association config file. + # See https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/UniversalLinks.html for details. + cat > $STATIC_DIR/apple-app-site-association <<- EOM +{ + "applinks": { + "apps": [], + "details": [ + { + "appID": "$IOS_UNIV_LINKS_APP_ID", + "paths": [ "*" ] + } + ] + } +} +EOM +fi + +run_init_db=false +run_tinode=false +case "$SERVICES_TO_RUN" in +"init-db") + run_init_db=true + ;; +"tinode") + run_tinode=true + ;; +"both") + run_init_db=true + run_tinode=true + ;; +*) + echo "Invalid val for SERVICES_TO_RUN env var. Can be either 'init-db' or 'tinode' or 'both'." + exit 1 + ;; +esac + +echo "Will run init-db: ${run_init_db}, tinode: ${run_tinode}" + +touch /botdata/tino-password + +if [ "$run_init_db" == "true" ]; then + # Initialize the database if it has not been initialized yet or if data reset/upgrade has been requested. + ./init-db --reset=${RESET_DB} --upgrade=${UPGRADE_DB} --config=${CONFIG} | grep "usr;tino;" > /botdata/tino-password +fi + +if [ "$run_tinode" != "true" ]; then + # If we don't want to run tinode, we are done. + exit 0 +fi if [ -s /botdata/tino-password ] ; then # Convert Tino's authentication credentials into a cookie file. @@ -89,5 +137,22 @@ args=("--config=${CONFIG}" "--static_data=$STATIC_DIR") if [ ! -z "$CLUSTER_SELF" ] ; then args+=("--cluster_self=$CLUSTER_SELF") fi +if [ ! -z "$PPROF_URL" ] ; then + args+=("--pprof_url=$PPROF_URL") +fi + +# Create the log directory (/var/log/tinode-`current timestamp`). +# And symlink /var/log/tinode-latest to it. +runid=tinode-`date +%s` +logdir=/var/log/$runid +mkdir -p $logdir +if [ -d /var/log/tinode-latest ]; then + rm /var/log/tinode-latest +fi +pushd . +cd /var/log +ln -s $runid tinode-latest +popd + # Run the tinode server. -./tinode "${args[@]}" 2> /var/log/tinode.log +./tinode "${args[@]}" 2> $logdir/tinode.log From 1c7b04275686ad555bc523b8e933c33229caf149 Mon Sep 17 00:00:00 2001 From: aforge Date: Thu, 19 Mar 2020 23:36:35 -0700 Subject: [PATCH 030/142] Example e2e docker-compose setup. --- docker/e2e/README.md | 1 + docker/e2e/cluster-docker-compose.yml | 207 +++++++++++++++++++++++ docker/e2e/standalone-docker-compose.yml | 136 +++++++++++++++ 3 files changed, 344 insertions(+) create mode 100644 docker/e2e/README.md create mode 100644 docker/e2e/cluster-docker-compose.yml create mode 100644 docker/e2e/standalone-docker-compose.yml diff --git a/docker/e2e/README.md b/docker/e2e/README.md new file mode 100644 index 000000000..fa919e9c9 --- /dev/null +++ b/docker/e2e/README.md @@ -0,0 +1 @@ +Docker compose for E2E setup. diff --git a/docker/e2e/cluster-docker-compose.yml b/docker/e2e/cluster-docker-compose.yml new file mode 100644 index 000000000..db1a1cc4b --- /dev/null +++ b/docker/e2e/cluster-docker-compose.yml @@ -0,0 +1,207 @@ +# Reference configuration for a simple 3-node Tinode cluster. +# Includes: +# * Mysql database +# * 3 Tinode servers +# * 3 exporters + +version: '3.4' + +# Base Tinode template. +x-tinode: + &tinode-base + depends_on: + - mysql + image: tinode/tinode:latest + restart: always + +x-exporter: + &exporter-base + image: tinode/exporter:latest + restart: always + networks: + internal: + +x-tinode-env-vars: &tinode-env-vars + "STORE_USE_ADAPTER": "mysql" + "DEFAULT_SAMPLE_DATA": "" + "FCM_PUSH_ENABLED": "false" + # "FCM_API_KEY": "" + # "FCM_APP_ID": "" + # "FCM_PROJECT_ID": "" + # "FCM_SENDER_ID": + # "FCM_VAPID_KEY": "" + # "IOS_UNIV_LINKS_APP_ID": "" + "PPROF_URL": "/pprof" + # Run tinode server only. + "SERVICES_TO_RUN": "tinode" + # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to + # "EXT_CONFIG": "/etc/tinode/tinode.conf" + +x-exporter-env-vars: &exporter-env-vars + "TINODE_ADDR": "http://tinode.host:18080/stats/expvar/" + # Prometheus configuration: + "SERVE_FOR": "prometheus" + "PROM_NAMESPACE": "tinode" + "PROM_METRICS_PATH": "/metrics" + # InfluxDB configation: + # "SERVE_FOR": "influxdb" + # "INFLUXDB_VERSION": 1.7 + # "INFLUXDB_ORGANIZATION": "" + # "INFLUXDB_PUSH_INTERVAL": 30 + # "INFLUXDB_PUSH_ADDRESS": "https://mon.tinode.co/intake" + # "INFLUXDB_AUTH_TOKEN": "abcdef" + +services: + mysql: + image: mysql:5.7 + container_name: mysql + restart: always + networks: + internal: + ipv4_address: 172.19.0.3 + # Use your own volume. + # volumes: + # - :/var/lib/mysql + environment: + - MYSQL_ALLOW_EMPTY_PASSWORD=yes + healthcheck: + test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] + timeout: 5s + retries: 10 + + # Run this only when you need to initialize Tinode database. + init-db: + << : *tinode-base + container_name: init-db + hostname: init-db + restart: "no" + networks: + internal: + ipv4_address: 172.19.0.2 + environment: + << : *tinode-env-vars + "SERVICES_TO_RUN": "init-db" + "UPGRADE_DB": "false" + + # Run this only when you need to upgrade Tinode database to a newer version. + upgrade-db: + << : *tinode-base + container_name: upgrade-db + hostname: upgrade-db + restart: "no" + networks: + internal: + ipv4_address: 172.19.0.2 + environment: + << : *tinode-env-vars + "SERVICES_TO_RUN": "init-db" + "UPGRADE_DB": "true" + + # Tinode servers. + tinode-0: + << : *tinode-base + container_name: tinode-0 + hostname: tinode-0 + networks: + internal: + ipv4_address: 172.19.0.5 + # You can mount your volumes as necessary: + # volumes: + # # E.g. external config. + # - :/etc/tinode/tinode.conf + # # Logs directory. + # - :/var/log + ports: + - "6060:18080" + environment: + << : *tinode-env-vars + "CLUSTER_SELF": "tinode-0" + + tinode-1: + << : *tinode-base + container_name: tinode-1 + hostname: tinode-1 + networks: + internal: + ipv4_address: 172.19.0.6 + # You can mount your volumes as necessary: + # volumes: + # # E.g. external config. + # - :/etc/tinode/tinode.conf + # # Logs directory. + # - :/var/log + ports: + - "6061:18080" + environment: + << : *tinode-env-vars + "CLUSTER_SELF": "tinode-1" + + tinode-2: + << : *tinode-base + container_name: tinode-2 + hostname: tinode-2 + networks: + internal: + ipv4_address: 172.19.0.7 + # You can mount your volumes as necessary: + # volumes: + # # E.g. external config. + # - :/etc/tinode/tinode.conf + # # Logs directory. + # - :/var/log + ports: + - "6062:18080" + environment: + << : *tinode-env-vars + "CLUSTER_SELF": "tinode-2" + + # Monitoring. + # Exporters are paired with tinode instances. + exporter-0: + << : *exporter-base + container_name: exporter-0 + hostname: exporter-0 + depends_on: + - tinode-0 + ports: + - "6222:6222" + environment: + << : *exporter-env-vars + "INSTANCE": "tinode-exp-0" + extra_hosts: + - "tinode.host:172.19.0.5" + + exporter-1: + << : *exporter-base + container_name: exporter-1 + hostname: exporter-1 + depends_on: + - tinode-1 + ports: + - "6223:6222" + environment: + << : *exporter-env-vars + "INSTANCE": "tinode-exp-1" + extra_hosts: + - "tinode.host:172.19.0.6" + + exporter-2: + << : *exporter-base + container_name: exporter-2 + hostname: exporter-2 + depends_on: + - tinode-2 + ports: + - "6224:6222" + environment: + << : *exporter-env-vars + "INSTANCE": "tinode-exp-2" + extra_hosts: + - "tinode.host:172.19.0.7" + +networks: + internal: + ipam: + driver: default + config: + - subnet: "172.19.0.0/24" diff --git a/docker/e2e/standalone-docker-compose.yml b/docker/e2e/standalone-docker-compose.yml new file mode 100644 index 000000000..c4b2e264a --- /dev/null +++ b/docker/e2e/standalone-docker-compose.yml @@ -0,0 +1,136 @@ +# Reference configuration for a simple Tinode server. +# Includes: +# * Mysql database +# * Tinode server +# * Tinode exporters + +version: '3.4' + +# Base Tinode template. +x-tinode: + &tinode-base + depends_on: + - mysql + image: tinode/tinode:latest + restart: always + +x-tinode-env-vars: &tinode-env-vars + "STORE_USE_ADAPTER": "mysql" + "DEFAULT_SAMPLE_DATA": "" + "FCM_PUSH_ENABLED": "false" + # "FCM_API_KEY": "" + # "FCM_APP_ID": "" + # "FCM_PROJECT_ID": "" + # "FCM_SENDER_ID": + # "FCM_VAPID_KEY": "" + # "IOS_UNIV_LINKS_APP_ID": "" + "PPROF_URL": "/pprof" + # Run tinode server only. + "SERVICES_TO_RUN": "tinode" + # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to + # "EXT_CONFIG": "/etc/tinode/tinode.conf" + +x-exporter-env-vars: &exporter-env-vars + "TINODE_ADDR": "http://tinode.host:18080/stats/expvar/" + # Prometheus configuration: + "SERVE_FOR": "prometheus" + "PROM_NAMESPACE": "tinode" + "PROM_METRICS_PATH": "/metrics" + # InfluxDB configation: + # "SERVE_FOR": "influxdb" + # "INFLUXDB_VERSION": 1.7 + # "INFLUXDB_ORGANIZATION": "" + # "INFLUXDB_PUSH_INTERVAL": 30 + # "INFLUXDB_PUSH_ADDRESS": "https://mon.tinode.co/intake" + # "INFLUXDB_AUTH_TOKEN": "abcdef" + +services: + mysql: + image: mysql:5.7 + container_name: mysql + restart: always + networks: + internal: + ipv4_address: 172.19.0.3 + # Use your own volume. + # volumes: + # - :/var/lib/mysql + environment: + - MYSQL_ALLOW_EMPTY_PASSWORD=yes + healthcheck: + test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] + timeout: 5s + retries: 10 + + # Run this only when you need to initialize Tinode database. + init-db: + << : *tinode-base + container_name: init-db + hostname: init-db + restart: "no" + networks: + internal: + ipv4_address: 172.19.0.2 + environment: + << : *tinode-env-vars + "SERVICES_TO_RUN": "init-db" + "UPGRADE_DB": "false" + + # Run this only when you need to upgrade Tinode database to a newer version. + upgrade-db: + << : *tinode-base + container_name: upgrade-db + hostname: upgrade-db + restart: "no" + networks: + internal: + ipv4_address: 172.19.0.2 + environment: + << : *tinode-env-vars + "SERVICES_TO_RUN": "init-db" + "UPGRADE_DB": "true" + + tinode-0: + << : *tinode-base + container_name: tinode-0 + hostname: tinode-0 + networks: + internal: + ipv4_address: 172.19.0.5 + # You can mount your volumes as necessary: + # volumes: + # # E.g. external config. + # - :/etc/tinode/tinode.conf + # # Logs directory. + # - :/var/log + ports: + - "6060:18080" + environment: + << : *tinode-env-vars + + # Monitoring. + # Exporters are paired with tinode instances. + exporter-0: + container_name: exporter-0 + hostname: exporter-0 + depends_on: + - tinode-0 + image: tinode/exporter:latest + restart: always + networks: + internal: + ports: + - "6222:6222" + environment: + << : *exporter-env-vars + # Remove? + "INSTANCE": "tinode-exp-0" + extra_hosts: + - "tinode.host:172.19.0.5" + +networks: + internal: + ipam: + driver: default + config: + - subnet: "172.19.0.0/24" From aa388e55c6dd379ce786b63b3f71ceaa3c371cbe Mon Sep 17 00:00:00 2001 From: aforge Date: Thu, 19 Mar 2020 23:46:48 -0700 Subject: [PATCH 031/142] Load sample data and cluster config in tinode.conf template. --- docker/tinode/config.template | 16 ++++++++++++++++ docker/tinode/entrypoint.sh | 7 ++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docker/tinode/config.template b/docker/tinode/config.template index 9c13c681a..4e47f581c 100644 --- a/docker/tinode/config.template +++ b/docker/tinode/config.template @@ -133,6 +133,22 @@ } ], + "cluster_config": { + "self": "", + "nodes": [ + // Name and TCP address of each node. + {"name": "tinode-0", "addr": "tinode-0:12001"}, + {"name": "tinode-1", "addr": "tinode-1:12002"}, + {"name": "tinode-2", "addr": "tinode-2:12003"} + ], + "failover": { + "enabled": true, + "heartbeat": 100, + "vote_after": 8, + "node_fail_after": 16 + } + }, + "plugins": [ { "enabled": $PLUGIN_PYTHON_CHAT_BOT_ENABLED, diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index e784cc2b7..1c4cf2741 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -113,8 +113,13 @@ echo "Will run init-db: ${run_init_db}, tinode: ${run_tinode}" touch /botdata/tino-password if [ "$run_init_db" == "true" ]; then + init_args=("--reset=${RESET_DB}" "--upgrade=${UPGRADE_DB}" "--config=${CONFIG}") + # Maybe load sample data? + if [ ! -z "$SAMPLE_DATA" ] ; then + init_args+=("--data=$SAMPLE_DATA") + fi # Initialize the database if it has not been initialized yet or if data reset/upgrade has been requested. - ./init-db --reset=${RESET_DB} --upgrade=${UPGRADE_DB} --config=${CONFIG} | grep "usr;tino;" > /botdata/tino-password + ./init-db "${init_args[@]}" | grep "usr;tino;" > /botdata/tino-password fi if [ "$run_tinode" != "true" ]; then From a6ca8106c2f42e107085409fea9494968d545137 Mon Sep 17 00:00:00 2001 From: aforge Date: Fri, 20 Mar 2020 00:47:01 -0700 Subject: [PATCH 032/142] Load sample data in standalone. --- docker/e2e/standalone-docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker/e2e/standalone-docker-compose.yml b/docker/e2e/standalone-docker-compose.yml index c4b2e264a..ccd15121a 100644 --- a/docker/e2e/standalone-docker-compose.yml +++ b/docker/e2e/standalone-docker-compose.yml @@ -74,6 +74,8 @@ services: environment: << : *tinode-env-vars "SERVICES_TO_RUN": "init-db" + # Comment out this line if you don't want sample data loaded. + "SAMPLE_DATA": "data.json" "UPGRADE_DB": "false" # Run this only when you need to upgrade Tinode database to a newer version. From 75ecc3b2b9f213ab6d051698c245170e9da8f944 Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 20 Mar 2020 20:32:10 +0300 Subject: [PATCH 033/142] another attempt at fixing memory leak --- server/cluster.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/cluster.go b/server/cluster.go index 056d92899..e9ed017a8 100644 --- a/server/cluster.go +++ b/server/cluster.go @@ -22,6 +22,8 @@ const ( defaultClusterReconnect = 200 * time.Millisecond // Number of replicas in ringhash clusterHashReplicas = 20 + // Period for running health check on cluster session: terminate sessions with no subscriptions. + clusterSessionCleanup = 5 * time.Second ) type clusterNodeConfig struct { @@ -744,6 +746,8 @@ func (sess *Session) rpcWriteLoop() { sess.unsubAll() }() + heartBeat := time.NewTimer(clusterSessionCleanup) + for { select { case msg, ok := <-sess.send: @@ -768,6 +772,7 @@ func (sess *Session) rpcWriteLoop() { case topic := <-sess.detach: sess.delSub(topic) + case <-heartBeat.C: // All proxied subsriptions are gone, this session is no longer needed. if sess.countSub() == 0 { return From c28f28feb636351545e672cad2ae6e096e3268ec Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 21 Mar 2020 13:01:24 +0300 Subject: [PATCH 034/142] document some incorrect code --- server/cluster.go | 9 +++++++++ server/session.go | 3 +++ 2 files changed, 12 insertions(+) diff --git a/server/cluster.go b/server/cluster.go index e9ed017a8..b167f6285 100644 --- a/server/cluster.go +++ b/server/cluster.go @@ -746,6 +746,7 @@ func (sess *Session) rpcWriteLoop() { sess.unsubAll() }() + // Timer which checks for orphaned nodes. heartBeat := time.NewTimer(clusterSessionCleanup) for { @@ -883,6 +884,14 @@ func (c *Cluster) invalidateRemoteSubs() { } } s.remoteSubsLock.Unlock() + // FIXME: + // This is problematic for two reasons: + // 1. We don't really know if subscription contained in s.remoteSubs actually exists. + // We only know that {sub} packet was sent to the remote node and it was delivered. + // 2. The {pres what=term} is sent on 'me' topic but we don't know if the session is + // subscribed to 'me' topic. The correct way of doing it is to send to those online + // in the topic on topic itsef, to those offline on their 'me' topic. In general + // the 'presTermDirect' should not exist. s.presTermDirect(topicsToTerminate) } } diff --git a/server/session.go b/server/session.go index b73580943..05bd27171 100644 --- a/server/session.go +++ b/server/session.go @@ -117,6 +117,7 @@ type Session struct { subsLock sync.RWMutex // Map of remote topic subscriptions, indexed by topic name. + // It does not contain actual subscriptions but rather "maybe subscriptions". remoteSubs map[string]*RemoteSubscription // Synchronizes access to remoteSubs. remoteSubsLock sync.RWMutex @@ -432,6 +433,7 @@ func (s *Session) subscribe(msg *ClientComMessage) { } else { originalTopic = msg.topic } + // FIXME: we don't really know if subscription was successful. s.addRemoteSub(expanded, &RemoteSubscription{node: remoteNodeName, originalTopic: originalTopic}) } } else { @@ -473,6 +475,7 @@ func (s *Session) leave(msg *ClientComMessage) { log.Println("s.leave:", err, s.sid) s.queueOut(ErrClusterUnreachable(msg.id, msg.topic, msg.timestamp)) } else { + // FIXME: we don't really know if leave succeeded. s.delRemoteSub(expanded) } } else if !msg.Leave.Unsub { From f92d1f5fa45f0e08ba8f6e0d68fc368ff0fa3708 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 21 Mar 2020 19:10:09 +0300 Subject: [PATCH 035/142] using go mod for package management --- go.mod | 28 ++++++ go.sum | 302 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 go.mod create mode 100644 go.sum diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..9c8b84490 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module github.com/tinode/chat + +go 1.14 + +require ( + firebase.google.com/go v3.12.0+incompatible + github.com/DisposaBoy/JsonConfigReader v0.0.0-20171218180944-5ea4d0ddac55 + github.com/aws/aws-sdk-go v1.29.29 + github.com/bitly/go-hostpool v0.1.0 // indirect + github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect + github.com/go-sql-driver/mysql v1.5.0 + github.com/golang/protobuf v1.3.5 + github.com/google/go-cmp v0.4.0 + github.com/gorilla/handlers v1.4.2 + github.com/gorilla/websocket v1.4.2 + github.com/jmoiron/sqlx v1.2.0 + github.com/prometheus/client_golang v1.5.1 + github.com/prometheus/common v0.9.1 + github.com/tinode/snowflake v1.0.0 + go.mongodb.org/mongo-driver v1.3.1 + golang.org/x/crypto v0.0.0-20200320181102-891825fb96df + golang.org/x/net v0.0.0-20200320220750-118fecf932d8 + golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d + golang.org/x/text v0.3.2 + google.golang.org/api v0.20.0 + google.golang.org/grpc v1.28.0 + gopkg.in/rethinkdb/rethinkdb-go.v5 v5.1.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..c012b5340 --- /dev/null +++ b/go.sum @@ -0,0 +1,302 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0 h1:ROfEUZz+Gh5pa62DJWXSaonyu3StP6EA6lPEXPI6mCo= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +firebase.google.com/go v3.12.0+incompatible h1:q70KCp/J0oOL8kJ8oV2j3646kV4TB8Y5IvxXC0WT1bo= +firebase.google.com/go v3.12.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/DisposaBoy/JsonConfigReader v0.0.0-20171218180944-5ea4d0ddac55 h1:jbGlDKdzAZ92NzK65hUP98ri0/r50vVVvmZsFP/nIqo= +github.com/DisposaBoy/JsonConfigReader v0.0.0-20171218180944-5ea4d0ddac55/go.mod h1:GCzqZQHydohgVLSIqRKZeTt8IGb1Y4NaFfim3H40uUI= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/aws/aws-sdk-go v1.29.29 h1:4TdSYzXL8bHKu80tzPjO4c0ALw4Fd8qZGqf1aozUcBU= +github.com/aws/aws-sdk-go v1.29.29/go.mod h1:1KvfttTE3SPKMpo8g2c6jL3ZKfXtFvKscTgahTma5Xg= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bitly/go-hostpool v0.1.0 h1:XKmsF6k5el6xHG3WPJ8U0Ku/ye7njX7W81Ng7O2ioR0= +github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/cenkalti/backoff v2.0.0+incompatible h1:5IIPUHhlnUZbcHQsQou5k1Tn58nJkeJL9U+ig5CHJbY= +github.com/cenkalti/backoff v2.0.0+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= +github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= +github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= +github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs= +github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= +github.com/gobuffalo/flect v0.1.3/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= +github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk= +github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28= +github.com/gobuffalo/genny v0.1.0/go.mod h1:XidbUqzak3lHdS//TPu2OgiFB+51Ur5f7CSnXZ/JDvo= +github.com/gobuffalo/genny v0.1.1/go.mod h1:5TExbEyY48pfunL4QSXxlDOmdsD44RRq4mVZ0Ex28Xk= +github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw= +github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360= +github.com/gobuffalo/gogen v0.1.0/go.mod h1:8NTelM5qd8RZ15VjQTFkAW6qOMx5wBbW4dSCS3BY8gg= +github.com/gobuffalo/gogen v0.1.1/go.mod h1:y8iBtmHmGc4qa3urIyo1shvOD8JftTtfcKi+71xfDNE= +github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8= +github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= +github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= +github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= +github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= +github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg= +github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA= +github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= +github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/klauspost/compress v1.9.5 h1:U+CaK85mrNNb4k8BNOfgJtJ/gr6kswUCFj6miSzVC6M= +github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= +github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= +github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4= +github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/opentracing/opentracing-go v1.0.2 h1:3jA2P6O1F9UOrWVpwrIo17pu01KWvNWg4X946/Y5Zwg= +github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.5.1 h1:bdHYieyGlH+6OLEk2YQha8THib30KP0/yD0YH9m6xcA= +github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1 h1:KOMtN28tlbam3/7ZKEYKHhKoJZYYj3gMH4uc62x7X7U= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8 h1:+fpWZdT24pJBiqJdAwYBjPSk+5YmQzYNPYzQsdzLkt8= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tinode/snowflake v1.0.0 h1:YciQ9ZKn1TrnvpS8yZErt044XJaxWVtR9aMO9rOZVOE= +github.com/tinode/snowflake v1.0.0/go.mod h1:5JiaCe3o7QdDeyRcAeZBGVghwRS+ygt2CF/hxmAoptQ= +github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c h1:u40Z8hqBAAQyv+vATcGgV0YCnDjqSL7/q/JyPhhJSPk= +github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= +github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc h1:n+nNi93yXLkJvKwXNP9d55HC7lGK4H/SRcwB5IaUZLo= +github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= +go.mongodb.org/mongo-driver v1.3.1 h1:op56IfTQiaY2679w922KVWa3qcHdml2K/Io8ayAOUEQ= +go.mongodb.org/mongo-driver v1.3.1/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE= +go.opencensus.io v0.21.0 h1:mU6zScU4U1YAFPHEHYk+3JC4SY7JxgkqS10ZOSyksNg= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +golang.org/x/crypto v0.0.0-20180820150726-614d502a4dac/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200320181102-891825fb96df h1:lDWgvUvNnaTnNBc/dwOty86cFeKoKWbwy2wQj0gIxbU= +golang.org/x/crypto v0.0.0-20200320181102-891825fb96df/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200320220750-118fecf932d8 h1:1+zQlQqEEhUeStBTi653GZAnAuivZq/2hz+Iz+OP7rg= +golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180828065106-d99a578cf41b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82 h1:ywK/j/KkyTHcdyYSZNXGjMwgmDSfjglYZ3vStQ/gSCU= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.20.0 h1:jz2KixHX7EcCPiQrySzPdnYT7DbINAypCqKZ1Z7GM40= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55 h1:gSJIx1SDwno+2ElGhA4+qG2zF97qiUzTM+rQ0klBOcE= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0 h1:bO/TA4OxCOummhSf10siHuG7vJOiwh7SpRpFZDkOgl4= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fatih/pool.v2 v2.0.0 h1:xIFeWtxifuQJGk/IEPKsTduEKcKvPmhoiVDGpC40nKg= +gopkg.in/fatih/pool.v2 v2.0.0/go.mod h1:8xVGeu1/2jr2wm5V9SPuMht2H5AEmf5aFMGSQixtjTY= +gopkg.in/rethinkdb/rethinkdb-go.v5 v5.1.0 h1:ebhfJGmp8FpPwlgypOCcHhh556+AnUs2Tc48+GC8GDg= +gopkg.in/rethinkdb/rethinkdb-go.v5 v5.1.0/go.mod h1:x+1XKi70FH0kHCpvPQ78hGBCCxoNdE7sP+kEFdKgN6A= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5 h1:ymVxjfMaHvXD8RqPRmzHHsB3VvucivSkIAvJFDI5O3c= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= From 2601d51823425c8d7e90b2ac7ccc8eedfedbf9ef Mon Sep 17 00:00:00 2001 From: aforge Date: Sat, 21 Mar 2020 23:10:54 -0700 Subject: [PATCH 036/142] Run tinode only if init-db returns success. --- docker/README.md | 2 +- docker/tinode/Dockerfile | 5 +-- docker/tinode/entrypoint.sh | 68 ++++++++++++++++--------------------- tinode-db/main.go | 11 +++++- 4 files changed, 43 insertions(+), 43 deletions(-) diff --git a/docker/README.md b/docker/README.md index 8780923af..03aac9f31 100644 --- a/docker/README.md +++ b/docker/README.md @@ -153,7 +153,7 @@ You can specify the following environment variables when issuing `docker run` co | `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 | | `RESET_DB` | bool | `false` | Drop and recreate the database. | -| `SAMPLE_DATA` | string | _see comment_ | File with sample data to load. Default `data.json` when resetting or generating new DB, none when upgrading. Use `-` to disable | +| `SAMPLE_DATA` | string | _see comment_ | File with sample data to load. Default `data.json` when resetting or generating new DB, none when upgrading. Use `` (empty string) to disable | | `SMTP_DOMAINS` | string | | White list of email domains; when non-empty, accept registrations with emails from these domains only (email verification). | | `SMTP_HOST_URL` | string | `'http://localhost:6060/'` | URL of the host where the webapp is running (email verification). | | `SMTP_LOGIN` | string | | Optional login to use for authentication with the SMTP server (email verification). If login is missing, `addr-spec` part of `SMTP_SENDER` will be used: e.g. if `SMTP_SENDER` is `'"John Doe" '`, `jdoe@example.com` will be used as login. | diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index 6e438a1b2..9249a99f1 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -40,8 +40,8 @@ ENV RESET_DB=false ENV UPGRADE_DB=false # Load sample data to database from data.json. -ARG DEFAULT_SAMPLE_DATA=data.json -ENV SAMPLE_DATA= +ARG SAMPLE_DATA=data.json +ENV SAMPLE_DATA=$SAMPLE_DATA # The MySQL DSN connection. ENV MYSQL_DSN='root@tcp(mysql)/tinode' @@ -102,6 +102,7 @@ RUN tar -xzf tinode-$TARGET_DB.linux-amd64.tar.gz \ && rm tinode-$TARGET_DB.linux-amd64.tar.gz # Copy config template to the container. +COPY init-db . COPY config.template . COPY entrypoint.sh . diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index 1c4cf2741..cf5f716e9 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -50,8 +50,8 @@ else fi # Load default sample data when generating or resetting the database. -if [[ -z "$SAMPLE_DATA" && "$UPGRADE_DB" = "false" ]] ; then - SAMPLE_DATA="$DEFAULT_SAMPLE_DATA" +if [[ "$UPGRADE_DB" = "true" ]] ; then + SAMPLE_DATA="" fi # If push notifications are enabled, generate client-side firebase config file. @@ -89,42 +89,32 @@ if [ ! -z "$IOS_UNIV_LINKS_APP_ID" ] ; then EOM fi -run_init_db=false -run_tinode=false -case "$SERVICES_TO_RUN" in -"init-db") - run_init_db=true - ;; -"tinode") - run_tinode=true - ;; -"both") - run_init_db=true - run_tinode=true - ;; -*) - echo "Invalid val for SERVICES_TO_RUN env var. Can be either 'init-db' or 'tinode' or 'both'." - exit 1 - ;; -esac - -echo "Will run init-db: ${run_init_db}, tinode: ${run_tinode}" - -touch /botdata/tino-password - -if [ "$run_init_db" == "true" ]; then - init_args=("--reset=${RESET_DB}" "--upgrade=${UPGRADE_DB}" "--config=${CONFIG}") - # Maybe load sample data? - if [ ! -z "$SAMPLE_DATA" ] ; then - init_args+=("--data=$SAMPLE_DATA") - fi - # Initialize the database if it has not been initialized yet or if data reset/upgrade has been requested. - ./init-db "${init_args[@]}" | grep "usr;tino;" > /botdata/tino-password +init_args=("--reset=${RESET_DB}" "--upgrade=${UPGRADE_DB}" "--config=${CONFIG}") +# Maybe load sample data? +if [ ! -z "$SAMPLE_DATA" ] ; then + init_args+=("--data=$SAMPLE_DATA") +fi +init_stdout=./init-db-stdout.txt +init_stderr=./init-db-stderr.txt +# Initialize the database if it has not been initialized yet or if data reset/upgrade has been requested. +./init-db "${init_args[@]}" 1>$init_stdout 2>$init_stderr +init_result=$? +cat $init_stderr >&2 +if [ $init_result != 0 ]; then + echo "./init-db failed. Quitting." + exit 1 +fi + +# If sample data was provided, try to find Tino password. +if [ ! -z "$SAMPLE_DATA" ] ; then + grep "usr;tino;" $init_stdout > /botdata/tino-password fi -if [ "$run_tinode" != "true" ]; then - # If we don't want to run tinode, we are done. - exit 0 +# Check if the last line in the output contains the magic string. +grep -q "All done" $init_stderr +if [ $? != 0 ]; then + echo "Database could not be set up correctly. Quitting." + exit 1 fi if [ -s /botdata/tino-password ] ; then @@ -140,10 +130,10 @@ args=("--config=${CONFIG}" "--static_data=$STATIC_DIR") # Maybe set node name in the cluster. if [ ! -z "$CLUSTER_SELF" ] ; then - args+=("--cluster_self=$CLUSTER_SELF") + args+=("--cluster_self=$CLUSTER_SELF") fi if [ ! -z "$PPROF_URL" ] ; then - args+=("--pprof_url=$PPROF_URL") + args+=("--pprof_url=$PPROF_URL") fi # Create the log directory (/var/log/tinode-`current timestamp`). @@ -152,7 +142,7 @@ runid=tinode-`date +%s` logdir=/var/log/$runid mkdir -p $logdir if [ -d /var/log/tinode-latest ]; then - rm /var/log/tinode-latest + rm /var/log/tinode-latest fi pushd . cd /var/log diff --git a/tinode-db/main.go b/tinode-db/main.go index f8ac4686d..bceb4a938 100644 --- a/tinode-db/main.go +++ b/tinode-db/main.go @@ -225,11 +225,20 @@ func main() { // Upgrade DB from one version to another. err = store.UpgradeDb(config.StoreConfig) if err == nil { - log.Println("Database successfully upgraded") + log.Println("Database successfully upgraded. All done.") } } else { // Reset or create DB err = store.InitDb(config.StoreConfig, true) + if err == nil { + var action string + if *reset { + action = "reset" + } else { + action = "initialized" + } + log.Println("Database ", action, ". All done.") + } } if err != nil { From ef87515f84ed7228b9c3de81f6f651634a5c61da Mon Sep 17 00:00:00 2001 From: aforge Date: Sat, 21 Mar 2020 23:17:43 -0700 Subject: [PATCH 037/142] Fix comment. --- docker/tinode/entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index cf5f716e9..381e32058 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -110,7 +110,7 @@ if [ ! -z "$SAMPLE_DATA" ] ; then grep "usr;tino;" $init_stdout > /botdata/tino-password fi -# Check if the last line in the output contains the magic string. +# Check if the init-db output contains the magic string. grep -q "All done" $init_stderr if [ $? != 0 ]; then echo "Database could not be set up correctly. Quitting." From c328d98e13a9adb4220edd0bdc70ab0dd5749873 Mon Sep 17 00:00:00 2001 From: aforge Date: Sun, 22 Mar 2020 00:20:32 -0700 Subject: [PATCH 038/142] Address comments. --- docker/tinode/Dockerfile | 1 - docker/tinode/entrypoint.sh | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index 9249a99f1..e40ab85b5 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -102,7 +102,6 @@ RUN tar -xzf tinode-$TARGET_DB.linux-amd64.tar.gz \ && rm tinode-$TARGET_DB.linux-amd64.tar.gz # Copy config template to the container. -COPY init-db . COPY config.template . COPY entrypoint.sh . diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index 381e32058..ada99ada3 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -49,7 +49,7 @@ else STATIC_DIR="./static" fi -# Load default sample data when generating or resetting the database. +# Do not load data when upgrading database. if [[ "$UPGRADE_DB" = "true" ]] ; then SAMPLE_DATA="" fi @@ -100,7 +100,7 @@ init_stderr=./init-db-stderr.txt ./init-db "${init_args[@]}" 1>$init_stdout 2>$init_stderr init_result=$? cat $init_stderr >&2 -if [ $init_result != 0 ]; then +if [ $init_result -ne 0 ]; then echo "./init-db failed. Quitting." exit 1 fi @@ -112,7 +112,7 @@ fi # Check if the init-db output contains the magic string. grep -q "All done" $init_stderr -if [ $? != 0 ]; then +if [ $? -ne 0 ]; then echo "Database could not be set up correctly. Quitting." exit 1 fi From b3e93f1014cd4f7d55b4f190c3fd924ef5fffc47 Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 22 Mar 2020 13:28:09 +0300 Subject: [PATCH 039/142] unnecessary import and a different base image --- chatbot/python/requirements.txt | 3 +-- docker/chatbot/Dockerfile | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/chatbot/python/requirements.txt b/chatbot/python/requirements.txt index e13eafe37..053ec320e 100644 --- a/chatbot/python/requirements.txt +++ b/chatbot/python/requirements.txt @@ -1,4 +1,3 @@ futures>=3.2.0; python_version<'3' grpcio>=1.18.0 -requests>=2.21.0 -tinode-grpc>=0.15.11 +tinode-grpc>=0.16 diff --git a/docker/chatbot/Dockerfile b/docker/chatbot/Dockerfile index 821892a46..826b10a5a 100644 --- a/docker/chatbot/Dockerfile +++ b/docker/chatbot/Dockerfile @@ -1,6 +1,6 @@ # Dockerfile builds an image with a chatbot (Tino) for Tinode. -FROM python:slim +FROM python:3.7-alpine ARG VERSION=0.16 ENV VERSION=$VERSION @@ -12,15 +12,15 @@ LABEL version=$VERSION RUN mkdir -p /usr/src/bot WORKDIR /usr/src/bot -RUN pip install --no-cache-dir -q tinode-grpc - # Get tarball with the chatbot code and data. ADD https://github.com/tinode/chat/releases/download/v$VERSION/py-chatbot.tar.gz . # Unpack chatbot, delete archive RUN tar -xzf py-chatbot.tar.gz \ && rm py-chatbot.tar.gz -# Use command line parameter `-e LOGIN_AS=user:password` to login as someone other than Tino. +RUN pip install --no-cache-dir -r requirements.txt + +# Use docker's command line parameter `-e LOGIN_AS=user:password` to login as someone other than Tino. CMD python chatbot.py --login-basic=${LOGIN_AS} --login-cookie=/botdata/.tn-cookie --host=tinode-srv:16061 > /var/log/chatbot.log From 6613853dbf4e6735a90a5bec3f91c8679ccdf9ea Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 22 Mar 2020 13:28:26 +0300 Subject: [PATCH 040/142] expanded readme --- docker/e2e/README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docker/e2e/README.md b/docker/e2e/README.md index fa919e9c9..d4b98ee66 100644 --- a/docker/e2e/README.md +++ b/docker/e2e/README.md @@ -1 +1,7 @@ -Docker compose for E2E setup. +# Docker compose for end-to-end setup. + +These reference docker-compose files will run Tinode either as a cluster or as stand-alone service. +They both use MySQL backend. +``` +docker-compose -f up -d +``` From 99916feeea73c44796bb46214e2d44137553b375 Mon Sep 17 00:00:00 2001 From: aforge Date: Sun, 22 Mar 2020 17:30:59 -0700 Subject: [PATCH 041/142] Check init-db exit code before proceeding to tinode in tinode docker. --- docker/tinode/entrypoint.sh | 14 ++------------ tinode-db/main.go | 10 +++++----- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index ada99ada3..d9d017c00 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -95,12 +95,9 @@ if [ ! -z "$SAMPLE_DATA" ] ; then init_args+=("--data=$SAMPLE_DATA") fi init_stdout=./init-db-stdout.txt -init_stderr=./init-db-stderr.txt # Initialize the database if it has not been initialized yet or if data reset/upgrade has been requested. -./init-db "${init_args[@]}" 1>$init_stdout 2>$init_stderr -init_result=$? -cat $init_stderr >&2 -if [ $init_result -ne 0 ]; then +./init-db "${init_args[@]}" 1>$init_stdout +if [ $? -ne 0 ]; then echo "./init-db failed. Quitting." exit 1 fi @@ -110,13 +107,6 @@ if [ ! -z "$SAMPLE_DATA" ] ; then grep "usr;tino;" $init_stdout > /botdata/tino-password fi -# Check if the init-db output contains the magic string. -grep -q "All done" $init_stderr -if [ $? -ne 0 ]; then - echo "Database could not be set up correctly. Quitting." - exit 1 -fi - if [ -s /botdata/tino-password ] ; then # Convert Tino's authentication credentials into a cookie file. # The cookie file is also used to check if database has been initialized. diff --git a/tinode-db/main.go b/tinode-db/main.go index bceb4a938..58cce008d 100644 --- a/tinode-db/main.go +++ b/tinode-db/main.go @@ -177,11 +177,11 @@ func main() { if *datafile != "" && *datafile != "-" { raw, err := ioutil.ReadFile(*datafile) if err != nil { - log.Fatal("Failed to parse data:", err) + log.Fatal("Failed to read sample data file:", err) } err = json.Unmarshal(raw, &data) if err != nil { - log.Fatal(err) + log.Fatal("Failed to parse sample data:", err) } } @@ -218,14 +218,14 @@ func main() { log.Println("Database reset requested") } else { log.Println("Database exists, DB version is correct. All done.") - return + os.Exit(0) } if *upgrade { // Upgrade DB from one version to another. err = store.UpgradeDb(config.StoreConfig) if err == nil { - log.Println("Database successfully upgraded. All done.") + log.Println("Database successfully upgraded.") } } else { // Reset or create DB @@ -237,7 +237,7 @@ func main() { } else { action = "initialized" } - log.Println("Database ", action, ". All done.") + log.Println("Database ", action) } } From 005c18ba5583b87830281cab124e6219ce4b1973 Mon Sep 17 00:00:00 2001 From: aforge Date: Sun, 22 Mar 2020 20:47:22 -0700 Subject: [PATCH 042/142] Better startup order in E2E setup. --- docker/e2e/README.md | 8 +++- ...cluster-docker-compose.yml => cluster.yml} | 44 ++++++------------- ...docker-compose.yml => single-instance.yml} | 37 +++------------- docker/exporter/Dockerfile | 1 + docker/exporter/entrypoint.sh | 10 +++++ docker/tinode/Dockerfile | 4 ++ docker/tinode/entrypoint.sh | 10 +++++ 7 files changed, 51 insertions(+), 63 deletions(-) rename docker/e2e/{cluster-docker-compose.yml => cluster.yml} (84%) rename docker/e2e/{standalone-docker-compose.yml => single-instance.yml} (76%) diff --git a/docker/e2e/README.md b/docker/e2e/README.md index d4b98ee66..eb63f6098 100644 --- a/docker/e2e/README.md +++ b/docker/e2e/README.md @@ -1,7 +1,13 @@ # Docker compose for end-to-end setup. -These reference docker-compose files will run Tinode either as a cluster or as stand-alone service. +These reference docker-compose files will run Tinode either as [a single-instance](single-instance.yml) or [a 3-node cluster](cluster.yml) setup. They both use MySQL backend. + ``` docker-compose -f up -d ``` + +By default, this command starts up a mysql instance, Tinode server(s) and Tinode exporter(s). +Tinode server(s) is(are) configured similar to [Tinode Demo/Sandbox](../../README.md#demosandbox) and +maps its web port to the host's port 6060 (6061, 6062). +Tinode exporter(s) serve(s) metrics for Prometheus. Port mapping is 6222 (6223, 6224). diff --git a/docker/e2e/cluster-docker-compose.yml b/docker/e2e/cluster.yml similarity index 84% rename from docker/e2e/cluster-docker-compose.yml rename to docker/e2e/cluster.yml index db1a1cc4b..998bf0dd3 100644 --- a/docker/e2e/cluster-docker-compose.yml +++ b/docker/e2e/cluster.yml @@ -36,6 +36,7 @@ x-tinode-env-vars: &tinode-env-vars "SERVICES_TO_RUN": "tinode" # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to # "EXT_CONFIG": "/etc/tinode/tinode.conf" + "WAIT_FOR": "mysql:3306" x-exporter-env-vars: &exporter-env-vars "TINODE_ADDR": "http://tinode.host:18080/stats/expvar/" @@ -43,6 +44,7 @@ x-exporter-env-vars: &exporter-env-vars "SERVE_FOR": "prometheus" "PROM_NAMESPACE": "tinode" "PROM_METRICS_PATH": "/metrics" + # # InfluxDB configation: # "SERVE_FOR": "influxdb" # "INFLUXDB_VERSION": 1.7 @@ -69,34 +71,6 @@ services: timeout: 5s retries: 10 - # Run this only when you need to initialize Tinode database. - init-db: - << : *tinode-base - container_name: init-db - hostname: init-db - restart: "no" - networks: - internal: - ipv4_address: 172.19.0.2 - environment: - << : *tinode-env-vars - "SERVICES_TO_RUN": "init-db" - "UPGRADE_DB": "false" - - # Run this only when you need to upgrade Tinode database to a newer version. - upgrade-db: - << : *tinode-base - container_name: upgrade-db - hostname: upgrade-db - restart: "no" - networks: - internal: - ipv4_address: 172.19.0.2 - environment: - << : *tinode-env-vars - "SERVICES_TO_RUN": "init-db" - "UPGRADE_DB": "true" - # Tinode servers. tinode-0: << : *tinode-base @@ -107,7 +81,7 @@ services: ipv4_address: 172.19.0.5 # You can mount your volumes as necessary: # volumes: - # # E.g. external config. + # # E.g. external config (assuming EXT_CONFIG is set). # - :/etc/tinode/tinode.conf # # Logs directory. # - :/var/log @@ -116,6 +90,9 @@ services: environment: << : *tinode-env-vars "CLUSTER_SELF": "tinode-0" + # Uncomment the lines below if you wish to upgrade or reset the database. + # "UPGRADE_DB": "true" + # "RESET_DB": "true" tinode-1: << : *tinode-base @@ -126,7 +103,7 @@ services: ipv4_address: 172.19.0.6 # You can mount your volumes as necessary: # volumes: - # # E.g. external config. + # # E.g. external config (assuming EXT_CONFIG is set). # - :/etc/tinode/tinode.conf # # Logs directory. # - :/var/log @@ -135,6 +112,7 @@ services: environment: << : *tinode-env-vars "CLUSTER_SELF": "tinode-1" + "WAIT_FOR": "tinode-0:18080" tinode-2: << : *tinode-base @@ -145,7 +123,7 @@ services: ipv4_address: 172.19.0.7 # You can mount your volumes as necessary: # volumes: - # # E.g. external config. + # # E.g. external config (assuming EXT_CONFIG is set). # - :/etc/tinode/tinode.conf # # Logs directory. # - :/var/log @@ -154,6 +132,7 @@ services: environment: << : *tinode-env-vars "CLUSTER_SELF": "tinode-2" + "WAIT_FOR": "tinode-0:18080" # Monitoring. # Exporters are paired with tinode instances. @@ -168,6 +147,7 @@ services: environment: << : *exporter-env-vars "INSTANCE": "tinode-exp-0" + "WAIT_FOR": "tinode-0:18080" extra_hosts: - "tinode.host:172.19.0.5" @@ -182,6 +162,7 @@ services: environment: << : *exporter-env-vars "INSTANCE": "tinode-exp-1" + "WAIT_FOR": "tinode-1:18080" extra_hosts: - "tinode.host:172.19.0.6" @@ -196,6 +177,7 @@ services: environment: << : *exporter-env-vars "INSTANCE": "tinode-exp-2" + "WAIT_FOR": "tinode-2:18080" extra_hosts: - "tinode.host:172.19.0.7" diff --git a/docker/e2e/standalone-docker-compose.yml b/docker/e2e/single-instance.yml similarity index 76% rename from docker/e2e/standalone-docker-compose.yml rename to docker/e2e/single-instance.yml index ccd15121a..dd05a8b37 100644 --- a/docker/e2e/standalone-docker-compose.yml +++ b/docker/e2e/single-instance.yml @@ -29,6 +29,7 @@ x-tinode-env-vars: &tinode-env-vars "SERVICES_TO_RUN": "tinode" # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to # "EXT_CONFIG": "/etc/tinode/tinode.conf" + "WAIT_FOR": "mysql:3306" x-exporter-env-vars: &exporter-env-vars "TINODE_ADDR": "http://tinode.host:18080/stats/expvar/" @@ -62,46 +63,19 @@ services: timeout: 5s retries: 10 - # Run this only when you need to initialize Tinode database. - init-db: - << : *tinode-base - container_name: init-db - hostname: init-db - restart: "no" - networks: - internal: - ipv4_address: 172.19.0.2 - environment: - << : *tinode-env-vars - "SERVICES_TO_RUN": "init-db" - # Comment out this line if you don't want sample data loaded. - "SAMPLE_DATA": "data.json" - "UPGRADE_DB": "false" - - # Run this only when you need to upgrade Tinode database to a newer version. - upgrade-db: - << : *tinode-base - container_name: upgrade-db - hostname: upgrade-db - restart: "no" - networks: - internal: - ipv4_address: 172.19.0.2 - environment: - << : *tinode-env-vars - "SERVICES_TO_RUN": "init-db" - "UPGRADE_DB": "true" - + # Tinode. tinode-0: << : *tinode-base container_name: tinode-0 hostname: tinode-0 + depends_on: + - mysql networks: internal: ipv4_address: 172.19.0.5 # You can mount your volumes as necessary: # volumes: - # # E.g. external config. + # # E.g. external config (assuming EXT_CONFIG is set). # - :/etc/tinode/tinode.conf # # Logs directory. # - :/var/log @@ -127,6 +101,7 @@ services: << : *exporter-env-vars # Remove? "INSTANCE": "tinode-exp-0" + "WAIT_FOR": "tinode-0:18080" extra_hosts: - "tinode.host:172.19.0.5" diff --git a/docker/exporter/Dockerfile b/docker/exporter/Dockerfile index 1602021a1..c22909d17 100644 --- a/docker/exporter/Dockerfile +++ b/docker/exporter/Dockerfile @@ -8,6 +8,7 @@ LABEL name="TinodeMetricExporter" LABEL version=$VERSION ENV SERVE_FOR="" +ENV WAIT_FOR="" ENV TINODE_ADDR=http://localhost/stats/expvar/ ENV INSTANCE="exporter-instance" diff --git a/docker/exporter/entrypoint.sh b/docker/exporter/entrypoint.sh index 6de9940ca..943c618c3 100755 --- a/docker/exporter/entrypoint.sh +++ b/docker/exporter/entrypoint.sh @@ -61,4 +61,14 @@ case "$SERVE_FOR" in ;; esac +# Wait for Tinode server if needed. +if [ ! -z "$WAIT_FOR" ] ; then + IFS=':' read -ra TND <<< "$WAIT_FOR" + if [ ${#TND[@]} -ne 2 ]; then + echo "\$WAIT_FOR (${WAIT_FOR}) env var should be in form HOST:PORT" + exit 1 + fi + until nc -z -v -w5 ${TND[0]} ${TND[1]}; do echo "waiting for ${WAIT_FOR}..."; sleep 5; done +fi + ./exporter "${args[@]}" diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index 6e438a1b2..7695d13ac 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -33,6 +33,10 @@ ENV TARGET_DB=$TARGET_DB # Specifies what jobs to run: init-db, tinode or both. ENV SERVICES_TO_RUN='both' +# Specifies the database host:port pair to wait for before running Tinode. +# Ignored if empty. +ENV WAIT_FOR= + # An option to reset database. ENV RESET_DB=false diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index 1c4cf2741..beb495ea7 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -89,6 +89,16 @@ if [ ! -z "$IOS_UNIV_LINKS_APP_ID" ] ; then EOM fi +# Wait for database if needed. +if [ ! -z "$WAIT_FOR" ] ; then + IFS=':' read -ra DB <<< "$WAIT_FOR" + if [ ${#DB[@]} -ne 2 ]; then + echo "\$WAIT_FOR (${WAIT_FOR}) env var should be in form HOST:PORT" + exit 1 + fi + until nc -z -v -w5 ${DB[0]} ${DB[1]}; do echo "waiting for ${WAIT_FOR}..."; sleep 5; done +fi + run_init_db=false run_tinode=false case "$SERVICES_TO_RUN" in From 77fc309d37b3956d5ae62854ac1379d5ec9f651f Mon Sep 17 00:00:00 2001 From: aforge Date: Sun, 22 Mar 2020 20:52:01 -0700 Subject: [PATCH 043/142] Add RESET_DB and UPGRADE_DB envvar comments to single-instance.yml. --- docker/e2e/single-instance.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker/e2e/single-instance.yml b/docker/e2e/single-instance.yml index dd05a8b37..be625aa7d 100644 --- a/docker/e2e/single-instance.yml +++ b/docker/e2e/single-instance.yml @@ -83,6 +83,9 @@ services: - "6060:18080" environment: << : *tinode-env-vars + # Uncomment the lines below if you wish to upgrade or reset the database. + # "UPGRADE_DB": "true" + # "RESET_DB": "true" # Monitoring. # Exporters are paired with tinode instances. From 3f7d6ab729c4e777eaa6be4d4dbe1a7f93868144 Mon Sep 17 00:00:00 2001 From: aforge Date: Sun, 22 Mar 2020 23:31:39 -0700 Subject: [PATCH 044/142] Use Tinode FCM default values in e2e docker-compose configs. --- docker/e2e/cluster.yml | 20 +++++++++++--------- docker/e2e/single-instance.yml | 20 +++++++++++--------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/docker/e2e/cluster.yml b/docker/e2e/cluster.yml index 998bf0dd3..46982deda 100644 --- a/docker/e2e/cluster.yml +++ b/docker/e2e/cluster.yml @@ -24,19 +24,21 @@ x-exporter: x-tinode-env-vars: &tinode-env-vars "STORE_USE_ADAPTER": "mysql" "DEFAULT_SAMPLE_DATA": "" - "FCM_PUSH_ENABLED": "false" - # "FCM_API_KEY": "" - # "FCM_APP_ID": "" - # "FCM_PROJECT_ID": "" - # "FCM_SENDER_ID": - # "FCM_VAPID_KEY": "" - # "IOS_UNIV_LINKS_APP_ID": "" "PPROF_URL": "/pprof" - # Run tinode server only. - "SERVICES_TO_RUN": "tinode" # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to # "EXT_CONFIG": "/etc/tinode/tinode.conf" "WAIT_FOR": "mysql:3306" + # Web client push notifications. + # Modify as appropriate. + # To enable Tinode server push notifications, please refer to the "push" section of your tinode.conf. + "FCM_PUSH_ENABLED": "true" + "FCM_API_KEY": "AIzaSyD6X4ULR-RUsobvs1zZ2bHdJuPz39q2tbQ" + "FCM_APP_ID": "1:114126160546:web:aca6ea2981feb81fb44dfb" + "FCM_PROJECT_ID": "tinode-1000" + "FCM_SENDER_ID": 114126160546 + "FCM_VAPID_KEY": "BOgQVPOMzIMXUpsYGpbVkZoEBc0ifKY_f2kSU5DNDGYI6i6CoKqqxDd7w7PJ3FaGRBgVGJffldETumOx831jl58" + # iOS app universal links configuration. + # "IOS_UNIV_LINKS_APP_ID": "" x-exporter-env-vars: &exporter-env-vars "TINODE_ADDR": "http://tinode.host:18080/stats/expvar/" diff --git a/docker/e2e/single-instance.yml b/docker/e2e/single-instance.yml index be625aa7d..ec5c7d9aa 100644 --- a/docker/e2e/single-instance.yml +++ b/docker/e2e/single-instance.yml @@ -17,19 +17,21 @@ x-tinode: x-tinode-env-vars: &tinode-env-vars "STORE_USE_ADAPTER": "mysql" "DEFAULT_SAMPLE_DATA": "" - "FCM_PUSH_ENABLED": "false" - # "FCM_API_KEY": "" - # "FCM_APP_ID": "" - # "FCM_PROJECT_ID": "" - # "FCM_SENDER_ID": - # "FCM_VAPID_KEY": "" - # "IOS_UNIV_LINKS_APP_ID": "" "PPROF_URL": "/pprof" - # Run tinode server only. - "SERVICES_TO_RUN": "tinode" # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to # "EXT_CONFIG": "/etc/tinode/tinode.conf" "WAIT_FOR": "mysql:3306" + # Web client push notifications. + # Modify as appropriate. + # To enable Tinode server push notifications, please refer to the "push" section of your tinode.conf. + "FCM_PUSH_ENABLED": "true" + "FCM_API_KEY": "AIzaSyD6X4ULR-RUsobvs1zZ2bHdJuPz39q2tbQ" + "FCM_APP_ID": "1:114126160546:web:aca6ea2981feb81fb44dfb" + "FCM_PROJECT_ID": "tinode-1000" + "FCM_SENDER_ID": 114126160546 + "FCM_VAPID_KEY": "BOgQVPOMzIMXUpsYGpbVkZoEBc0ifKY_f2kSU5DNDGYI6i6CoKqqxDd7w7PJ3FaGRBgVGJffldETumOx831jl58" + # iOS app universal links configuration. + # "IOS_UNIV_LINKS_APP_ID": "" x-exporter-env-vars: &exporter-env-vars "TINODE_ADDR": "http://tinode.host:18080/stats/expvar/" From a3b4f671b00af768b45a63c08e5532f9c126eea4 Mon Sep 17 00:00:00 2001 From: aforge Date: Mon, 23 Mar 2020 20:03:57 -0700 Subject: [PATCH 045/142] Simplify code a bit. --- docker/tinode/entrypoint.sh | 33 ++++----------------------------- 1 file changed, 4 insertions(+), 29 deletions(-) diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index d9d017c00..73026d629 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -51,7 +51,7 @@ fi # Do not load data when upgrading database. if [[ "$UPGRADE_DB" = "true" ]] ; then - SAMPLE_DATA="" + SAMPLE_DATA= fi # If push notifications are enabled, generate client-side firebase config file. @@ -89,11 +89,7 @@ if [ ! -z "$IOS_UNIV_LINKS_APP_ID" ] ; then EOM fi -init_args=("--reset=${RESET_DB}" "--upgrade=${UPGRADE_DB}" "--config=${CONFIG}") -# Maybe load sample data? -if [ ! -z "$SAMPLE_DATA" ] ; then - init_args+=("--data=$SAMPLE_DATA") -fi +init_args=("--reset=${RESET_DB}" "--upgrade=${UPGRADE_DB}" "--config=${CONFIG}" "--data=$SAMPLE_DATA") init_stdout=./init-db-stdout.txt # Initialize the database if it has not been initialized yet or if data reset/upgrade has been requested. ./init-db "${init_args[@]}" 1>$init_stdout @@ -116,28 +112,7 @@ if [ -s /botdata/tino-password ] ; then ./credentials.sh /botdata/.tn-cookie < /botdata/tino-password fi -args=("--config=${CONFIG}" "--static_data=$STATIC_DIR") - -# Maybe set node name in the cluster. -if [ ! -z "$CLUSTER_SELF" ] ; then - args+=("--cluster_self=$CLUSTER_SELF") -fi -if [ ! -z "$PPROF_URL" ] ; then - args+=("--pprof_url=$PPROF_URL") -fi - -# Create the log directory (/var/log/tinode-`current timestamp`). -# And symlink /var/log/tinode-latest to it. -runid=tinode-`date +%s` -logdir=/var/log/$runid -mkdir -p $logdir -if [ -d /var/log/tinode-latest ]; then - rm /var/log/tinode-latest -fi -pushd . -cd /var/log -ln -s $runid tinode-latest -popd +args=("--config=${CONFIG}" "--static_data=$STATIC_DIR" "--cluster_self=$CLUSTER_SELF" "--pprof_url=$PPROF_URL") # Run the tinode server. -./tinode "${args[@]}" 2> $logdir/tinode.log +./tinode "${args[@]}" 2> /var/log/tinode.log From 29b7c5debbbb645811612d3a5400a08ccad20ed4 Mon Sep 17 00:00:00 2001 From: aforge Date: Mon, 23 Mar 2020 20:22:03 -0700 Subject: [PATCH 046/142] Append to log file in tinode container. --- docker/tinode/entrypoint.sh | 2 +- tinode-db/main.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index 73026d629..15c9ca65c 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -115,4 +115,4 @@ fi args=("--config=${CONFIG}" "--static_data=$STATIC_DIR" "--cluster_self=$CLUSTER_SELF" "--pprof_url=$PPROF_URL") # Run the tinode server. -./tinode "${args[@]}" 2> /var/log/tinode.log +./tinode "${args[@]}" 2>> /var/log/tinode.log diff --git a/tinode-db/main.go b/tinode-db/main.go index 58cce008d..71593b902 100644 --- a/tinode-db/main.go +++ b/tinode-db/main.go @@ -246,4 +246,5 @@ func main() { } genDb(&data) + os.Exit(0) } From edb55c4822d1908bda85b2e35c7f74131e33d906 Mon Sep 17 00:00:00 2001 From: aforge Date: Mon, 23 Mar 2020 21:14:05 -0700 Subject: [PATCH 047/142] More FCM configuration. --- docker/e2e/cluster.yml | 6 +++++- docker/e2e/single-instance.yml | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docker/e2e/cluster.yml b/docker/e2e/cluster.yml index 46982deda..9219481ed 100644 --- a/docker/e2e/cluster.yml +++ b/docker/e2e/cluster.yml @@ -31,7 +31,11 @@ x-tinode-env-vars: &tinode-env-vars # Web client push notifications. # Modify as appropriate. # To enable Tinode server push notifications, please refer to the "push" section of your tinode.conf. - "FCM_PUSH_ENABLED": "true" + "FCM_PUSH_ENABLED": "false" + # "FCM_CRED_FILE": "" + # "FCM_INCLUDE_ANDROID_NOTIFICATION": false + # + # FCM Web client configuration. "FCM_API_KEY": "AIzaSyD6X4ULR-RUsobvs1zZ2bHdJuPz39q2tbQ" "FCM_APP_ID": "1:114126160546:web:aca6ea2981feb81fb44dfb" "FCM_PROJECT_ID": "tinode-1000" diff --git a/docker/e2e/single-instance.yml b/docker/e2e/single-instance.yml index ec5c7d9aa..4253c0d58 100644 --- a/docker/e2e/single-instance.yml +++ b/docker/e2e/single-instance.yml @@ -24,7 +24,12 @@ x-tinode-env-vars: &tinode-env-vars # Web client push notifications. # Modify as appropriate. # To enable Tinode server push notifications, please refer to the "push" section of your tinode.conf. - "FCM_PUSH_ENABLED": "true" + "FCM_PUSH_ENABLED": "false" + # FCM specific server configuration. + # "FCM_CRED_FILE": "" + # "FCM_INCLUDE_ANDROID_NOTIFICATION": false + # + # FCM Web client configuration. "FCM_API_KEY": "AIzaSyD6X4ULR-RUsobvs1zZ2bHdJuPz39q2tbQ" "FCM_APP_ID": "1:114126160546:web:aca6ea2981feb81fb44dfb" "FCM_PROJECT_ID": "tinode-1000" From d5f194a5ac3d5a8bb5bb8181687cc4579d138303 Mon Sep 17 00:00:00 2001 From: aforge Date: Mon, 23 Mar 2020 22:35:38 -0700 Subject: [PATCH 048/142] Add Tinode Push Gateway configuration to Tinode Docker setups. --- docker/e2e/cluster.yml | 8 ++++++-- docker/e2e/single-instance.yml | 9 ++++++--- docker/tinode/Dockerfile | 12 ++++++++++++ docker/tinode/config.template | 9 +++++++++ 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/docker/e2e/cluster.yml b/docker/e2e/cluster.yml index 9219481ed..22368481d 100644 --- a/docker/e2e/cluster.yml +++ b/docker/e2e/cluster.yml @@ -28,9 +28,13 @@ x-tinode-env-vars: &tinode-env-vars # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to # "EXT_CONFIG": "/etc/tinode/tinode.conf" "WAIT_FOR": "mysql:3306" - # Web client push notifications. + # Push notifications. # Modify as appropriate. - # To enable Tinode server push notifications, please refer to the "push" section of your tinode.conf. + # Tinode Push Gateway configuration. + "TNPG_PUSH_ENABLED": "false" + # "TNPG_USER": "" + # "TNPG_AUTH_TOKEN": "" + # FCM specific server configuration. "FCM_PUSH_ENABLED": "false" # "FCM_CRED_FILE": "" # "FCM_INCLUDE_ANDROID_NOTIFICATION": false diff --git a/docker/e2e/single-instance.yml b/docker/e2e/single-instance.yml index 4253c0d58..1d2ab6552 100644 --- a/docker/e2e/single-instance.yml +++ b/docker/e2e/single-instance.yml @@ -21,11 +21,14 @@ x-tinode-env-vars: &tinode-env-vars # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to # "EXT_CONFIG": "/etc/tinode/tinode.conf" "WAIT_FOR": "mysql:3306" - # Web client push notifications. + # Push notifications. # Modify as appropriate. - # To enable Tinode server push notifications, please refer to the "push" section of your tinode.conf. - "FCM_PUSH_ENABLED": "false" + # Tinode Push Gateway configuration. + "TNPG_PUSH_ENABLED": "false" + # "TNPG_USER": "" + # "TNPG_AUTH_TOKEN": "" # FCM specific server configuration. + "FCM_PUSH_ENABLED": "false" # "FCM_CRED_FILE": "" # "FCM_INCLUDE_ANDROID_NOTIFICATION": false # diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index bdb451413..a5752bc7a 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -86,6 +86,18 @@ ENV FCM_PUSH_ENABLED=false # Enable Android-specific notifications by default. ENV FCM_INCLUDE_ANDROID_NOTIFICATION=true +# Disable push notifications via Tinode Push Gateway. +ENV TNPG_PUSH_ENABLED=false + +# Tinode Push Gateway authentication token. +ENV TNPG_AUTH_TOKEN= + +# Tinode Push Gateway user name. +ENV TNPG_USER= + +# Enable Tinode Push Gateway request payload compression. +ENV TNPG_COMPRESS_PAYLOADS=true + # 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. diff --git a/docker/tinode/config.template b/docker/tinode/config.template index 4e47f581c..0134253fd 100644 --- a/docker/tinode/config.template +++ b/docker/tinode/config.template @@ -105,6 +105,15 @@ }, "push": [ + { + "name":"tnpg", + "config": { + "enabled": $TNPG_PUSH_ENABLED, + "auth_token": "$TNPG_AUTH_TOKEN", + "user": "$TNPG_USER", + "compress_payloads": $TNPG_COMPRESS_PAYLOADS + } + }, { "name":"fcm", "config": { From 69a296099fe8705a73bda643bd9e161529169b9d Mon Sep 17 00:00:00 2001 From: aforge Date: Mon, 23 Mar 2020 23:57:55 -0700 Subject: [PATCH 049/142] Push metrics to InfluxDB in e2e all exporters by default. --- docker/e2e/cluster.yml | 21 ++++++++++----------- docker/e2e/single-instance.yml | 20 ++++++++++---------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/docker/e2e/cluster.yml b/docker/e2e/cluster.yml index 22368481d..3a820c86f 100644 --- a/docker/e2e/cluster.yml +++ b/docker/e2e/cluster.yml @@ -50,18 +50,17 @@ x-tinode-env-vars: &tinode-env-vars x-exporter-env-vars: &exporter-env-vars "TINODE_ADDR": "http://tinode.host:18080/stats/expvar/" - # Prometheus configuration: - "SERVE_FOR": "prometheus" - "PROM_NAMESPACE": "tinode" - "PROM_METRICS_PATH": "/metrics" - # # InfluxDB configation: - # "SERVE_FOR": "influxdb" - # "INFLUXDB_VERSION": 1.7 - # "INFLUXDB_ORGANIZATION": "" - # "INFLUXDB_PUSH_INTERVAL": 30 - # "INFLUXDB_PUSH_ADDRESS": "https://mon.tinode.co/intake" - # "INFLUXDB_AUTH_TOKEN": "abcdef" + "SERVE_FOR": "influxdb" + "INFLUXDB_VERSION": 1.7 + "INFLUXDB_ORGANIZATION": "" + "INFLUXDB_PUSH_INTERVAL": 30 + "INFLUXDB_PUSH_ADDRESS": "https://mon.tinode.co/intake" + "INFLUXDB_AUTH_TOKEN": "" + # Prometheus configuration: + # "SERVE_FOR": "prometheus" + # "PROM_NAMESPACE": "tinode" + # "PROM_METRICS_PATH": "/metrics" services: mysql: diff --git a/docker/e2e/single-instance.yml b/docker/e2e/single-instance.yml index 1d2ab6552..08da0e525 100644 --- a/docker/e2e/single-instance.yml +++ b/docker/e2e/single-instance.yml @@ -43,17 +43,17 @@ x-tinode-env-vars: &tinode-env-vars x-exporter-env-vars: &exporter-env-vars "TINODE_ADDR": "http://tinode.host:18080/stats/expvar/" - # Prometheus configuration: - "SERVE_FOR": "prometheus" - "PROM_NAMESPACE": "tinode" - "PROM_METRICS_PATH": "/metrics" # InfluxDB configation: - # "SERVE_FOR": "influxdb" - # "INFLUXDB_VERSION": 1.7 - # "INFLUXDB_ORGANIZATION": "" - # "INFLUXDB_PUSH_INTERVAL": 30 - # "INFLUXDB_PUSH_ADDRESS": "https://mon.tinode.co/intake" - # "INFLUXDB_AUTH_TOKEN": "abcdef" + "SERVE_FOR": "influxdb" + "INFLUXDB_VERSION": 1.7 + "INFLUXDB_ORGANIZATION": "" + "INFLUXDB_PUSH_INTERVAL": 30 + "INFLUXDB_PUSH_ADDRESS": "https://mon.tinode.co/intake" + "INFLUXDB_AUTH_TOKEN": "" + # Prometheus configuration: + # "SERVE_FOR": "prometheus" + # "PROM_NAMESPACE": "tinode" + # "PROM_METRICS_PATH": "/metrics" services: mysql: From b0dcff1ff8914e067df807b7b37c1c29c933daa9 Mon Sep 17 00:00:00 2001 From: aforge Date: Tue, 24 Mar 2020 01:01:43 -0700 Subject: [PATCH 050/142] Rename mysql -> db in e2e configs. --- docker/e2e/cluster.yml | 4 ++-- docker/e2e/single-instance.yml | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docker/e2e/cluster.yml b/docker/e2e/cluster.yml index 3a820c86f..81d0fe292 100644 --- a/docker/e2e/cluster.yml +++ b/docker/e2e/cluster.yml @@ -10,7 +10,7 @@ version: '3.4' x-tinode: &tinode-base depends_on: - - mysql + - db image: tinode/tinode:latest restart: always @@ -63,7 +63,7 @@ x-exporter-env-vars: &exporter-env-vars # "PROM_METRICS_PATH": "/metrics" services: - mysql: + db: image: mysql:5.7 container_name: mysql restart: always diff --git a/docker/e2e/single-instance.yml b/docker/e2e/single-instance.yml index 08da0e525..c2393cff7 100644 --- a/docker/e2e/single-instance.yml +++ b/docker/e2e/single-instance.yml @@ -10,7 +10,7 @@ version: '3.4' x-tinode: &tinode-base depends_on: - - mysql + - db image: tinode/tinode:latest restart: always @@ -56,7 +56,7 @@ x-exporter-env-vars: &exporter-env-vars # "PROM_METRICS_PATH": "/metrics" services: - mysql: + db: image: mysql:5.7 container_name: mysql restart: always @@ -78,8 +78,6 @@ services: << : *tinode-base container_name: tinode-0 hostname: tinode-0 - depends_on: - - mysql networks: internal: ipv4_address: 172.19.0.5 From 703cfa548eed442e0f50d977250e00fcc79b2895 Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 24 Mar 2020 21:24:30 +0300 Subject: [PATCH 051/142] missing dot in macro script --- tn-cli/sample-macro-script.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tn-cli/sample-macro-script.txt b/tn-cli/sample-macro-script.txt index 0d7e7663d..c25595280 100644 --- a/tn-cli/sample-macro-script.txt +++ b/tn-cli/sample-macro-script.txt @@ -28,7 +28,7 @@ .must usermod $user.params[user] --name 'Test User 1' # Change test's anon acs. -must chacs $user.params[user] --anon=JR +.must chacs $user.params[user] --anon=JR # Set test's password to test456. passwd $user.params[user] -P test456 From c5c9e0c546e989fd8b5fff8694baa861cc872184 Mon Sep 17 00:00:00 2001 From: aforge Date: Tue, 24 Mar 2020 18:09:19 -0700 Subject: [PATCH 052/142] Handle 'DEL' char in the set command in tn-cli. --- tn-cli/tn-cli.py | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/tn-cli/tn-cli.py b/tn-cli/tn-cli.py index 01e0a7e37..d61b1e669 100644 --- a/tn-cli/tn-cli.py +++ b/tn-cli/tn-cli.py @@ -89,25 +89,31 @@ def make_vcard(fn, photofile): card['fn'] = fn.strip() if photofile != None: - try: - f = open(photofile, 'rb') - # File extension is used as a file type - mimetype = mimetypes.guess_type(photofile) - if mimetype[0]: - mimetype = mimetype[0].split("/")[1] - else: - mimetype = 'jpeg' - data = base64.b64encode(f.read()) - # python3 fix. - if type(data) is not str: - data = data.decode() + if photofile == '␡': + # Delete the avatar. card['photo'] = { - 'data': data, - 'type': mimetype + 'data': photofile } - f.close() - except IOError as err: - stdoutln("Error opening '" + photofile + "':", err) + else: + try: + f = open(photofile, 'rb') + # File extension is used as a file type + mimetype = mimetypes.guess_type(photofile) + if mimetype[0]: + mimetype = mimetype[0].split("/")[1] + else: + mimetype = 'jpeg' + data = base64.b64encode(f.read()) + # python3 fix. + if type(data) is not str: + data = data.decode() + card['photo'] = { + 'data': data, + 'type': mimetype + } + f.close() + except IOError as err: + stdoutln("Error opening '" + photofile + "':", err) return card From 86161de6a1ebf505b6cd6ebb6802b397b3ea1170 Mon Sep 17 00:00:00 2001 From: aforge Date: Tue, 24 Mar 2020 21:04:56 -0700 Subject: [PATCH 053/142] Simplify e2e configs (remove network). --- docker/e2e/cluster.yml | 45 +++++++++------------------------- docker/e2e/single-instance.yml | 23 ++--------------- 2 files changed, 14 insertions(+), 54 deletions(-) diff --git a/docker/e2e/cluster.yml b/docker/e2e/cluster.yml index 81d0fe292..df782c287 100644 --- a/docker/e2e/cluster.yml +++ b/docker/e2e/cluster.yml @@ -18,8 +18,6 @@ x-exporter: &exporter-base image: tinode/exporter:latest restart: always - networks: - internal: x-tinode-env-vars: &tinode-env-vars "STORE_USE_ADAPTER": "mysql" @@ -67,9 +65,6 @@ services: image: mysql:5.7 container_name: mysql restart: always - networks: - internal: - ipv4_address: 172.19.0.3 # Use your own volume. # volumes: # - :/var/lib/mysql @@ -85,9 +80,6 @@ services: << : *tinode-base container_name: tinode-0 hostname: tinode-0 - networks: - internal: - ipv4_address: 172.19.0.5 # You can mount your volumes as necessary: # volumes: # # E.g. external config (assuming EXT_CONFIG is set). @@ -107,9 +99,6 @@ services: << : *tinode-base container_name: tinode-1 hostname: tinode-1 - networks: - internal: - ipv4_address: 172.19.0.6 # You can mount your volumes as necessary: # volumes: # # E.g. external config (assuming EXT_CONFIG is set). @@ -127,9 +116,6 @@ services: << : *tinode-base container_name: tinode-2 hostname: tinode-2 - networks: - internal: - ipv4_address: 172.19.0.7 # You can mount your volumes as necessary: # volumes: # # E.g. external config (assuming EXT_CONFIG is set). @@ -152,13 +138,13 @@ services: depends_on: - tinode-0 ports: - - "6222:6222" + - 6222:6222 + links: + - tinode-0:tinode.host environment: << : *exporter-env-vars - "INSTANCE": "tinode-exp-0" + "INSTANCE": "tinode-0" "WAIT_FOR": "tinode-0:18080" - extra_hosts: - - "tinode.host:172.19.0.5" exporter-1: << : *exporter-base @@ -167,13 +153,13 @@ services: depends_on: - tinode-1 ports: - - "6223:6222" + - 6223:6222 + links: + - tinode-1:tinode.host environment: << : *exporter-env-vars - "INSTANCE": "tinode-exp-1" + "INSTANCE": "tinode-1" "WAIT_FOR": "tinode-1:18080" - extra_hosts: - - "tinode.host:172.19.0.6" exporter-2: << : *exporter-base @@ -182,17 +168,10 @@ services: depends_on: - tinode-2 ports: - - "6224:6222" + - 6224:6222 + links: + - tinode-2:tinode.host environment: << : *exporter-env-vars - "INSTANCE": "tinode-exp-2" + "INSTANCE": "tinode-2" "WAIT_FOR": "tinode-2:18080" - extra_hosts: - - "tinode.host:172.19.0.7" - -networks: - internal: - ipam: - driver: default - config: - - subnet: "172.19.0.0/24" diff --git a/docker/e2e/single-instance.yml b/docker/e2e/single-instance.yml index c2393cff7..bb8ba76e7 100644 --- a/docker/e2e/single-instance.yml +++ b/docker/e2e/single-instance.yml @@ -60,9 +60,6 @@ services: image: mysql:5.7 container_name: mysql restart: always - networks: - internal: - ipv4_address: 172.19.0.3 # Use your own volume. # volumes: # - :/var/lib/mysql @@ -78,9 +75,6 @@ services: << : *tinode-base container_name: tinode-0 hostname: tinode-0 - networks: - internal: - ipv4_address: 172.19.0.5 # You can mount your volumes as necessary: # volumes: # # E.g. external config (assuming EXT_CONFIG is set). @@ -104,21 +98,8 @@ services: - tinode-0 image: tinode/exporter:latest restart: always - networks: - internal: - ports: - - "6222:6222" + links: + - tinode-0:tinode.host environment: << : *exporter-env-vars - # Remove? - "INSTANCE": "tinode-exp-0" "WAIT_FOR": "tinode-0:18080" - extra_hosts: - - "tinode.host:172.19.0.5" - -networks: - internal: - ipam: - driver: default - config: - - subnet: "172.19.0.0/24" From 78fc59e88f37a6359874f547a74ff655dd427b1d Mon Sep 17 00:00:00 2001 From: or-else Date: Wed, 25 Mar 2020 08:53:59 +0300 Subject: [PATCH 054/142] simplify avatar deletion --- tn-cli/tn-cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tn-cli/tn-cli.py b/tn-cli/tn-cli.py index d61b1e669..6592db142 100644 --- a/tn-cli/tn-cli.py +++ b/tn-cli/tn-cli.py @@ -89,10 +89,10 @@ def make_vcard(fn, photofile): card['fn'] = fn.strip() if photofile != None: - if photofile == '␡': + if photofile == '': # Delete the avatar. card['photo'] = { - 'data': photofile + 'data': '␡' } else: try: From 5888fcf28241cf4869d7b87b94d7e7eb5c7076e0 Mon Sep 17 00:00:00 2001 From: aforge Date: Tue, 24 Mar 2020 23:43:25 -0700 Subject: [PATCH 055/142] Fetch correct user data when subscribing to ME on behalf of another user. --- server/init_topic.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/server/init_topic.go b/server/init_topic.go index d272a1a19..a255dd0c4 100644 --- a/server/init_topic.go +++ b/server/init_topic.go @@ -113,7 +113,19 @@ func topicInit(t *Topic, sreg *sessionJoin, h *Hub) { func initTopicMe(t *Topic, sreg *sessionJoin) error { t.cat = types.TopicCatMe - user, err := store.Users.Get(sreg.sess.uid) + uid := types.ParseUserId(t.name) + if uid.IsZero() { + // Log out the session + sreg.sess.uid = types.ZeroUid + log.Println("malformed topic name (sub me): ", t.name, sreg.sess.sid) + return types.ErrMalformed + } + if uid != sreg.sess.uid && sreg.sess.authLvl != auth.LevelRoot { + sreg.sess.uid = types.ZeroUid + log.Println("initTopicMe: attempt to subscribe to another account by non-root", sreg.sess) + return types.ErrPermissionDenied + } + user, err := store.Users.Get(uid) if err != nil { // Log out the session sreg.sess.uid = types.ZeroUid From 57ed84720f46c6df253f6f0d300d0e56d59b87e8 Mon Sep 17 00:00:00 2001 From: aforge Date: Wed, 25 Mar 2020 01:15:25 -0700 Subject: [PATCH 056/142] Move parsing of msg.from to dispatch from initTopicMe. --- server/init_topic.go | 11 ----------- server/session.go | 6 +++++- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/server/init_topic.go b/server/init_topic.go index a255dd0c4..51ba20c51 100644 --- a/server/init_topic.go +++ b/server/init_topic.go @@ -114,17 +114,6 @@ func initTopicMe(t *Topic, sreg *sessionJoin) error { t.cat = types.TopicCatMe uid := types.ParseUserId(t.name) - if uid.IsZero() { - // Log out the session - sreg.sess.uid = types.ZeroUid - log.Println("malformed topic name (sub me): ", t.name, sreg.sess.sid) - return types.ErrMalformed - } - if uid != sreg.sess.uid && sreg.sess.authLvl != auth.LevelRoot { - sreg.sess.uid = types.ZeroUid - log.Println("initTopicMe: attempt to subscribe to another account by non-root", sreg.sess) - return types.ErrPermissionDenied - } user, err := store.Users.Get(uid) if err != nil { // Log out the session diff --git a/server/session.go b/server/session.go index 05bd27171..2e9f1cdad 100644 --- a/server/session.go +++ b/server/session.go @@ -283,7 +283,11 @@ func (s *Session) dispatch(msg *ClientComMessage) { s.queueOut(ErrPermissionDenied("", "", msg.timestamp)) log.Println("s.dispatch: non-root asigned msg.from", s.sid) return - } + } else if fromUid := types.ParseUserId(msg.from); fromUid.IsZero() { + s.queueOut(ErrMalformed("", "", msg.timestamp)) + log.Println("malformed msg.from: ", msg.from, s.sid) + return + } var resp *ServerComMessage if msg, resp = pluginFireHose(s, msg); resp != nil { From ea4b294fcacefc05d511a81c975e11a7c624b50c Mon Sep 17 00:00:00 2001 From: aforge Date: Wed, 25 Mar 2020 01:17:02 -0700 Subject: [PATCH 057/142] formatting --- server/session.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/session.go b/server/session.go index 2e9f1cdad..04fcf4709 100644 --- a/server/session.go +++ b/server/session.go @@ -287,7 +287,7 @@ func (s *Session) dispatch(msg *ClientComMessage) { s.queueOut(ErrMalformed("", "", msg.timestamp)) log.Println("malformed msg.from: ", msg.from, s.sid) return - } + } var resp *ServerComMessage if msg, resp = pluginFireHose(s, msg); resp != nil { From 5897700b5e495886dda4e704d25fef9bd422e1a8 Mon Sep 17 00:00:00 2001 From: aforge Date: Wed, 25 Mar 2020 01:32:36 -0700 Subject: [PATCH 058/142] Treat explicitly specified empty string args to Usermod as not-None. --- tn-cli/macros.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tn-cli/macros.py b/tn-cli/macros.py index 179e07576..dc8435f79 100644 --- a/tn-cli/macros.py +++ b/tn-cli/macros.py @@ -85,11 +85,11 @@ def expand(self, id, cmd, args): # Change VCard. varname = cmd.varname if hasattr(cmd, 'varname') and cmd.varname else '$temp' set_cmd = '.must ' + varname + ' set me' - if cmd.name: + if cmd.name is not None: set_cmd += ' --fn="%s"' % cmd.name - if cmd.avatar: + if cmd.avatar is not None: set_cmd += ' --photo="%s"' % cmd.avatar - if cmd.comment: + if cmd.comment is not None: set_cmd += ' --private="%s"' % cmd.comment old_user = tn_globals.DefaultUser if tn_globals.DefaultUser else '' return ['.use --user %s' % cmd.userid, From 5a63f7b3a061789e60ee49e190a0068e6d3c0765 Mon Sep 17 00:00:00 2001 From: or-else Date: Wed, 25 Mar 2020 11:55:36 +0300 Subject: [PATCH 059/142] remove unnecessary variable --- server/init_topic.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/init_topic.go b/server/init_topic.go index 51ba20c51..98565b3ce 100644 --- a/server/init_topic.go +++ b/server/init_topic.go @@ -113,8 +113,7 @@ func topicInit(t *Topic, sreg *sessionJoin, h *Hub) { func initTopicMe(t *Topic, sreg *sessionJoin) error { t.cat = types.TopicCatMe - uid := types.ParseUserId(t.name) - user, err := store.Users.Get(uid) + user, err := store.Users.Get(types.ParseUserId(t.name)) if err != nil { // Log out the session sreg.sess.uid = types.ZeroUid From baf17f5e7e05bd22a91ae070934c068bcec3a0c4 Mon Sep 17 00:00:00 2001 From: or-else Date: Wed, 25 Mar 2020 11:59:05 +0300 Subject: [PATCH 060/142] commong logging string prefix --- server/session.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/session.go b/server/session.go index 04fcf4709..6f5dc3bc1 100644 --- a/server/session.go +++ b/server/session.go @@ -285,7 +285,7 @@ func (s *Session) dispatch(msg *ClientComMessage) { return } else if fromUid := types.ParseUserId(msg.from); fromUid.IsZero() { s.queueOut(ErrMalformed("", "", msg.timestamp)) - log.Println("malformed msg.from: ", msg.from, s.sid) + log.Println("s.dispatch: malformed msg.from: ", msg.from, s.sid) return } From fb30f65108301822f50903a2ba0b3c3c08c1fbc7 Mon Sep 17 00:00:00 2001 From: aforge Date: Wed, 25 Mar 2020 23:46:50 -0700 Subject: [PATCH 061/142] Add docker-compose overrides for rethinkdb and mongodb. --- docker/e2e/README.md | 16 ++++++- docker/e2e/cluster.mongodb.yml | 53 ++++++++++++++++++++++++ docker/e2e/cluster.rethinkdb.yml | 39 +++++++++++++++++ docker/e2e/cluster.yml | 4 ++ docker/e2e/single-instance.mongodb.yml | 37 +++++++++++++++++ docker/e2e/single-instance.rethinkdb.yml | 23 ++++++++++ docker/e2e/single-instance.yml | 2 + 7 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 docker/e2e/cluster.mongodb.yml create mode 100644 docker/e2e/cluster.rethinkdb.yml create mode 100644 docker/e2e/single-instance.mongodb.yml create mode 100644 docker/e2e/single-instance.rethinkdb.yml diff --git a/docker/e2e/README.md b/docker/e2e/README.md index eb63f6098..7c7b06893 100644 --- a/docker/e2e/README.md +++ b/docker/e2e/README.md @@ -1,7 +1,6 @@ # Docker compose for end-to-end setup. -These reference docker-compose files will run Tinode either as [a single-instance](single-instance.yml) or [a 3-node cluster](cluster.yml) setup. -They both use MySQL backend. +These reference docker-compose files will run Tinode with the MySql backend either as [a single-instance](single-instance.yml) or [a 3-node cluster](cluster.yml) setup. ``` docker-compose -f up -d @@ -11,3 +10,16 @@ By default, this command starts up a mysql instance, Tinode server(s) and Tinode Tinode server(s) is(are) configured similar to [Tinode Demo/Sandbox](../../README.md#demosandbox) and maps its web port to the host's port 6060 (6061, 6062). Tinode exporter(s) serve(s) metrics for Prometheus. Port mapping is 6222 (6223, 6224). + +Reference configuration for [RethinkDB 2.4.0](https://hub.docker.com/_/rethinkdb?tab=tags) and [MongoDB 4.2.3](https://hub.docker.com/_/mongo?tab=tags) is also available +in the form of override files. + +Start single-instance setup with: +``` +docker-compose -f single-instance.yml -f single-instance..yml up -d +``` + +And cluster with: +``` +docker-compose -f cluster.yml -f cluster..yml up -d +``` diff --git a/docker/e2e/cluster.mongodb.yml b/docker/e2e/cluster.mongodb.yml new file mode 100644 index 000000000..ab8f0e70c --- /dev/null +++ b/docker/e2e/cluster.mongodb.yml @@ -0,0 +1,53 @@ +version: '3.4' + +x-mongodb-tinode-env-vars: &mongodb-tinode-env-vars + "STORE_USE_ADAPTER": "mongodb" + +x-mongodb-exporter-env-vars: &mongodb-exporter-env-vars + "SERVE_FOR": "influxdb" + +services: + db: + image: mongo:4.2.3 + container_name: mongodb + entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs0" ] + healthcheck: + test: ["CMD", "curl -f http://localhost:28017/ || exit 1"] + + # Initializes MongoDb replicaset. + initdb: + image: mongo:4.2.3 + container_name: initdb + depends_on: + - db + command: > + bash -c "echo 'Starting replica set initialize'; + until mongo --host mongodb --eval 'print(\"waited for connection\")'; do sleep 2; done; + echo 'Connection finished'; + echo 'Creating replica set'; + echo \"rs.initiate({'_id': 'rs0', "members": [ {'_id': 0, 'host': 'mongodb:27017'} ]})\" | mongo --host mongodb" + + tinode-0: + environment: + << : *mongodb-tinode-env-vars + "WAIT_FOR": "mongodb:27017" + + tinode-1: + environment: + << : *mongodb-tinode-env-vars + + tinode-2: + environment: + << : *mongodb-tinode-env-vars + + exporter-0: + environment: + << : *mongodb-exporter-env-vars + + exporter-1: + environment: + << : *mongodb-exporter-env-vars + + exporter-2: + environment: + << : *mongodb-exporter-env-vars diff --git a/docker/e2e/cluster.rethinkdb.yml b/docker/e2e/cluster.rethinkdb.yml new file mode 100644 index 000000000..d74cea003 --- /dev/null +++ b/docker/e2e/cluster.rethinkdb.yml @@ -0,0 +1,39 @@ +version: '3.4' + +x-rethinkdb-tinode-env-vars: &rethinkdb-tinode-env-vars + "STORE_USE_ADAPTER": "rethinkdb" + +x-rethinkdb-exporter-env-vars: &rethinkdb-exporter-env-vars + "SERVE_FOR": "influxdb" + +services: + db: + image: rethinkdb:2.4.0 + container_name: rethinkdb + healthcheck: + test: ["CMD", "curl -f http://localhost:8080/ || exit 1"] + + tinode-0: + environment: + << : *rethinkdb-tinode-env-vars + "WAIT_FOR": "rethinkdb:8080" + + tinode-1: + environment: + << : *rethinkdb-tinode-env-vars + + tinode-2: + environment: + << : *rethinkdb-tinode-env-vars + + exporter-0: + environment: + << : *rethinkdb-exporter-env-vars + + exporter-1: + environment: + << : *rethinkdb-exporter-env-vars + + exporter-2: + environment: + << : *rethinkdb-exporter-env-vars diff --git a/docker/e2e/cluster.yml b/docker/e2e/cluster.yml index df782c287..6afbd9f12 100644 --- a/docker/e2e/cluster.yml +++ b/docker/e2e/cluster.yml @@ -110,6 +110,8 @@ services: environment: << : *tinode-env-vars "CLUSTER_SELF": "tinode-1" + # Wait for tinode-0, not the database since + # we let tinode-0 perform all database initialization and upgrade work. "WAIT_FOR": "tinode-0:18080" tinode-2: @@ -127,6 +129,8 @@ services: environment: << : *tinode-env-vars "CLUSTER_SELF": "tinode-2" + # Wait for tinode-0, not the database since + # we let tinode-0 perform all database initialization and upgrade work. "WAIT_FOR": "tinode-0:18080" # Monitoring. diff --git a/docker/e2e/single-instance.mongodb.yml b/docker/e2e/single-instance.mongodb.yml new file mode 100644 index 000000000..6667e5283 --- /dev/null +++ b/docker/e2e/single-instance.mongodb.yml @@ -0,0 +1,37 @@ +version: '3.4' + +x-mongodb-tinode-env-vars: &mongodb-tinode-env-vars + "STORE_USE_ADAPTER": "mongodb" + +x-mongodb-exporter-env-vars: &mongodb-exporter-env-vars + "SERVE_FOR": "influxdb" + +services: + db: + image: mongo:4.2.3 + container_name: mongodb + entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs0" ] + healthcheck: + test: ["CMD", "curl -f http://localhost:28017/ || exit 1"] + + # Initializes MongoDb replicaset. + initdb: + image: mongo:4.2.3 + container_name: initdb + depends_on: + - db + command: > + bash -c "echo 'Starting replica set initialize'; + until mongo --host mongodb --eval 'print(\"waited for connection\")'; do sleep 2; done; + echo 'Connection finished'; + echo 'Creating replica set'; + echo \"rs.initiate({'_id': 'rs0', "members": [ {'_id': 0, 'host': 'mongodb:27017'} ]})\" | mongo --host mongodb" + + tinode-0: + environment: + << : *mongodb-tinode-env-vars + "WAIT_FOR": "mongodb:27017" + + exporter-0: + environment: + << : *mongodb-exporter-env-vars diff --git a/docker/e2e/single-instance.rethinkdb.yml b/docker/e2e/single-instance.rethinkdb.yml new file mode 100644 index 000000000..6c0e7dedd --- /dev/null +++ b/docker/e2e/single-instance.rethinkdb.yml @@ -0,0 +1,23 @@ +version: '3.4' + +x-rethinkdb-tinode-env-vars: &rethinkdb-tinode-env-vars + "STORE_USE_ADAPTER": "rethinkdb" + +x-rethinkdb-exporter-env-vars: &rethinkdb-exporter-env-vars + "SERVE_FOR": "influxdb" + +services: + db: + image: rethinkdb:2.4.0 + container_name: rethinkdb + healthcheck: + test: ["CMD", "curl -f http://localhost:8080/ || exit 1"] + + tinode-0: + environment: + << : *rethinkdb-tinode-env-vars + "WAIT_FOR": "rethinkdb:8080" + + exporter-0: + environment: + << : *rethinkdb-exporter-env-vars diff --git a/docker/e2e/single-instance.yml b/docker/e2e/single-instance.yml index bb8ba76e7..e7d06602d 100644 --- a/docker/e2e/single-instance.yml +++ b/docker/e2e/single-instance.yml @@ -98,6 +98,8 @@ services: - tinode-0 image: tinode/exporter:latest restart: always + ports: + - "6222:6222" links: - tinode-0:tinode.host environment: From 7efdab243972826a942ad938c11dd545929ac464 Mon Sep 17 00:00:00 2001 From: or-else Date: Thu, 26 Mar 2020 14:15:18 +0300 Subject: [PATCH 062/142] make chatbot docker more configurable --- docker/chatbot/Dockerfile | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docker/chatbot/Dockerfile b/docker/chatbot/Dockerfile index 826b10a5a..6c1d14a73 100644 --- a/docker/chatbot/Dockerfile +++ b/docker/chatbot/Dockerfile @@ -3,6 +3,8 @@ FROM python:3.7-alpine ARG VERSION=0.16 +ARG LOGIN_AS= +ARG TINODE_HOST=tinode-srv:6061 ENV VERSION=$VERSION LABEL maintainer="Tinode Team " @@ -10,8 +12,13 @@ LABEL name="TinodeChatbot" LABEL version=$VERSION RUN mkdir -p /usr/src/bot +RUN mkdir /etc/botdata + WORKDIR /usr/src/bot +# Volume with login cookie. Not created automatically. +# VOLUME /etc/botdata + # Get tarball with the chatbot code and data. ADD https://github.com/tinode/chat/releases/download/v$VERSION/py-chatbot.tar.gz . # Unpack chatbot, delete archive @@ -22,7 +29,7 @@ RUN pip install --no-cache-dir -r requirements.txt # Use docker's command line parameter `-e LOGIN_AS=user:password` to login as someone other than Tino. -CMD python chatbot.py --login-basic=${LOGIN_AS} --login-cookie=/botdata/.tn-cookie --host=tinode-srv:16061 > /var/log/chatbot.log +CMD python chatbot.py --login-basic=${LOGIN_AS} --login-cookie=/etc/botdata/.tn-cookie --host=${TINODE_HOST} > /var/log/chatbot.log # Plugin port EXPOSE 40051 From 83150ad58f849ad28ed4b5f9f87c2138db45da0e Mon Sep 17 00:00:00 2001 From: or-else Date: Thu, 26 Mar 2020 14:18:57 +0300 Subject: [PATCH 063/142] keep old cookie location in chatbot --- docker/chatbot/Dockerfile | 5 ++--- docker/tinode/Dockerfile | 8 +------- docker/tinode/entrypoint.sh | 5 ++--- server/push/tnpg/push_tnpg.go | 32 +++++++++++++------------------- 4 files changed, 18 insertions(+), 32 deletions(-) diff --git a/docker/chatbot/Dockerfile b/docker/chatbot/Dockerfile index 6c1d14a73..a790e013c 100644 --- a/docker/chatbot/Dockerfile +++ b/docker/chatbot/Dockerfile @@ -12,12 +12,11 @@ LABEL name="TinodeChatbot" LABEL version=$VERSION RUN mkdir -p /usr/src/bot -RUN mkdir /etc/botdata WORKDIR /usr/src/bot # Volume with login cookie. Not created automatically. -# VOLUME /etc/botdata +# VOLUME /botdata # Get tarball with the chatbot code and data. ADD https://github.com/tinode/chat/releases/download/v$VERSION/py-chatbot.tar.gz . @@ -29,7 +28,7 @@ RUN pip install --no-cache-dir -r requirements.txt # Use docker's command line parameter `-e LOGIN_AS=user:password` to login as someone other than Tino. -CMD python chatbot.py --login-basic=${LOGIN_AS} --login-cookie=/etc/botdata/.tn-cookie --host=${TINODE_HOST} > /var/log/chatbot.log +CMD python chatbot.py --login-basic=${LOGIN_AS} --login-cookie=/botdata/.tn-cookie --host=${TINODE_HOST} > /var/log/chatbot.log # Plugin port EXPOSE 40051 diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index a5752bc7a..66eff6dd5 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -11,7 +11,7 @@ FROM alpine:latest -ARG VERSION=0.16.3 +ARG VERSION=0.16.4 ENV VERSION=$VERSION LABEL maintainer="Tinode Team " @@ -30,9 +30,6 @@ ENV TARGET_DB=$TARGET_DB # Runtime options. -# Specifies what jobs to run: init-db, tinode or both. -ENV SERVICES_TO_RUN='both' - # Specifies the database host:port pair to wait for before running Tinode. # Ignored if empty. ENV WAIT_FOR= @@ -95,9 +92,6 @@ ENV TNPG_AUTH_TOKEN= # Tinode Push Gateway user name. ENV TNPG_USER= -# Enable Tinode Push Gateway request payload compression. -ENV TNPG_COMPRESS_PAYLOADS=true - # 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. diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index 6e1331bc6..cb7c0db39 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -96,10 +96,10 @@ if [ ! -z "$WAIT_FOR" ] ; then echo "\$WAIT_FOR (${WAIT_FOR}) env var should be in form HOST:PORT" exit 1 fi - until nc -z -v -w5 ${DB[0]} ${DB[1]}; do echo "waiting for ${WAIT_FOR}..."; sleep 5; done + until nc -z -v -w5 ${DB[0]} ${DB[1]}; do echo "waiting for ${WAIT_FOR}..."; sleep 3; done fi -init_args=("--reset=${RESET_DB}" "--upgrade=${UPGRADE_DB}" "--config=${CONFIG}" "--data=$SAMPLE_DATA") +init_args=("--reset=${RESET_DB}" "--upgrade=${UPGRADE_DB}" "--config=${CONFIG}" "--data=${SAMPLE_DATA}") init_stdout=./init-db-stdout.txt # Initialize the database if it has not been initialized yet or if data reset/upgrade has been requested. ./init-db "${init_args[@]}" 1>$init_stdout @@ -115,7 +115,6 @@ fi if [ -s /botdata/tino-password ] ; then # Convert Tino's authentication credentials into a cookie file. - # The cookie file is also used to check if database has been initialized. # /botdata/tino-password could be empty if DB was not updated. In such a case the # /botdata/.tn-cookie will not be modified. diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go index 5282bc49c..094af43c9 100644 --- a/server/push/tnpg/push_tnpg.go +++ b/server/push/tnpg/push_tnpg.go @@ -21,17 +21,16 @@ const ( var handler Handler type Handler struct { - input chan *push.Receipt - stop chan bool + input chan *push.Receipt + stop chan bool } type configType struct { - Enabled bool `json:"enabled"` - Buffer int `json:"buffer"` - CompressPayloads bool `json:"compress_payloads"` - User string `json:"user"` - AuthToken string `json:"auth_token"` - Android fcm.AndroidConfig `json:"android,omitempty"` + Enabled bool `json:"enabled"` + Buffer int `json:"buffer"` + User string `json:"user"` + AuthToken string `json:"auth_token"` + Android fcm.AndroidConfig `json:"android,omitempty"` } // Init initializes the handler @@ -68,23 +67,18 @@ func (Handler) Init(jsonconf string) error { func postMessage(body interface{}, config *configType) (int, string, error) { buf := new(bytes.Buffer) - if config.CompressPayloads { - gz := gzip.NewWriter(buf) - json.NewEncoder(gz).Encode(body) - gz.Close() - } else { - json.NewEncoder(buf).Encode(body) - } + gz := gzip.NewWriter(buf) + json.NewEncoder(gz).Encode(body) + gz.Close() targetAddress := baseTargetAddress + config.User req, err := http.NewRequest("POST", targetAddress, buf) if err != nil { return -1, "", err } - req.Header.Add("Authorization", "Bearer " + config.AuthToken) + req.Header.Add("Authorization", "Bearer "+config.AuthToken) req.Header.Set("Content-Type", "application/json; charset=utf-8") - if config.CompressPayloads { - req.Header.Add("Content-Encoding", "gzip") - } + req.Header.Add("Content-Encoding", "gzip") + resp, err := http.DefaultClient.Do(req) if err != nil { return -1, "", err From 01f0519bd59973e19253f6eed831fb9bd6597496 Mon Sep 17 00:00:00 2001 From: or-else Date: Thu, 26 Mar 2020 20:06:29 +0300 Subject: [PATCH 064/142] go back to slim as slpine does not have gcc --- docker/chatbot/Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docker/chatbot/Dockerfile b/docker/chatbot/Dockerfile index a790e013c..93b57cbc7 100644 --- a/docker/chatbot/Dockerfile +++ b/docker/chatbot/Dockerfile @@ -1,11 +1,12 @@ # Dockerfile builds an image with a chatbot (Tino) for Tinode. -FROM python:3.7-alpine +FROM python:3.7-slim ARG VERSION=0.16 ARG LOGIN_AS= ARG TINODE_HOST=tinode-srv:6061 ENV VERSION=$VERSION +ARG BINVERS=$VERSION LABEL maintainer="Tinode Team " LABEL name="TinodeChatbot" @@ -19,7 +20,7 @@ WORKDIR /usr/src/bot # VOLUME /botdata # Get tarball with the chatbot code and data. -ADD https://github.com/tinode/chat/releases/download/v$VERSION/py-chatbot.tar.gz . +ADD https://github.com/tinode/chat/releases/download/v${BINVERS}/py-chatbot.tar.gz . # Unpack chatbot, delete archive RUN tar -xzf py-chatbot.tar.gz \ && rm py-chatbot.tar.gz From 13262c7d64cb454ae5fa8c090a5b8519352dfba8 Mon Sep 17 00:00:00 2001 From: or-else Date: Thu, 26 Mar 2020 20:09:59 +0300 Subject: [PATCH 065/142] typos --- server/tinode.conf | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/tinode.conf b/server/tinode.conf index 333a80b1e..566a23fb3 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -1,8 +1,8 @@ // The JSON comments are somewhat brittle. Don't try anything too fancy. { - // HTTP(S) address to listen on for websocket and long polling clients. Either a TCP host:port pair + // HTTP(S) address to listen on for websocket and long polling clients. Either a TCP host:port pair // or a path to Unix socket as "unix:/path/to/socket.sock". - // The TCP port is numeric value or a canonical name, e.g. ":80" or ":https". May include the host name, + // The TCP port is numeric value or a canonical name, e.g. ":80" or ":https". May include the host name, ///e.g. "localhost:80" or "hostname.example.com:https". // It could be blank: if TLS is not configured it will default to ":80", otherwise to ":443". // Can be overridden from the command line, see option --listen. @@ -18,7 +18,7 @@ // URL path for mounting the directory with static files. "static_mount": "/", - // TCP host:port or unix:/path/to/socket to listen for gRPC clients. + // TCP host:port or unix:/path/to/socket to listen for gRPC clients. // Leave blank to disable gRPC support. // Could be overridden from the command line with --grpc_listen. "grpc_listen": ":6061", @@ -428,10 +428,10 @@ "account": "C" }, - // Error code to use in case flugin has failed. + // Error code to use in case plugin has failed. "failure_code": 0, - // Text of an error message to report in case of plugin falure. + // Text of an error message to report in case of plugin failure. "failure_text": null, // Address of the plugin. From 885ce7505c71727920bcf705f1c7aadd62282f9b Mon Sep 17 00:00:00 2001 From: aforge Date: Thu, 26 Mar 2020 20:52:14 -0700 Subject: [PATCH 066/142] Rename docker/e2e->docker-compose. Bump version up to 3.7. --- docker/{e2e => docker-compose}/README.md | 0 .../cluster.mongodb.yml | 17 +------- .../cluster.rethinkdb.yml} | 13 ++++--- docker/{e2e => docker-compose}/cluster.yml | 2 +- .../single-instance.mongodb.yml | 9 +---- .../single-instance.rethinkdb.yml | 16 ++++++++ .../single-instance.yml | 2 +- docker/e2e/cluster.rethinkdb.yml | 39 ------------------- 8 files changed, 27 insertions(+), 71 deletions(-) rename docker/{e2e => docker-compose}/README.md (100%) rename docker/{e2e => docker-compose}/cluster.mongodb.yml (76%) rename docker/{e2e/single-instance.rethinkdb.yml => docker-compose/cluster.rethinkdb.yml} (70%) rename docker/{e2e => docker-compose}/cluster.yml (99%) rename docker/{e2e => docker-compose}/single-instance.mongodb.yml (84%) create mode 100644 docker/docker-compose/single-instance.rethinkdb.yml rename docker/{e2e => docker-compose}/single-instance.yml (99%) delete mode 100644 docker/e2e/cluster.rethinkdb.yml diff --git a/docker/e2e/README.md b/docker/docker-compose/README.md similarity index 100% rename from docker/e2e/README.md rename to docker/docker-compose/README.md diff --git a/docker/e2e/cluster.mongodb.yml b/docker/docker-compose/cluster.mongodb.yml similarity index 76% rename from docker/e2e/cluster.mongodb.yml rename to docker/docker-compose/cluster.mongodb.yml index ab8f0e70c..229fb0277 100644 --- a/docker/e2e/cluster.mongodb.yml +++ b/docker/docker-compose/cluster.mongodb.yml @@ -1,11 +1,8 @@ -version: '3.4' +version: '3.7' x-mongodb-tinode-env-vars: &mongodb-tinode-env-vars "STORE_USE_ADAPTER": "mongodb" -x-mongodb-exporter-env-vars: &mongodb-exporter-env-vars - "SERVE_FOR": "influxdb" - services: db: image: mongo:4.2.3 @@ -39,15 +36,3 @@ services: tinode-2: environment: << : *mongodb-tinode-env-vars - - exporter-0: - environment: - << : *mongodb-exporter-env-vars - - exporter-1: - environment: - << : *mongodb-exporter-env-vars - - exporter-2: - environment: - << : *mongodb-exporter-env-vars diff --git a/docker/e2e/single-instance.rethinkdb.yml b/docker/docker-compose/cluster.rethinkdb.yml similarity index 70% rename from docker/e2e/single-instance.rethinkdb.yml rename to docker/docker-compose/cluster.rethinkdb.yml index 6c0e7dedd..4226ff703 100644 --- a/docker/e2e/single-instance.rethinkdb.yml +++ b/docker/docker-compose/cluster.rethinkdb.yml @@ -1,11 +1,8 @@ -version: '3.4' +version: '3.7' x-rethinkdb-tinode-env-vars: &rethinkdb-tinode-env-vars "STORE_USE_ADAPTER": "rethinkdb" -x-rethinkdb-exporter-env-vars: &rethinkdb-exporter-env-vars - "SERVE_FOR": "influxdb" - services: db: image: rethinkdb:2.4.0 @@ -18,6 +15,10 @@ services: << : *rethinkdb-tinode-env-vars "WAIT_FOR": "rethinkdb:8080" - exporter-0: + tinode-1: environment: - << : *rethinkdb-exporter-env-vars + << : *rethinkdb-tinode-env-vars + + tinode-2: + environment: + << : *rethinkdb-tinode-env-vars diff --git a/docker/e2e/cluster.yml b/docker/docker-compose/cluster.yml similarity index 99% rename from docker/e2e/cluster.yml rename to docker/docker-compose/cluster.yml index 6afbd9f12..97af1a774 100644 --- a/docker/e2e/cluster.yml +++ b/docker/docker-compose/cluster.yml @@ -4,7 +4,7 @@ # * 3 Tinode servers # * 3 exporters -version: '3.4' +version: '3.7' # Base Tinode template. x-tinode: diff --git a/docker/e2e/single-instance.mongodb.yml b/docker/docker-compose/single-instance.mongodb.yml similarity index 84% rename from docker/e2e/single-instance.mongodb.yml rename to docker/docker-compose/single-instance.mongodb.yml index 6667e5283..19d6025ab 100644 --- a/docker/e2e/single-instance.mongodb.yml +++ b/docker/docker-compose/single-instance.mongodb.yml @@ -1,11 +1,8 @@ -version: '3.4' +version: '3.7' x-mongodb-tinode-env-vars: &mongodb-tinode-env-vars "STORE_USE_ADAPTER": "mongodb" -x-mongodb-exporter-env-vars: &mongodb-exporter-env-vars - "SERVE_FOR": "influxdb" - services: db: image: mongo:4.2.3 @@ -31,7 +28,3 @@ services: environment: << : *mongodb-tinode-env-vars "WAIT_FOR": "mongodb:27017" - - exporter-0: - environment: - << : *mongodb-exporter-env-vars diff --git a/docker/docker-compose/single-instance.rethinkdb.yml b/docker/docker-compose/single-instance.rethinkdb.yml new file mode 100644 index 000000000..5dfa6afdc --- /dev/null +++ b/docker/docker-compose/single-instance.rethinkdb.yml @@ -0,0 +1,16 @@ +version: '3.7' + +x-rethinkdb-tinode-env-vars: &rethinkdb-tinode-env-vars + "STORE_USE_ADAPTER": "rethinkdb" + +services: + db: + image: rethinkdb:2.4.0 + container_name: rethinkdb + healthcheck: + test: ["CMD", "curl -f http://localhost:8080/ || exit 1"] + + tinode-0: + environment: + << : *rethinkdb-tinode-env-vars + "WAIT_FOR": "rethinkdb:8080" diff --git a/docker/e2e/single-instance.yml b/docker/docker-compose/single-instance.yml similarity index 99% rename from docker/e2e/single-instance.yml rename to docker/docker-compose/single-instance.yml index e7d06602d..10203396e 100644 --- a/docker/e2e/single-instance.yml +++ b/docker/docker-compose/single-instance.yml @@ -4,7 +4,7 @@ # * Tinode server # * Tinode exporters -version: '3.4' +version: '3.7' # Base Tinode template. x-tinode: diff --git a/docker/e2e/cluster.rethinkdb.yml b/docker/e2e/cluster.rethinkdb.yml deleted file mode 100644 index d74cea003..000000000 --- a/docker/e2e/cluster.rethinkdb.yml +++ /dev/null @@ -1,39 +0,0 @@ -version: '3.4' - -x-rethinkdb-tinode-env-vars: &rethinkdb-tinode-env-vars - "STORE_USE_ADAPTER": "rethinkdb" - -x-rethinkdb-exporter-env-vars: &rethinkdb-exporter-env-vars - "SERVE_FOR": "influxdb" - -services: - db: - image: rethinkdb:2.4.0 - container_name: rethinkdb - healthcheck: - test: ["CMD", "curl -f http://localhost:8080/ || exit 1"] - - tinode-0: - environment: - << : *rethinkdb-tinode-env-vars - "WAIT_FOR": "rethinkdb:8080" - - tinode-1: - environment: - << : *rethinkdb-tinode-env-vars - - tinode-2: - environment: - << : *rethinkdb-tinode-env-vars - - exporter-0: - environment: - << : *rethinkdb-exporter-env-vars - - exporter-1: - environment: - << : *rethinkdb-exporter-env-vars - - exporter-2: - environment: - << : *rethinkdb-exporter-env-vars From a9798d5bbb0ba9750eeea9bec861b84109ef8c11 Mon Sep 17 00:00:00 2001 From: aforge Date: Thu, 26 Mar 2020 21:52:04 -0700 Subject: [PATCH 067/142] Make RESET_DB and UPGRADE_DB subsitute vars. Beef up README. --- docker/docker-compose/README.md | 48 ++++++++++++++++++++--- docker/docker-compose/cluster.yml | 5 +-- docker/docker-compose/single-instance.yml | 5 +-- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/docker/docker-compose/README.md b/docker/docker-compose/README.md index 7c7b06893..46f880f71 100644 --- a/docker/docker-compose/README.md +++ b/docker/docker-compose/README.md @@ -3,7 +3,7 @@ These reference docker-compose files will run Tinode with the MySql backend either as [a single-instance](single-instance.yml) or [a 3-node cluster](cluster.yml) setup. ``` -docker-compose -f up -d +docker-compose -f [-f ] up -d ``` By default, this command starts up a mysql instance, Tinode server(s) and Tinode exporter(s). @@ -14,12 +14,50 @@ Tinode exporter(s) serve(s) metrics for Prometheus. Port mapping is 6222 (6223, Reference configuration for [RethinkDB 2.4.0](https://hub.docker.com/_/rethinkdb?tab=tags) and [MongoDB 4.2.3](https://hub.docker.com/_/mongo?tab=tags) is also available in the form of override files. -Start single-instance setup with: +## Commands + +### Full stack +To bring up the full stack, you can use the following commands: +* MySql: +-Single-instance setup: `docker-compose -f single-instance.yml up -d` +-Cluster: `docker-compose -f cluster.yml up -d` +* RethinkDb: +-Single-instance setup: `docker-compose -f single-instance.yml -f single-instance.rethinkdb.yml up -d` +-Cluster: `docker-compose -f cluster.yml -f cluster.rethinkdb.yml up -d` +* MongoDb: +-Single-instance setup: `docker-compose -f single-instance.yml -f single-instance.mongodb.yml up -d` +-Cluster: `docker-compose -f cluster.yml -f cluster.mongodb.yml up -d` + +You can run individual/separate components of the setup by providing their names to the `docker-compose` command. +E.g. to start the Tinode server in the single-instance MySql setup, +``` +docker-compose -f single-instance.yml up -d tinode-0 +``` + +### Database resets and/or version upgrades +To reset the database or upgrade the database version, you can set `RESET_DB` or `UPGRADE_DB` environment variable to true when starting Tinode with docker-compose. +E.g. for upgrading the database in MongoDb cluster setup, use: +``` +UPGRADE_DB=true docker-compose -f cluster.yml -f cluster.mongodb.yml up -d tinode-0 +``` + +For resetting the database in RethinkDb single-instance setup, run: +``` +RESET_DB=true docker-compose -f single-instance.yml -f single-instance.rethinkdb.yml up -d tinode-0 +``` + +## Troubleshooting +Print out and verify your docker-compose configuration by running: +``` +docker-compose -f [-f ] config +``` + +If the Tinode server(s) are failing, you can print the job's stdout/stderr with: ``` -docker-compose -f single-instance.yml -f single-instance..yml up -d +docker logs tinode- ``` -And cluster with: +Additionally, you can examine the jobs `tinode.log` file. To download it from the container, run: ``` -docker-compose -f cluster.yml -f cluster..yml up -d +docker cp tinode-:/var/log/tinode.log . ``` diff --git a/docker/docker-compose/cluster.yml b/docker/docker-compose/cluster.yml index 97af1a774..b1fe8395b 100644 --- a/docker/docker-compose/cluster.yml +++ b/docker/docker-compose/cluster.yml @@ -91,9 +91,8 @@ services: environment: << : *tinode-env-vars "CLUSTER_SELF": "tinode-0" - # Uncomment the lines below if you wish to upgrade or reset the database. - # "UPGRADE_DB": "true" - # "RESET_DB": "true" + "RESET_DB": ${RESET_DB:-false} + "UPGRADE_DB": ${UPGRADE_DB:-false} tinode-1: << : *tinode-base diff --git a/docker/docker-compose/single-instance.yml b/docker/docker-compose/single-instance.yml index 10203396e..f01eb98c5 100644 --- a/docker/docker-compose/single-instance.yml +++ b/docker/docker-compose/single-instance.yml @@ -85,9 +85,8 @@ services: - "6060:18080" environment: << : *tinode-env-vars - # Uncomment the lines below if you wish to upgrade or reset the database. - # "UPGRADE_DB": "true" - # "RESET_DB": "true" + "RESET_DB": ${RESET_DB:-false} + "UPGRADE_DB": ${UPGRADE_DB:-false} # Monitoring. # Exporters are paired with tinode instances. From aa651c5b8b68b39c3eee806178e91583ce7ab979 Mon Sep 17 00:00:00 2001 From: aforge Date: Thu, 26 Mar 2020 21:57:13 -0700 Subject: [PATCH 068/142] Fix formatting. --- docker/docker-compose/README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docker/docker-compose/README.md b/docker/docker-compose/README.md index 46f880f71..6d26ff8ca 100644 --- a/docker/docker-compose/README.md +++ b/docker/docker-compose/README.md @@ -12,21 +12,21 @@ maps its web port to the host's port 6060 (6061, 6062). Tinode exporter(s) serve(s) metrics for Prometheus. Port mapping is 6222 (6223, 6224). Reference configuration for [RethinkDB 2.4.0](https://hub.docker.com/_/rethinkdb?tab=tags) and [MongoDB 4.2.3](https://hub.docker.com/_/mongo?tab=tags) is also available -in the form of override files. +in the override files. ## Commands ### Full stack To bring up the full stack, you can use the following commands: * MySql: --Single-instance setup: `docker-compose -f single-instance.yml up -d` --Cluster: `docker-compose -f cluster.yml up -d` + - Single-instance setup: `docker-compose -f single-instance.yml up -d` + - Cluster: `docker-compose -f cluster.yml up -d` * RethinkDb: --Single-instance setup: `docker-compose -f single-instance.yml -f single-instance.rethinkdb.yml up -d` --Cluster: `docker-compose -f cluster.yml -f cluster.rethinkdb.yml up -d` + - Single-instance setup: `docker-compose -f single-instance.yml -f single-instance.rethinkdb.yml up -d` + - Cluster: `docker-compose -f cluster.yml -f cluster.rethinkdb.yml up -d` * MongoDb: --Single-instance setup: `docker-compose -f single-instance.yml -f single-instance.mongodb.yml up -d` --Cluster: `docker-compose -f cluster.yml -f cluster.mongodb.yml up -d` + - Single-instance setup: `docker-compose -f single-instance.yml -f single-instance.mongodb.yml up -d` + - Cluster: `docker-compose -f cluster.yml -f cluster.mongodb.yml up -d` You can run individual/separate components of the setup by providing their names to the `docker-compose` command. E.g. to start the Tinode server in the single-instance MySql setup, From b5ace68c7465abab4346a37d8ae1943fbda269a8 Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 27 Mar 2020 08:14:14 +0300 Subject: [PATCH 069/142] removed $TNPG_COMPRESS_PAYLOADS as it is always true --- docker/tinode/config.template | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docker/tinode/config.template b/docker/tinode/config.template index 0134253fd..826c93375 100644 --- a/docker/tinode/config.template +++ b/docker/tinode/config.template @@ -110,8 +110,7 @@ "config": { "enabled": $TNPG_PUSH_ENABLED, "auth_token": "$TNPG_AUTH_TOKEN", - "user": "$TNPG_USER", - "compress_payloads": $TNPG_COMPRESS_PAYLOADS + "user": "$TNPG_USER" } }, { From 0584eba0b847c9ab55f28154a2d1b4a8224d412c Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 27 Mar 2020 08:33:40 +0300 Subject: [PATCH 070/142] typo and comment clarification --- server/auth/rest/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/auth/rest/README.md b/server/auth/rest/README.md index ba82f9991..8f71c3e81 100644 --- a/server/auth/rest/README.md +++ b/server/auth/rest/README.md @@ -48,11 +48,11 @@ Request and response payloads are formatted as JSON. Some of the request or resp { // ServerUrl is the URL of the authentication server to call. "server_url": "http://127.0.0.1:5000/", - // Server may create new accounts. + // Authentication server is allowed to create new accounts. "allow_new_accounts": true, // Use separate endpoints, i.e. add request name to serverUrl path when making requests: // http://127.0.0.1:5000/add - "use_separae_endpoints": true + "use_separate_endpoints": true } ``` From d838803fccc30335051343031a81c540dc1713be Mon Sep 17 00:00:00 2001 From: aforge Date: Thu, 26 Mar 2020 23:34:23 -0700 Subject: [PATCH 071/142] init-db: do not load sample data on upgrade. --- tinode-db/main.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tinode-db/main.go b/tinode-db/main.go index 71593b902..7ec8b2367 100644 --- a/tinode-db/main.go +++ b/tinode-db/main.go @@ -245,6 +245,8 @@ func main() { log.Fatal("Failed to init DB:", err) } - genDb(&data) + if !*upgrade { + genDb(&data) + } os.Exit(0) } From a6e275cc76d554a887e4896dc1cd45a4f12d4a2d Mon Sep 17 00:00:00 2001 From: aforge Date: Thu, 26 Mar 2020 23:40:49 -0700 Subject: [PATCH 072/142] Add a logging message to say sample data ignored when an upgrade was requested. --- tinode-db/main.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tinode-db/main.go b/tinode-db/main.go index 7ec8b2367..bc50be9d4 100644 --- a/tinode-db/main.go +++ b/tinode-db/main.go @@ -247,6 +247,8 @@ func main() { if !*upgrade { genDb(&data) - } + } else { + log.Println("Sample data was ignored. All done.") + } os.Exit(0) } From b65273421a839b30c72e87f6090e8c9ef3ba4193 Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 27 Mar 2020 09:49:23 +0300 Subject: [PATCH 073/142] update REST auth documentation per #401 --- server/auth/rest/README.md | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/server/auth/rest/README.md b/server/auth/rest/README.md index 8f71c3e81..0f67b9250 100644 --- a/server/auth/rest/README.md +++ b/server/auth/rest/README.md @@ -44,16 +44,33 @@ Request and response payloads are formatted as JSON. Some of the request or resp ## Configuration -```js -{ - // ServerUrl is the URL of the authentication server to call. - "server_url": "http://127.0.0.1:5000/", - // Authentication server is allowed to create new accounts. - "allow_new_accounts": true, - // Use separate endpoints, i.e. add request name to serverUrl path when making requests: - // http://127.0.0.1:5000/add - "use_separate_endpoints": true -} +Add the following section to the `auth_config` in [tinode.conf](../../tinode.conf): + +```json +... +"auth_config": { + ... + "myveryownauth": { + // ServerUrl is the URL of the authentication server to call. + "server_url": "http://127.0.0.1:5000/", + // Authentication server is allowed to create new accounts. + "allow_new_accounts": true, + // Use separate endpoints, i.e. add request name to serverUrl path when making requests: + // http://127.0.0.1:5000/add + "use_separate_endpoints": true + }, + ... +}, +``` +The name `myveryownauth` is completely arbitrary, but your client has to be configured to use it. If you want to use your +config **instead** of stock `basic` (login-password) authentication, then add a logical renaming: +``` +... +"auth_config": { + "logical_names": ["myveryownauth:basic", "basic:"], + "myveryownauth": { ... }, +}, +... ``` ## Request From a7f0d1f26902b57bf8755cc32bc02dc11775ee72 Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 27 Mar 2020 09:51:18 +0300 Subject: [PATCH 074/142] gofmt --- tinode-db/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinode-db/main.go b/tinode-db/main.go index bc50be9d4..78aa09f97 100644 --- a/tinode-db/main.go +++ b/tinode-db/main.go @@ -249,6 +249,6 @@ func main() { genDb(&data) } else { log.Println("Sample data was ignored. All done.") - } + } os.Exit(0) } From de266fbf0554e3f12403f57724e38c635c0accce Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 27 Mar 2020 09:54:03 +0300 Subject: [PATCH 075/142] markdown formatting --- server/auth/rest/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/auth/rest/README.md b/server/auth/rest/README.md index 0f67b9250..e1b60a9e6 100644 --- a/server/auth/rest/README.md +++ b/server/auth/rest/README.md @@ -46,7 +46,7 @@ Request and response payloads are formatted as JSON. Some of the request or resp Add the following section to the `auth_config` in [tinode.conf](../../tinode.conf): -```json +```js ... "auth_config": { ... @@ -64,11 +64,12 @@ Add the following section to the `auth_config` in [tinode.conf](../../tinode.con ``` The name `myveryownauth` is completely arbitrary, but your client has to be configured to use it. If you want to use your config **instead** of stock `basic` (login-password) authentication, then add a logical renaming: -``` +```js ... "auth_config": { "logical_names": ["myveryownauth:basic", "basic:"], "myveryownauth": { ... }, + ... }, ... ``` From 346d2c79aac5bcce10ac8c24a9f6175144c0d2a4 Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 27 Mar 2020 11:23:18 +0300 Subject: [PATCH 076/142] remove unused DEFAULT_SAMPLE_DATA --- docker/docker-compose/cluster.yml | 1 - docker/docker-compose/single-instance.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/docker/docker-compose/cluster.yml b/docker/docker-compose/cluster.yml index b1fe8395b..ac1b681c1 100644 --- a/docker/docker-compose/cluster.yml +++ b/docker/docker-compose/cluster.yml @@ -21,7 +21,6 @@ x-exporter: x-tinode-env-vars: &tinode-env-vars "STORE_USE_ADAPTER": "mysql" - "DEFAULT_SAMPLE_DATA": "" "PPROF_URL": "/pprof" # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to # "EXT_CONFIG": "/etc/tinode/tinode.conf" diff --git a/docker/docker-compose/single-instance.yml b/docker/docker-compose/single-instance.yml index f01eb98c5..e18a9d873 100644 --- a/docker/docker-compose/single-instance.yml +++ b/docker/docker-compose/single-instance.yml @@ -16,7 +16,6 @@ x-tinode: x-tinode-env-vars: &tinode-env-vars "STORE_USE_ADAPTER": "mysql" - "DEFAULT_SAMPLE_DATA": "" "PPROF_URL": "/pprof" # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to # "EXT_CONFIG": "/etc/tinode/tinode.conf" From ad4508e68cc9dc2fe46766b29464a421c0feb9b3 Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 27 Mar 2020 11:23:45 +0300 Subject: [PATCH 077/142] consistent use of tabs vs spaces --- docker/tinode/entrypoint.sh | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index cb7c0db39..186198b1e 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -76,15 +76,15 @@ if [ ! -z "$IOS_UNIV_LINKS_APP_ID" ] ; then # See https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/UniversalLinks.html for details. cat > $STATIC_DIR/apple-app-site-association <<- EOM { - "applinks": { - "apps": [], - "details": [ - { - "appID": "$IOS_UNIV_LINKS_APP_ID", - "paths": [ "*" ] - } - ] - } + "applinks": { + "apps": [], + "details": [ + { + "appID": "$IOS_UNIV_LINKS_APP_ID", + "paths": [ "*" ] + } + ] + } } EOM fi @@ -99,10 +99,9 @@ if [ ! -z "$WAIT_FOR" ] ; then until nc -z -v -w5 ${DB[0]} ${DB[1]}; do echo "waiting for ${WAIT_FOR}..."; sleep 3; done fi -init_args=("--reset=${RESET_DB}" "--upgrade=${UPGRADE_DB}" "--config=${CONFIG}" "--data=${SAMPLE_DATA}") -init_stdout=./init-db-stdout.txt # Initialize the database if it has not been initialized yet or if data reset/upgrade has been requested. -./init-db "${init_args[@]}" 1>$init_stdout +init_stdout=./init-db-stdout.txt +./init-db --reset=${RESET_DB} --upgrade=${UPGRADE_DB} --config=${CONFIG} --data=${SAMPLE_DATA} 1>${init_stdout} if [ $? -ne 0 ]; then echo "./init-db failed. Quitting." exit 1 From f4d0401d6a24e07993240b85dc3d5592379bde55 Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 27 Mar 2020 12:04:17 +0300 Subject: [PATCH 078/142] consistent use of tabs vs spaces --- docker/tinode/entrypoint.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index 186198b1e..143b2ffbd 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -44,9 +44,9 @@ fi # If external static dir is defined, use it. # Otherwise, fall back to "./static". if [ ! -z "$EXT_STATIC_DIR" ] ; then - STATIC_DIR=$EXT_STATIC_DIR + STATIC_DIR=$EXT_STATIC_DIR else - STATIC_DIR="./static" + STATIC_DIR="./static" fi # Do not load data when upgrading database. @@ -57,7 +57,7 @@ fi # If push notifications are enabled, generate client-side firebase config file. if [ ! -z "$FCM_PUSH_ENABLED" ] ; then # Write client config to $STATIC_DIR/firebase-init.js - cat > $STATIC_DIR/firebase-init.js <<- EOM + cat > $STATIC_DIR/firebase-init.js <<- EOM const FIREBASE_INIT = { apiKey: "$FCM_API_KEY", appId: "$FCM_APP_ID", @@ -73,7 +73,7 @@ fi if [ ! -z "$IOS_UNIV_LINKS_APP_ID" ] ; then # Write config to $STATIC_DIR/apple-app-site-association config file. - # See https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/UniversalLinks.html for details. + # See https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/UniversalLinks.html for details. cat > $STATIC_DIR/apple-app-site-association <<- EOM { "applinks": { From 208c669c56d0af2bb421797f9dc0dad9eb748acf Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 27 Mar 2020 12:04:58 +0300 Subject: [PATCH 079/142] log that data was ignored only when the data was provided --- tinode-db/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinode-db/main.go b/tinode-db/main.go index 78aa09f97..47076d03e 100644 --- a/tinode-db/main.go +++ b/tinode-db/main.go @@ -247,7 +247,7 @@ func main() { if !*upgrade { genDb(&data) - } else { + } else if len(data.Users) > 0 { log.Println("Sample data was ignored. All done.") } os.Exit(0) From fb3d4c589be05f1a30a1ad598e69651a7b4f160a Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 27 Mar 2020 12:35:49 +0300 Subject: [PATCH 080/142] defer logging adapter name & version till after config is read --- tinode-db/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tinode-db/main.go b/tinode-db/main.go index 47076d03e..78091714c 100644 --- a/tinode-db/main.go +++ b/tinode-db/main.go @@ -165,7 +165,6 @@ func getPassword(n int) string { } func main() { - log.Println("Initializing", store.GetAdapterName(), store.GetAdapterVersion()) var reset = flag.Bool("reset", false, "force database reset") var upgrade = flag.Bool("upgrade", false, "perform database version upgrade") var datafile = flag.String("data", "", "name of file with sample data to load") @@ -198,6 +197,8 @@ func main() { err := store.Open(1, config.StoreConfig) defer store.Close() + log.Println("Initializing", store.GetAdapterName(), store.GetAdapterVersion()) + if err != nil { if strings.Contains(err.Error(), "Database not initialized") { log.Println("Database not found. Creating.") From 1f25f0ca6584e017b90aeff5d3d01115988458a6 Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 27 Mar 2020 13:01:26 +0300 Subject: [PATCH 081/142] log formatting --- tinode-db/main.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tinode-db/main.go b/tinode-db/main.go index 78091714c..56e701409 100644 --- a/tinode-db/main.go +++ b/tinode-db/main.go @@ -176,11 +176,11 @@ func main() { if *datafile != "" && *datafile != "-" { raw, err := ioutil.ReadFile(*datafile) if err != nil { - log.Fatal("Failed to read sample data file:", err) + log.Fatalln("Failed to read sample data file:", err) } err = json.Unmarshal(raw, &data) if err != nil { - log.Fatal("Failed to parse sample data:", err) + log.Fatalln("Failed to parse sample data:", err) } } @@ -189,9 +189,9 @@ func main() { var config configType if file, err := os.Open(*conffile); err != nil { - log.Fatal("Failed to read config file:", err) + log.Fatalln("Failed to read config file:", err) } else if err = json.NewDecoder(jcr.New(file)).Decode(&config); err != nil { - log.Fatal("Failed to parse config file:", err) + log.Fatalln("Failed to parse config file:", err) } err := store.Open(1, config.StoreConfig) @@ -206,14 +206,14 @@ func main() { msg := "Wrong DB version: expected " + strconv.Itoa(store.GetAdapterVersion()) + ", got " + strconv.Itoa(store.GetDbVersion()) + "." if *reset { - log.Println(msg + " Dropping and recreating the database.") + log.Println(msg, "Dropping and recreating the database.") } else if *upgrade { - log.Println(msg + " Upgrading the database.") + log.Println(msg, "Upgrading the database.") } else { - log.Fatal(msg + " Use --reset to reset, --upgrade to upgrade.") + log.Fatalln(msg, "Use --reset to reset, --upgrade to upgrade.") } } else { - log.Fatal("Failed to init DB adapter:", err) + log.Fatalln("Failed to init DB adapter:", err) } } else if *reset { log.Println("Database reset requested") @@ -238,18 +238,18 @@ func main() { } else { action = "initialized" } - log.Println("Database ", action) + log.Println("Database", action) } } if err != nil { - log.Fatal("Failed to init DB:", err) + log.Fatalln("Failed to init DB:", err) } if !*upgrade { genDb(&data) } else if len(data.Users) > 0 { - log.Println("Sample data was ignored. All done.") + log.Println("Sample data ignored. All done.") } os.Exit(0) } From 7c0c039d7bfa87a5f761af12ec8e0663fc77b900 Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 27 Mar 2020 19:03:28 +0300 Subject: [PATCH 082/142] add NO_DB_INIT param to init-db --- docker/docker-compose/cluster.yml | 2 ++ docker/tinode/Dockerfile | 13 ++++++++----- docker/tinode/entrypoint.sh | 8 +++++++- tinode-db/main.go | 6 +++++- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/docker/docker-compose/cluster.yml b/docker/docker-compose/cluster.yml index ac1b681c1..d48ac5b69 100644 --- a/docker/docker-compose/cluster.yml +++ b/docker/docker-compose/cluster.yml @@ -111,6 +111,7 @@ services: # Wait for tinode-0, not the database since # we let tinode-0 perform all database initialization and upgrade work. "WAIT_FOR": "tinode-0:18080" + "NO_DB_INIT": "true" tinode-2: << : *tinode-base @@ -130,6 +131,7 @@ services: # Wait for tinode-0, not the database since # we let tinode-0 perform all database initialization and upgrade work. "WAIT_FOR": "tinode-0:18080" + "NO_DB_INIT": "true" # Monitoring. # Exporters are paired with tinode instances. diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index 66eff6dd5..7d498c5a5 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -11,7 +11,7 @@ FROM alpine:latest -ARG VERSION=0.16.4 +ARG VERSION=0.16 ENV VERSION=$VERSION LABEL maintainer="Tinode Team " @@ -40,6 +40,9 @@ ENV RESET_DB=false # An option to upgrade database. ENV UPGRADE_DB=false +# Don't initialize database if it's missing +ENV NO_DB_INIT=false + # Load sample data to database from data.json. ARG SAMPLE_DATA=data.json ENV SAMPLE_DATA=$SAMPLE_DATA @@ -104,6 +107,10 @@ RUN apk update && \ WORKDIR /opt/tinode +# Copy config template to the container. +COPY config.template . +COPY entrypoint.sh . + # Get the desired Tinode build. ADD https://github.com/tinode/chat/releases/download/v$VERSION/tinode-$TARGET_DB.linux-amd64.tar.gz . @@ -111,10 +118,6 @@ ADD https://github.com/tinode/chat/releases/download/v$VERSION/tinode-$TARGET_DB RUN tar -xzf tinode-$TARGET_DB.linux-amd64.tar.gz \ && rm tinode-$TARGET_DB.linux-amd64.tar.gz -# Copy config template to the container. -COPY config.template . -COPY entrypoint.sh . - # Create directory for chatbot data. RUN mkdir /botdata diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index 143b2ffbd..a64dabbd6 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -101,7 +101,13 @@ fi # Initialize the database if it has not been initialized yet or if data reset/upgrade has been requested. init_stdout=./init-db-stdout.txt -./init-db --reset=${RESET_DB} --upgrade=${UPGRADE_DB} --config=${CONFIG} --data=${SAMPLE_DATA} 1>${init_stdout} +./init-db \ + --reset=${RESET_DB} \ + --upgrade=${UPGRADE_DB} \ + --config=${CONFIG} \ + --data=${SAMPLE_DATA} \ + --no_int=${NO_DB_INIT} + 1>${init_stdout} if [ $? -ne 0 ]; then echo "./init-db failed. Quitting." exit 1 diff --git a/tinode-db/main.go b/tinode-db/main.go index 56e701409..8ad153f0d 100644 --- a/tinode-db/main.go +++ b/tinode-db/main.go @@ -167,6 +167,7 @@ func getPassword(n int) string { func main() { var reset = flag.Bool("reset", false, "force database reset") var upgrade = flag.Bool("upgrade", false, "perform database version upgrade") + var noInit = flag.Bool("no_init", false, "check that database exists but don't create if missing") var datafile = flag.String("data", "", "name of file with sample data to load") var conffile = flag.String("config", "./tinode.conf", "config of the database connection") @@ -197,10 +198,13 @@ func main() { err := store.Open(1, config.StoreConfig) defer store.Close() - log.Println("Initializing", store.GetAdapterName(), store.GetAdapterVersion()) + log.Println("Database", store.GetAdapterName(), store.GetAdapterVersion()) if err != nil { if strings.Contains(err.Error(), "Database not initialized") { + if *noInit { + log.Fatalln("Database not found.") + } log.Println("Database not found. Creating.") } else if strings.Contains(err.Error(), "Invalid database version") { msg := "Wrong DB version: expected " + strconv.Itoa(store.GetAdapterVersion()) + ", got " + From 926b60fd816ed10c3cfcfce84c4cd21b1b9881d2 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 28 Mar 2020 08:54:29 +0300 Subject: [PATCH 083/142] use [ syntax for consistency --- docker/tinode/Dockerfile | 2 +- docker/tinode/entrypoint.sh | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index 7d498c5a5..75be85cf6 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -40,7 +40,7 @@ ENV RESET_DB=false # An option to upgrade database. ENV UPGRADE_DB=false -# Don't initialize database if it's missing +# Option to skip DB initialization when it's missing. ENV NO_DB_INIT=false # Load sample data to database from data.json. diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index a64dabbd6..8b3043273 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -15,6 +15,11 @@ else # Remove the old config. rm -f working.config + # The 'alldbs' is not a valid adapter name. + if [ "$TARGET_DB" = "alldbs" ] ; then + TARGET_DB= + fi + # Enable email verification if $SMTP_SERVER is defined. if [ ! -z "$SMTP_SERVER" ] ; then EMAIL_VERIFICATION_REQUIRED='"auth"' @@ -50,7 +55,7 @@ else fi # Do not load data when upgrading database. -if [[ "$UPGRADE_DB" = "true" ]] ; then +if [ "$UPGRADE_DB" = "true" ] ; then SAMPLE_DATA= fi From d4cf94803c9f8d824d019a7d0bd18367149dab12 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 28 Mar 2020 10:02:46 +0300 Subject: [PATCH 084/142] more PEP 440 compliance in python build script --- py_grpc/version.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/py_grpc/version.py b/py_grpc/version.py index 6602eb3e1..93f4f8939 100644 --- a/py_grpc/version.py +++ b/py_grpc/version.py @@ -10,6 +10,10 @@ def git_version(): line = line[1:] if '-rc' in line: line = line.replace('-rc', 'rc') + if '-beta' in line: + line = line.replace('-beta', 'b') + if '-alpha' in line: + line = line.replace('-alpha', 'a') if '-' in line: parts = line.split('-') line = parts[0] + '.post' + parts[1] From 55bcb0363fb5b80c0936d415e1f37fae3136b6c2 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 28 Mar 2020 10:49:57 +0300 Subject: [PATCH 085/142] typo in docker --- docker/tinode/entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index 8b3043273..99c0d067c 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -111,7 +111,7 @@ init_stdout=./init-db-stdout.txt --upgrade=${UPGRADE_DB} \ --config=${CONFIG} \ --data=${SAMPLE_DATA} \ - --no_int=${NO_DB_INIT} + --no_init=${NO_DB_INIT} 1>${init_stdout} if [ $? -ne 0 ]; then echo "./init-db failed. Quitting." From 09fe46fe31937b8fcc6e7cfd674546c6111b2c93 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 28 Mar 2020 12:24:12 +0300 Subject: [PATCH 086/142] docker hub http api is just unusable --- docker-release.sh | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/docker-release.sh b/docker-release.sh index 09a0be3d7..c36e62e31 100755 --- a/docker-release.sh +++ b/docker-release.sh @@ -39,39 +39,6 @@ source .dockerhub # Login to docker hub docker login -u $user -p $pass -# Remove earlier builds -for dbtag in "${dbtags[@]}" -do - name="$(containerName $dbtag)" - if [ -n "$FULLRELEASE" ]; then - curl -u $user:$pass -i -X DELETE \ - https://hub.docker.com/v2/repositories/tinode/${name}/tags/latest/ - - curl -u $user:$pass -i -X DELETE \ - https://hub.docker.com/v2/repositories/tinode/${name}/tags/${ver[0]}.${ver[1]}/ - fi - curl -u $user:$pass -i -X DELETE \ - https://hub.docker.com/v2/repositories/tinode/${name}/tags/${ver[0]}.${ver[1]}.${ver[2]}/ -done - -if [ -n "$FULLRELEASE" ]; then - curl -u $user:$pass -i -X DELETE \ - https://hub.docker.com/v2/repositories/tinode/chatbot/tags/latest/ - curl -u $user:$pass -i -X DELETE \ - https://hub.docker.com/v2/repositories/tinode/chatbot/tags/${ver[0]}.${ver[1]}/ -fi -curl -u $user:$pass -i -X DELETE \ - https://hub.docker.com/v2/repositories/tinode/chatbot/tags/${ver[0]}.${ver[1]}.${ver[2]}/ - -if [ -n "$FULLRELEASE" ]; then - curl -u $user:$pass -i -X DELETE \ - https://hub.docker.com/v2/repositories/tinode/exporter/tags/latest/ - curl -u $user:$pass -i -X DELETE \ - https://hub.docker.com/v2/repositories/tinode/exporter/tags/${ver[0]}.${ver[1]}/ -fi -curl -u $user:$pass -i -X DELETE \ - https://hub.docker.com/v2/repositories/tinode/exporter/tags/${ver[0]}.${ver[1]}.${ver[2]}/ - # Deploy images for various DB backends for dbtag in "${dbtags[@]}" do From 23106c2f34feb8f990aec1526773ead0535160c8 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 28 Mar 2020 12:25:35 +0300 Subject: [PATCH 087/142] switch back to using port 6060 --- docker/README.md | 10 +++++----- docker/docker-compose/cluster.yml | 16 ++++++++-------- docker/tinode/Dockerfile | 15 ++++++++------- docker/tinode/config.template | 10 +++++----- 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/docker/README.md b/docker/README.md index 03aac9f31..8281dac3a 100644 --- a/docker/README.md +++ b/docker/README.md @@ -40,17 +40,17 @@ All images are available at https://hub.docker.com/r/tinode/ 1. **RethinkDB**: ``` - $ docker run -p 6060:18080 -d --name tinode-srv --network tinode-net tinode/tinode-rethinkdb:latest + $ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net tinode/tinode-rethinkdb:latest ``` 2. **MySQL**: ``` - $ docker run -p 6060:18080 -d --name tinode-srv --network tinode-net tinode/tinode-mysql:latest + $ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net tinode/tinode-mysql:latest ``` 3. **MongoDB**: ``` - $ docker run -p 6060:18080 -d --name tinode-srv --network tinode-net tinode/tinode-mongodb:latest + $ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net tinode/tinode-mongodb:latest ``` You can also run Tinode with the `tinode/tinode` image (which has all of the above DB adapters compiled in). You will need to specify the database adapter via `STORE_USE_ADAPTER` environment variable. E.g. for `mysql`, the command line will look like @@ -76,7 +76,7 @@ All images are available at https://hub.docker.com/r/tinode/ The container comes with a built-in config file which can be customized with values from the environment variables (see [Supported environment variables](#supported_environment_variables) below). If changes are extensive it may be more convenient to replace the built-in config file with a custom one. In that case map the config file located on your host (e.g. `/users/jdoe/new_tinode.conf`) to container (e.g. `/tinode.conf`) using [Docker volumes](https://docs.docker.com/storage/volumes/) `--volume /users/jdoe/new_tinode.conf:/tinode.conf` then instruct the container to use the new config `--env EXT_CONFIG=/tinode.conf`: ``` -$ docker run -p 6060:18080 -d --name tinode-srv --network tinode-net \ +$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net \ --volume /users/jdoe/new_tinode.conf:/tinode.conf \ --env EXT_CONFIG=/tinode.conf \ tinode/tinode-mysql:latest @@ -109,7 +109,7 @@ Project ID `myproject-1234`, App ID `1:141421356237:web:abc7de1234fab56cd78abc`, is `83_Or_So_Random_Looking_Characters`, start the container with the following parameters (using MySQL container as an example): ``` -$ docker run -p 6060:18080 -d --name tinode-srv --network tinode-net \ +$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net \ -v /Users/jdoe:/fcm \ --env FCM_CRED_FILE=/fcm/myproject-1234-firebase-adminsdk-abc12-abcdef012345.json \ --env FCM_API_KEY=AIRaNdOmX4ULR-X6ranDomzZ2bHdRanDomq2tbQ \ diff --git a/docker/docker-compose/cluster.yml b/docker/docker-compose/cluster.yml index d48ac5b69..ee6ae8bee 100644 --- a/docker/docker-compose/cluster.yml +++ b/docker/docker-compose/cluster.yml @@ -86,7 +86,7 @@ services: # # Logs directory. # - :/var/log ports: - - "6060:18080" + - "6060:6060" environment: << : *tinode-env-vars "CLUSTER_SELF": "tinode-0" @@ -104,13 +104,13 @@ services: # # Logs directory. # - :/var/log ports: - - "6061:18080" + - "6061:6060" environment: << : *tinode-env-vars "CLUSTER_SELF": "tinode-1" # Wait for tinode-0, not the database since # we let tinode-0 perform all database initialization and upgrade work. - "WAIT_FOR": "tinode-0:18080" + "WAIT_FOR": "tinode-0:6060" "NO_DB_INIT": "true" tinode-2: @@ -124,13 +124,13 @@ services: # # Logs directory. # - :/var/log ports: - - "6062:18080" + - "6062:6060" environment: << : *tinode-env-vars "CLUSTER_SELF": "tinode-2" # Wait for tinode-0, not the database since # we let tinode-0 perform all database initialization and upgrade work. - "WAIT_FOR": "tinode-0:18080" + "WAIT_FOR": "tinode-0:6060" "NO_DB_INIT": "true" # Monitoring. @@ -148,7 +148,7 @@ services: environment: << : *exporter-env-vars "INSTANCE": "tinode-0" - "WAIT_FOR": "tinode-0:18080" + "WAIT_FOR": "tinode-0:6060" exporter-1: << : *exporter-base @@ -163,7 +163,7 @@ services: environment: << : *exporter-env-vars "INSTANCE": "tinode-1" - "WAIT_FOR": "tinode-1:18080" + "WAIT_FOR": "tinode-1:6060" exporter-2: << : *exporter-base @@ -178,4 +178,4 @@ services: environment: << : *exporter-env-vars "INSTANCE": "tinode-2" - "WAIT_FOR": "tinode-2:18080" + "WAIT_FOR": "tinode-2:6060" diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index 75be85cf6..a3228d81f 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -1,9 +1,9 @@ # Docker file builds an image with a tinode chat server. -# The server exposes port 18080. -# In order to run the image you have to link it to a running RethinkDB container -# (assuming it's named 'rethinkdb') and map the port where the tinode server accepts connections: # -# $ docker run -p 6060:18080 -d --link rethinkdb \ +# In order to run the image you have to link it to a running database container. For example, to +# to use RethinkDB (named 'rethinkdb') and map the port where the tinode server accepts connections: +# +# $ docker run -p 6060:6060 -d --link rethinkdb \ # --env UID_ENCRYPTION_KEY=base64+encoded+16+bytes= \ # --env API_KEY_SALT=base64+encoded+32+bytes \ # --env AUTH_TOKEN_KEY=base64+encoded+32+bytes \ @@ -13,6 +13,7 @@ FROM alpine:latest ARG VERSION=0.16 ENV VERSION=$VERSION +ARG BINVERS=$VERSION LABEL maintainer="Tinode Team " LABEL name="TinodeChatServer" @@ -112,7 +113,7 @@ COPY config.template . COPY entrypoint.sh . # Get the desired Tinode build. -ADD https://github.com/tinode/chat/releases/download/v$VERSION/tinode-$TARGET_DB.linux-amd64.tar.gz . +ADD https://github.com/tinode/chat/releases/download/v$BINVERS/tinode-$TARGET_DB.linux-amd64.tar.gz . # Unpack the Tinode archive. RUN tar -xzf tinode-$TARGET_DB.linux-amd64.tar.gz \ @@ -128,5 +129,5 @@ RUN chmod +x credentials.sh # Generate config from template and run the server. ENTRYPOINT ./entrypoint.sh -# HTTP and gRPC ports -EXPOSE 18080 16061 +# HTTP, gRPC, cluster ports +EXPOSE 6060 16060 12000-12002 diff --git a/docker/tinode/config.template b/docker/tinode/config.template index 826c93375..76c66535c 100644 --- a/docker/tinode/config.template +++ b/docker/tinode/config.template @@ -1,9 +1,9 @@ { - "listen": ":18080", + "listen": ":6060", "api_path": "/", "cache_control": 39600, "static_mount": "/", - "grpc_listen": ":16061", + "grpc_listen": ":16060", "api_key_salt": "$API_KEY_SALT", "max_message_size": 4194304, "max_subscriber_count": 32, @@ -145,9 +145,9 @@ "self": "", "nodes": [ // Name and TCP address of each node. - {"name": "tinode-0", "addr": "tinode-0:12001"}, - {"name": "tinode-1", "addr": "tinode-1:12002"}, - {"name": "tinode-2", "addr": "tinode-2:12003"} + {"name": "tinode-0", "addr": "tinode-0:12000"}, + {"name": "tinode-1", "addr": "tinode-1:12001"}, + {"name": "tinode-2", "addr": "tinode-2:12002"} ], "failover": { "enabled": true, From 075c90d492fe967880994f3d08296da8b5828d86 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 28 Mar 2020 12:29:58 +0300 Subject: [PATCH 088/142] more port updates in various places --- chatbot/python/chatbot.py | 2 +- docker/chatbot/Dockerfile | 2 +- docker/docker-compose/single-instance.yml | 6 +++--- docker/tinode/Dockerfile | 2 +- server/tinode.conf | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/chatbot/python/chatbot.py b/chatbot/python/chatbot.py index 4be5cb7fc..4db684e35 100644 --- a/chatbot/python/chatbot.py +++ b/chatbot/python/chatbot.py @@ -349,7 +349,7 @@ def exit_gracefully(signo, stack_frame): purpose = "Tino, Tinode's chatbot." print(purpose) parser = argparse.ArgumentParser(description=purpose) - parser.add_argument('--host', default='localhost:6061', help='address of Tinode server gRPC endpoint') + parser.add_argument('--host', default='localhost:16060', help='address of Tinode server gRPC endpoint') parser.add_argument('--ssl', action='store_true', help='use SSL to connect to the server') parser.add_argument('--ssl-host', help='SSL host name to use instead of default (useful for connecting to localhost)') parser.add_argument('--listen', default='0.0.0.0:40051', help='address to listen on for incoming Plugin API calls') diff --git a/docker/chatbot/Dockerfile b/docker/chatbot/Dockerfile index 93b57cbc7..7b84395be 100644 --- a/docker/chatbot/Dockerfile +++ b/docker/chatbot/Dockerfile @@ -4,7 +4,7 @@ FROM python:3.7-slim ARG VERSION=0.16 ARG LOGIN_AS= -ARG TINODE_HOST=tinode-srv:6061 +ARG TINODE_HOST=tinode-srv:16060 ENV VERSION=$VERSION ARG BINVERS=$VERSION diff --git a/docker/docker-compose/single-instance.yml b/docker/docker-compose/single-instance.yml index e18a9d873..0245482ee 100644 --- a/docker/docker-compose/single-instance.yml +++ b/docker/docker-compose/single-instance.yml @@ -41,7 +41,7 @@ x-tinode-env-vars: &tinode-env-vars # "IOS_UNIV_LINKS_APP_ID": "" x-exporter-env-vars: &exporter-env-vars - "TINODE_ADDR": "http://tinode.host:18080/stats/expvar/" + "TINODE_ADDR": "http://tinode.host:6060/stats/expvar/" # InfluxDB configation: "SERVE_FOR": "influxdb" "INFLUXDB_VERSION": 1.7 @@ -81,7 +81,7 @@ services: # # Logs directory. # - :/var/log ports: - - "6060:18080" + - "6060:6060" environment: << : *tinode-env-vars "RESET_DB": ${RESET_DB:-false} @@ -102,4 +102,4 @@ services: - tinode-0:tinode.host environment: << : *exporter-env-vars - "WAIT_FOR": "tinode-0:18080" + "WAIT_FOR": "tinode-0:6060" diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index a3228d81f..147b33c2b 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -130,4 +130,4 @@ RUN chmod +x credentials.sh ENTRYPOINT ./entrypoint.sh # HTTP, gRPC, cluster ports -EXPOSE 6060 16060 12000-12002 +EXPOSE 6060 16060 12000-12003 diff --git a/server/tinode.conf b/server/tinode.conf index 566a23fb3..f3f8fa696 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -21,7 +21,7 @@ // TCP host:port or unix:/path/to/socket to listen for gRPC clients. // Leave blank to disable gRPC support. // Could be overridden from the command line with --grpc_listen. - "grpc_listen": ":6061", + "grpc_listen": ":16060", // Enable handling of gRPC keepalives https://github.com/grpc/grpc/blob/master/doc/keepalive.md // This sets server's GRPC_ARG_KEEPALIVE_TIME_MS to 60 seconds instead of the default 2 hours. From 246786469ab78c4f31450dd1444019f4e0d124ed Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 28 Mar 2020 13:26:28 +0300 Subject: [PATCH 089/142] missing backslash --- docker/tinode/entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index 99c0d067c..0fd201dbb 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -111,7 +111,7 @@ init_stdout=./init-db-stdout.txt --upgrade=${UPGRADE_DB} \ --config=${CONFIG} \ --data=${SAMPLE_DATA} \ - --no_init=${NO_DB_INIT} + --no_init=${NO_DB_INIT} \ 1>${init_stdout} if [ $? -ne 0 ]; then echo "./init-db failed. Quitting." From 866d51f31dfea9b4978ea385bb6ce21fe1e64496 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 28 Mar 2020 14:50:26 +0300 Subject: [PATCH 090/142] keep chat log beteen restarts --- docker/chatbot/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/chatbot/Dockerfile b/docker/chatbot/Dockerfile index 7b84395be..bb8c5111b 100644 --- a/docker/chatbot/Dockerfile +++ b/docker/chatbot/Dockerfile @@ -29,7 +29,7 @@ RUN pip install --no-cache-dir -r requirements.txt # Use docker's command line parameter `-e LOGIN_AS=user:password` to login as someone other than Tino. -CMD python chatbot.py --login-basic=${LOGIN_AS} --login-cookie=/botdata/.tn-cookie --host=${TINODE_HOST} > /var/log/chatbot.log +CMD python chatbot.py --login-basic=${LOGIN_AS} --login-cookie=/botdata/.tn-cookie --host=${TINODE_HOST} >> /var/log/chatbot.log # Plugin port EXPOSE 40051 From 96983760d2d0cd3e51efddb84bb9a5708b541d71 Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 29 Mar 2020 10:30:29 +0300 Subject: [PATCH 091/142] update port references --- docker/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/README.md b/docker/README.md index 8281dac3a..7436e3900 100644 --- a/docker/README.md +++ b/docker/README.md @@ -55,12 +55,12 @@ All images are available at https://hub.docker.com/r/tinode/ You can also run Tinode with the `tinode/tinode` image (which has all of the above DB adapters compiled in). You will need to specify the database adapter via `STORE_USE_ADAPTER` environment variable. E.g. for `mysql`, the command line will look like ``` - $ docker run -p 6060:18080 -d -e STORE_USE_ADAPTER mysql --name tinode-srv --network tinode-net tinode/tinode:latest + $ docker run -p 6060:6060 -d -e STORE_USE_ADAPTER mysql --name tinode-srv --network tinode-net tinode/tinode:latest ``` See [below](#supported-environment-variables) for more options. - The port mapping `-p 6060:18080` tells Docker to map container's port 18080 to host's port 6060 making server accessible at http://localhost:6060/. The container will initialize the database with test data on the first run. + The port mapping `-p 5678:1234` tells Docker to map container's port 1234 to host's port 5678 making server accessible at http://localhost:5678/. The container will initialize the database with test data on the first run. You may replace `:latest` with a different tag. See all all available tags here: * [MySQL tags](https://hub.docker.com/r/tinode/tinode-mysql/tags/) From 415014f0db5909113f04d99d0414120dd19bb46a Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 29 Mar 2020 12:50:52 +0300 Subject: [PATCH 092/142] fix chatbot failure to corretly connect to cluster --- chatbot/python/chatbot.py | 54 ++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/chatbot/python/chatbot.py b/chatbot/python/chatbot.py index 4db684e35..211153030 100644 --- a/chatbot/python/chatbot.py +++ b/chatbot/python/chatbot.py @@ -20,15 +20,19 @@ import time import grpc +from google.protobuf.json_format import MessageToDict # Import generated grpc modules from tinode_grpc import pb from tinode_grpc import pbx APP_NAME = "Tino-chatbot" -APP_VERSION = "1.1.3" +APP_VERSION = "1.2.0" LIB_VERSION = pkg_resources.get_distribution("tinode_grpc").version +# Maximum length of string to log. Shorten longer strings. +MAX_LOG_LEN = 64 + # User ID of the current user botUID = None @@ -42,16 +46,29 @@ def add_future(tid, bundle): onCompletion[tid] = bundle +# Shorten long strings for logging. +class StrLogger(json.JSONEncoder): + def default(self, obj): + if type(obj) == str and len(obj) > MAX_LOG_LEN: + return '<' + len(obj) + ', bytes: ' + obj[:12] + '...' + obj[-12:] + '>' + return super(StrLogger, self).default(obj) + # Resolve or reject the future def exec_future(tid, code, text, params): bundle = onCompletion.get(tid) if bundle != None: del onCompletion[tid] - if code >= 200 and code < 400: - arg = bundle.get('arg') - bundle.get('action')(arg, params) - else: - print("Error:", code, text) + try: + if code >= 200 and code < 400: + arg = bundle.get('arg') + bundle.get('onsuccess')(arg, params) + else: + print("Error: {} {} ({})".format(code, text, tid)) + onerror = bundle.get('onerror') + if onerror: + onerror(bundle.get('arg'), {'code': code, 'text': text}) + except Exception as err: + print("Error handling server response", err) # List of active subscriptions subscriptions = {} @@ -61,6 +78,16 @@ def add_subscription(topic): def del_subscription(topic): subscriptions.pop(topic, None) +def subscription_failed(topic, errcode): + if topic == 'me': + # Failed 'me' subscription means the bot is disfunctional. Break the loop and retry in a few seconds. + client_post(None) + +def login_error(unused, errcode): + # Check for 409 "already authenticated". + if errcode.get('code') != 409: + exit(1) + def server_version(params): if params == None: return @@ -109,7 +136,7 @@ def client_generate(): msg = queue_out.get() if msg == None: return - # print("out:", msg) + print("out: ", json.dumps(MessageToDict(msg), cls=StrLogger)) yield msg def client_post(msg): @@ -126,7 +153,7 @@ def client_reset(): def hello(): tid = next_id() add_future(tid, { - 'action': lambda unused, params: server_version(params), + 'onsuccess': lambda unused, params: server_version(params), }) return pb.ClientMsg(hi=pb.ClientHi(id=tid, user_agent=APP_NAME + "/" + APP_VERSION + " (" + platform.system() + "/" + platform.release() + "); gRPC-python/" + LIB_VERSION, @@ -136,7 +163,8 @@ def login(cookie_file_name, scheme, secret): tid = next_id() add_future(tid, { 'arg': cookie_file_name, - 'action': lambda fname, params: on_login(fname, params), + 'onsuccess': lambda fname, params: on_login(fname, params), + 'onerror': lambda unused, errcode: login_error(unused, errcode), }) return pb.ClientMsg(login=pb.ClientLogin(id=tid, scheme=scheme, secret=secret)) @@ -144,7 +172,8 @@ def subscribe(topic): tid = next_id() add_future(tid, { 'arg': topic, - 'action': lambda topicName, unused: add_subscription(topicName), + 'onsuccess': lambda topicName, unused: add_subscription(topicName), + 'onerror': lambda topicName, errcode: subscription_failed(topicName, errcode), }) return pb.ClientMsg(sub=pb.ClientSub(id=tid, topic=topic)) @@ -152,7 +181,7 @@ def leave(topic): tid = next_id() add_future(tid, { 'arg': topic, - 'action': lambda topicName, unused: del_subscription(topicName) + 'onsuccess': lambda topicName, unused: del_subscription(topicName) }) return pb.ClientMsg(leave=pb.ClientLeave(id=tid, topic=topic)) @@ -200,7 +229,8 @@ def client_message_loop(stream): try: # Read server responses for msg in stream: - # print("in:", msg) + print("in: ", json.dumps(MessageToDict(msg), cls=StrLogger)) + if msg.HasField("ctrl"): # Run code on command completion exec_future(msg.ctrl.id, msg.ctrl.code, msg.ctrl.text, msg.ctrl.params) From 2314ccf1719afa85001dc6be8b07f12cb8d6657b Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 29 Mar 2020 13:56:13 +0300 Subject: [PATCH 093/142] check for cluster unreachable error --- chatbot/python/chatbot.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/chatbot/python/chatbot.py b/chatbot/python/chatbot.py index 211153030..243a2c832 100644 --- a/chatbot/python/chatbot.py +++ b/chatbot/python/chatbot.py @@ -80,8 +80,12 @@ def del_subscription(topic): def subscription_failed(topic, errcode): if topic == 'me': - # Failed 'me' subscription means the bot is disfunctional. Break the loop and retry in a few seconds. - client_post(None) + # Failed 'me' subscription means the bot is disfunctional. + if errcode.get('code') == 502: + # Cluster unreachable. Break the loop and retry in a few seconds. + client_post(None) + else: + exit(1) def login_error(unused, errcode): # Check for 409 "already authenticated". From 590e294712f19095febb7a2a33cf55cbaceb848d Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 29 Mar 2020 14:03:56 +0300 Subject: [PATCH 094/142] update grpc port references 6061 -> 16060 --- tn-cli/README.md | 2 +- tn-cli/tn-cli.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tn-cli/README.md b/tn-cli/README.md index d296a6782..016a88d3a 100644 --- a/tn-cli/README.md +++ b/tn-cli/README.md @@ -22,7 +22,7 @@ where `X.XX.XX` is the version number which must match the server version number The client takes optional parameters: - * `--host` is the address of the gRPC server to connect to; default `localhost:6061`. + * `--host` is the address of the gRPC server to connect to; default `localhost:16060`. * `--web-host` is the address of Tinode web server, used for file uploads only; default `localhost:6060`. * `--ssl` the server requires a secure connection (SSL) * `--ssl-host` the domain name to use for SNI if different from the `--host` domain name. diff --git a/tn-cli/tn-cli.py b/tn-cli/tn-cli.py index 6592db142..2bda8bc3a 100644 --- a/tn-cli/tn-cli.py +++ b/tn-cli/tn-cli.py @@ -1032,8 +1032,8 @@ def print_server_params(params): purpose = "Tinode command line client. Version " + version + "." parser = argparse.ArgumentParser(description=purpose) - parser.add_argument('--host', default='localhost:6061', help='address of Tinode gRPC server') - parser.add_argument('--web-host', default='localhost:6060', help='address of Tinode web server') + parser.add_argument('--host', default='localhost:16060', help='address of Tinode gRPC server') + parser.add_argument('--web-host', default='localhost:6060', help='address of Tinode web server (for file uploads)') parser.add_argument('--ssl', action='store_true', help='connect to server over secure connection') parser.add_argument('--ssl-host', help='SSL host name to use instead of default (useful for connecting to localhost)') parser.add_argument('--login-basic', help='login using basic authentication username:password') From 679d4efebde283e7087ad18492a7f1166559107e Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 29 Mar 2020 16:45:05 +0300 Subject: [PATCH 095/142] add timestamp to logs --- chatbot/python/chatbot.py | 49 +++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/chatbot/python/chatbot.py b/chatbot/python/chatbot.py index 243a2c832..d70548fb4 100644 --- a/chatbot/python/chatbot.py +++ b/chatbot/python/chatbot.py @@ -6,6 +6,7 @@ import argparse import base64 from concurrent import futures +from datetime import datetime import json import os import pkg_resources @@ -42,16 +43,19 @@ # This is needed for gRPC ssl to work correctly. os.environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA" +def log(*args): + print(datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], *args) + # Add bundle for future execution def add_future(tid, bundle): onCompletion[tid] = bundle # Shorten long strings for logging. -class StrLogger(json.JSONEncoder): +class JsonHelper(json.JSONEncoder): def default(self, obj): if type(obj) == str and len(obj) > MAX_LOG_LEN: return '<' + len(obj) + ', bytes: ' + obj[:12] + '...' + obj[-12:] + '>' - return super(StrLogger, self).default(obj) + return super(JsonHelper, self).default(obj) # Resolve or reject the future def exec_future(tid, code, text, params): @@ -63,12 +67,12 @@ def exec_future(tid, code, text, params): arg = bundle.get('arg') bundle.get('onsuccess')(arg, params) else: - print("Error: {} {} ({})".format(code, text, tid)) + log("Error: {} {} ({})".format(code, text, tid)) onerror = bundle.get('onerror') if onerror: onerror(bundle.get('arg'), {'code': code, 'text': text}) except Exception as err: - print("Error handling server response", err) + log("Error handling server response", err) # List of active subscriptions subscriptions = {} @@ -95,7 +99,7 @@ def login_error(unused, errcode): def server_version(params): if params == None: return - print("Server:", params['build'].decode('ascii'), params['ver'].decode('ascii')) + log("Server:", params['build'].decode('ascii'), params['ver'].decode('ascii')) def next_id(): next_id.tid += 1 @@ -129,7 +133,7 @@ def Account(self, acc_event, context): else: action = "unknown" - print("Account", action, ":", acc_event.user_id, acc_event.public) + log("Account", action, ":", acc_event.user_id, acc_event.public) return pb.Unused() @@ -140,7 +144,7 @@ def client_generate(): msg = queue_out.get() if msg == None: return - print("out: ", json.dumps(MessageToDict(msg), cls=StrLogger)) + log("out:", json.dumps(MessageToDict(msg), cls=JsonHelper)) yield msg def client_post(msg): @@ -204,12 +208,12 @@ def init_server(listen): server.add_insecure_port(listen) server.start() - print("Plugin server running at '"+listen+"'") + log("Plugin server running at '"+listen+"'") return server def init_client(addr, schema, secret, cookie_file_name, secure, ssl_host): - print("Connecting to", "secure" if secure else "", "server at", addr, + log("Connecting to", "secure" if secure else "", "server at", addr, "SNI="+ssl_host if ssl_host else "") channel = None @@ -233,14 +237,15 @@ def client_message_loop(stream): try: # Read server responses for msg in stream: - print("in: ", json.dumps(MessageToDict(msg), cls=StrLogger)) + log(datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], + "in:", json.dumps(MessageToDict(msg), cls=JsonHelper)) if msg.HasField("ctrl"): # Run code on command completion exec_future(msg.ctrl.id, msg.ctrl.code, msg.ctrl.text, msg.ctrl.params) elif msg.HasField("data"): - # print("message from:", msg.data.from_user_id) + # log("message from:", msg.data.from_user_id) # Protection against the bot talking to self from another session. if msg.data.from_user_id != botUID: @@ -253,7 +258,7 @@ def client_message_loop(stream): client_post(publish(msg.data.topic, next_quote())) elif msg.HasField("pres"): - # print("presence:", msg.pres.topic, msg.pres.what) + # log("presence:", msg.pres.topic, msg.pres.what) # Wait for peers to appear online and subscribe to their topics if msg.pres.topic == 'me': if (msg.pres.what == pb.ServerPres.ON or msg.pres.what == pb.ServerPres.MSG) \ @@ -267,7 +272,7 @@ def client_message_loop(stream): pass except grpc._channel._Rendezvous as err: - print("Disconnected:", err) + log("Disconnected:", err) def read_auth_cookie(cookie_file_name): """Read authentication token from a file""" @@ -306,7 +311,7 @@ def on_login(cookie_file_name, params): json.dump(nice, cookie) cookie.close() except Exception as err: - print("Failed to save authentication cookie", err) + log("Failed to save authentication cookie", err) def load_quotes(file_name): with open(file_name) as f: @@ -323,25 +328,25 @@ def run(args): """Use token to login""" schema = 'token' secret = args.login_token.encode('acsii') - print("Logging in with token", args.login_token) + log("Logging in with token", args.login_token) elif args.login_basic: """Use username:password""" schema = 'basic' secret = args.login_basic.encode('utf-8') - print("Logging in with login:password", args.login_basic) + log("Logging in with login:password", args.login_basic) else: """Try reading the cookie file""" try: schema, secret = read_auth_cookie(args.login_cookie) - print("Logging in with cookie file", args.login_cookie) + log("Logging in with cookie file", args.login_cookie) except Exception as err: - print("Failed to read authentication cookie", err) + log("Failed to read authentication cookie", err) if schema: # Load random quotes from file - print("Loaded {} quotes".format(load_quotes(args.quotes))) + log("Loaded {} quotes".format(load_quotes(args.quotes))) # Start Plugin server server = init_server(args.listen) @@ -351,7 +356,7 @@ def run(args): # Setup closure for graceful termination def exit_gracefully(signo, stack_frame): - print("Terminated with signal", signo) + log("Terminated with signal", signo) server.stop(0) client.cancel() sys.exit(0) @@ -373,7 +378,7 @@ def exit_gracefully(signo, stack_frame): client.cancel() else: - print("Error: authentication scheme not defined") + log("Error: authentication scheme not defined") if __name__ == '__main__': @@ -381,7 +386,7 @@ def exit_gracefully(signo, stack_frame): random.seed() purpose = "Tino, Tinode's chatbot." - print(purpose) + log(purpose) parser = argparse.ArgumentParser(description=purpose) parser.add_argument('--host', default='localhost:16060', help='address of Tinode server gRPC endpoint') parser.add_argument('--ssl', action='store_true', help='use SSL to connect to the server') From 774980a860e989e1238f63c1493dbd4cc3578f39 Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 29 Mar 2020 16:46:06 +0300 Subject: [PATCH 096/142] fix for premature serialization of cluster messages --- server/cluster.go | 10 +++++----- server/session.go | 6 ++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/server/cluster.go b/server/cluster.go index b167f6285..08430eb00 100644 --- a/server/cluster.go +++ b/server/cluster.go @@ -131,7 +131,8 @@ type ClusterReq struct { // ClusterResp is a Master to Proxy response message. type ClusterResp struct { - Msg []byte + // Server message with the response. + SrvMsg *ServerComMessage // Session ID to forward message to, if any. FromSID string } @@ -392,7 +393,7 @@ func (Cluster) Proxy(msg *ClusterResp, unused *bool) error { // This cluster member received a response from topic owner to be forwarded to a session // Find appropriate session, send the message to it if sess := globals.sessionStore.Get(msg.FromSID); sess != nil { - if !sess.queueOutBytes(msg.Msg) { + if !sess.queueOut(msg.SrvMsg) { log.Println("cluster.Proxy: timeout") } } else { @@ -758,15 +759,14 @@ func (sess *Session) rpcWriteLoop() { } // The error is returned if the remote node is down. Which means the remote // session is also disconnected. - if err := sess.clnode.respond(&ClusterResp{Msg: msg.([]byte), FromSID: sess.sid}); err != nil { - + if err := sess.clnode.respond(&ClusterResp{SrvMsg: msg.(*ServerComMessage), FromSID: sess.sid}); err != nil { log.Println("cluster sess.writeRPC: " + err.Error()) return } case msg := <-sess.stop: // Shutdown is requested, don't care if the message is delivered if msg != nil { - sess.clnode.respond(&ClusterResp{Msg: msg.([]byte), FromSID: sess.sid}) + sess.clnode.respond(&ClusterResp{SrvMsg: msg.(*ServerComMessage), FromSID: sess.sid}) } return diff --git a/server/session.go b/server/session.go index 6f5dc3bc1..42169ab0a 100644 --- a/server/session.go +++ b/server/session.go @@ -1062,6 +1062,12 @@ func (s *Session) serialize(msg *ServerComMessage) interface{} { if s.proto == GRPC { return pbServSerialize(msg) } + + if s.proto == CLUSTER { + // No need to serialize the message to bytes within the cluster. + return msg + } + out, _ := json.Marshal(msg) return out } From 655d4c9f9e5842f79f6693b29d1ab155a1c46e3d Mon Sep 17 00:00:00 2001 From: or-else Date: Mon, 30 Mar 2020 12:17:31 +0300 Subject: [PATCH 097/142] add gob mappings --- server/cluster.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/cluster.go b/server/cluster.go index 08430eb00..2f4e70071 100644 --- a/server/cluster.go +++ b/server/cluster.go @@ -699,6 +699,8 @@ func clusterInit(configString json.RawMessage, self *string) int { gob.Register([]interface{}{}) gob.Register(map[string]interface{}{}) + gob.Register(map[string]int) + gob.Register(map[string]string) globals.cluster = &Cluster{ thisNodeName: thisName, @@ -760,7 +762,7 @@ func (sess *Session) rpcWriteLoop() { // The error is returned if the remote node is down. Which means the remote // session is also disconnected. if err := sess.clnode.respond(&ClusterResp{SrvMsg: msg.(*ServerComMessage), FromSID: sess.sid}); err != nil { - log.Println("cluster sess.writeRPC: " + err.Error()) + log.Println("cluster: sess.writeRPC: " + err.Error()) return } case msg := <-sess.stop: From 445027106529f3d3a8bcdfaeac13c310fb6e2180 Mon Sep 17 00:00:00 2001 From: or-else Date: Mon, 30 Mar 2020 12:18:11 +0300 Subject: [PATCH 098/142] race condition in creating a bucket in a cluster --- server/media/s3/s3.go | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/server/media/s3/s3.go b/server/media/s3/s3.go index f96e96f0b..2d27d8dd3 100644 --- a/server/media/s3/s3.go +++ b/server/media/s3/s3.go @@ -92,13 +92,28 @@ func (ah *awshandler) Init(jsconf string) error { // Create S3 service client ah.svc = s3.New(sess) - // Check if the bucket exists, create one if not. + // Check if bucket already exists. + _, err = ah.svc.HeadBucket(&s3.HeadBucketInput{Bucket: aws.String(ah.conf.BucketName)}) + if err == nil { + // Bucket exists + return nil + } + + if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != s3.ErrCodeNoSuchBucket { + // Hard error. + return err + } + + // Bucket does not exist. Create one. _, err = ah.svc.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(ah.conf.BucketName)}) if err != nil { - // Check if bucket already exists or a genuine error. + // Check if someone has already created a bucket (possible in a cluster). if aerr, ok := err.(awserr.Error); ok { if aerr.Code() == s3.ErrCodeBucketAlreadyExists || - aerr.Code() == s3.ErrCodeBucketAlreadyOwnedByYou { + aerr.Code() == s3.ErrCodeBucketAlreadyOwnedByYou || + // Someone is already creating this bucket: + // OperationAborted: A conflicting conditional operation is currently in progress against this resource. + aerr.Code() == "OperationAborted" { // Clear benign error err = nil } From b8a5500c74af0ffcddbb0d02be8215460d5c17bd Mon Sep 17 00:00:00 2001 From: or-else Date: Mon, 30 Mar 2020 12:28:40 +0300 Subject: [PATCH 099/142] add reference to google group to github issue template --- .github/issue_template.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/issue_template.md b/.github/issue_template.md index 2e08d354c..cbf60e406 100644 --- a/.github/issue_template.md +++ b/.github/issue_template.md @@ -1,3 +1,5 @@ +# If you are not reporting a bug or requesting a feature, please post to https://groups.google.com/d/forum/tinode instead. + ### Subject of the issue Describe your issue here. @@ -12,7 +14,7 @@ Describe your issue here. * platform (Windows, Mac, Linux etc) * version of tinode server, e.g. `0.15.2-rc3` * database backend - + #### Client-side - [ ] TinodeWeb/tinodejs: javascript client * Browser make and version. From 00df5718dfc07d5206da93b3b0fd86eb678f62ef Mon Sep 17 00:00:00 2001 From: or-else Date: Mon, 30 Mar 2020 12:29:03 +0300 Subject: [PATCH 100/142] run-cluster.sh updated to use custom config --- server/run-cluster.sh | 90 +++++++++++++++++++++++++------------------ 1 file changed, 53 insertions(+), 37 deletions(-) diff --git a/server/run-cluster.sh b/server/run-cluster.sh index 3b6bf1d5f..22a2b21a6 100755 --- a/server/run-cluster.sh +++ b/server/run-cluster.sh @@ -7,44 +7,60 @@ ALL_NODE_NAMES=( one two three ) # Port where the first node will listen for client connections over http HTTP_BASE_PORT=6080 # Port where the first node will listen for gRPC intra-cluster connections. -GRPC_BASE_PORT=6090 +GRPC_BASE_PORT=16060 -# Allow for non-default config file to be specifid on the command line like config=file_name -if [ ! -z "$config" ] ; then - TINODE_CONF=$config -else - TINODE_CONF="./tinode.conf" -fi +# Assign command line parameters to variables. +# for line in $@; do +# eval "$line" +#done -case "$1" in - start) - echo 'Running cluster on localhost, ports 6080-6082' +while [[ $# -gt 0 ]]; do + key="$1" + shift + echo "$key" + case "$key" in + -c|--config) + config=$1 + shift # value + ;; + start) + if [ ! -z "$config" ] ; then + TINODE_CONF=$config + else + TINODE_CONF="tinode.conf" + fi - HTTP_PORT=$HTTP_BASE_PORT - GRPC_PORT=$GRPC_BASE_PORT - for NODE_NAME in "${ALL_NODE_NAMES[@]}" - do - # Start the node - ./server -config=${TINODE_CONF} -cluster_self=$NODE_NAME -listen=:${HTTP_PORT} -grpc_listen=:${GRPC_PORT} & - # Save PID of the node to a temp file. - # /var/tmp/ does not requre root access. - echo $!> "/var/tmp/tinode-${NODE_NAME}.pid" - # Increment ports for the next node. - HTTP_PORT=$((HTTP_PORT+1)) - GRPC_PORT=$((GRPC_PORT+1)) - done - ;; - stop) - echo 'Stopping cluster' + echo "HTTP ports 6080-6082, gRPC ports 16060-16062, config ${config}" - for NODE_NAME in "${ALL_NODE_NAMES[@]}" - do - # Reda PIDs of running nodes from temp files and kill them. - kill `cat /var/tmp/tinode-${NODE_NAME}.pid` - # Clean up: delete temp files. - rm "/var/tmp/tinode-${NODE_NAME}.pid" - done - ;; - *) - echo $"Usage: $0 {start|stop} [ config= ]" -esac + HTTP_PORT=$HTTP_BASE_PORT + GRPC_PORT=$GRPC_BASE_PORT + for NODE_NAME in "${ALL_NODE_NAMES[@]}" + do + # Start the node + ./server -config=${TINODE_CONF} -cluster_self=${NODE_NAME} -listen=:${HTTP_PORT} -grpc_listen=:${GRPC_PORT} & + # Save PID of the node to a temp file. + # /var/tmp/ does not requre root access. + echo $!> "/var/tmp/tinode-${NODE_NAME}.pid" + # Increment ports for the next node. + HTTP_PORT=$((HTTP_PORT+1)) + GRPC_PORT=$((GRPC_PORT+1)) + done + exit 0 + ;; + stop) + echo 'Stopping cluster' + + for NODE_NAME in "${ALL_NODE_NAMES[@]}" + do + # Read PIDs of running nodes from temp files and kill them. + kill `cat /var/tmp/tinode-${NODE_NAME}.pid` + # Clean up: delete temp files. + rm "/var/tmp/tinode-${NODE_NAME}.pid" + done + exit 0 + ;; + *) + echo $"Usage: $0 {start|stop} [ --config ]" + exit 1 + esac +done From b53c888f76eba085b8d6e0bf1d0bfef758524fcb Mon Sep 17 00:00:00 2001 From: or-else Date: Mon, 30 Mar 2020 12:29:24 +0300 Subject: [PATCH 101/142] correct gob mapping --- server/cluster.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/cluster.go b/server/cluster.go index 2f4e70071..81032de04 100644 --- a/server/cluster.go +++ b/server/cluster.go @@ -699,8 +699,8 @@ func clusterInit(configString json.RawMessage, self *string) int { gob.Register([]interface{}{}) gob.Register(map[string]interface{}{}) - gob.Register(map[string]int) - gob.Register(map[string]string) + gob.Register(map[string]int{}) + gob.Register(map[string]string{}) globals.cluster = &Cluster{ thisNodeName: thisName, From 543883e432fc051a61aca8931e14332391b05a4b Mon Sep 17 00:00:00 2001 From: or-else Date: Mon, 30 Mar 2020 15:54:08 +0300 Subject: [PATCH 102/142] simplify TNPG & add documentation --- docs/API.md | 23 ++++++++++++++++------ docs/faq.md | 36 +++++++++++++++++++++++++++++++---- server/push/fcm/payload.go | 4 ++-- server/push/fcm/push_fcm.go | 17 +++++++---------- server/push/tnpg/push_tnpg.go | 19 +++++++++--------- server/tinode.conf | 15 ++++++++++++--- 6 files changed, 79 insertions(+), 35 deletions(-) diff --git a/docs/API.md b/docs/API.md index 0baddd4e9..45f855104 100644 --- a/docs/API.md +++ b/docs/API.md @@ -410,10 +410,6 @@ A user is reported as being online when one or more of user's sessions are attac An empty `ua=""` _user agent_ is not reported. I.e. if user attaches to `me` with non-empty _user agent_ then does so with an empty one, the change is not reported. An empty _user agent_ may be disallowed in the future. -## Push Notifications Support - -Tinode supports mobile push notifications though compile-time plugins. The channel published by the plugin receives a copy of every data message which was attempted to be delivered. The server supports [Google FCM](https://firebase.google.com/docs/cloud-messaging/) out of the box. - ## Public and Private Fields Topics and subscriptions have `public` and `private` fields. Generally, the fields are application-defined. The server does not enforce any particular structure of these fields except for `fnd` topic. At the same time, client software should use the same format for interoperability reasons. @@ -560,18 +556,33 @@ _Important!_ As a security measure, the client should not send security credenti ## Push Notifications -Tinode uses compile-time adapters for handling push notifications. The server comes with [Google FCM](https://firebase.google.com/docs/cloud-messaging/) and `stdout` adapters. FCM supports all major mobile platforms except Chinese flavor of Android. Any type of push notifications can be handled by writing an appropriate adapter. The payload of the notification from the FCM adapter is the following: +Tinode uses compile-time adapters for handling push notifications. The server comes with [Tinode Push Gateway](), [Google FCM](https://firebase.google.com/docs/cloud-messaging/), and `stdout` adapters. Tinode Push Gateway and Google FCM support Android with [Play Services](https://developers.google.com/android/guides/overview) (may not be supported by some Chinese phones), iOS devices and all major web browsers excluding Safari. The `stdout` adapter does not actually send push notifications. It's mostly useful for debugging, testing and logging. Other types of push notifications such as [TPNS](https://intl.cloud.tencent.com/product/tpns) can be handled by writing appropriate adapters. + +If you are writing a custom plugin, the notification payload is the following: ```js { topic: "grpnG99YhENiQU", // Topic which received the message. xfrom: "usr2il9suCbuko", // ID of the user who sent the message. ts: "2019-01-06T18:07:30.038Z", // message timestamp in RFC3339 format. seq: "1234", // sequential ID of the message (integer value sent as text). - mime: "text/x-drafty", // message MIME-Type. + mime: "text/x-drafty", // optional message MIME-Type. content: "Lorem ipsum dolor sit amet, consectetur adipisci", // The first 80 characters of the message content as plain text. } ``` +### Tinode Push Gateway + +Tinode Push Gateway (TNPG) is a proprietary Tinode service which sends push notifications on behalf of Tinode. It uses Google FCM on the backend and as such supports the same platforms as FCM. The main advantage of using TNPG over FCM is simplicity of configuration: mobile clients do not need to be recompiled, all is needed is a configuration update on a server. + +### Google FCM + +[Google FCM](https://firebase.google.com/docs/cloud-messaging/) supports Android with [Play Services](https://developers.google.com/android/guides/overview), iPhone and iPad devices, and all major web browsers excluding Safari. In order to use FCM mobile clients (iOS, Android) must be recompiled with credentials obtained from Google. + +### Stdout + +The `stdout` adapter is mostly useful for debugging and logging. It writes push payload to `STDOUT` where it can be redirected to file or read by some other process. + + ## Messages A message is a logically associated set of data. Messages are passed as JSON-formatted UTF-8 text. diff --git a/docs/faq.md b/docs/faq.md index 05df79e6e..c620fcbb8 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -14,8 +14,36 @@ docker cp name-of-the-container:/var/log/tinode.log ./tinode.log Alternatively, you can instruct the docker container to save the logs to a directory on the host by mapping a host directory to `/var/log/` in the container. Add `-v /where/to/save/logs:/var/log` to the `docker run` command. -### Q: How to setup FCM push notifications?
-**A**: Enabling push notifications requires two steps: + +### Q: What are the options for enabling push notifications?
+**A**: You can use Tinode Push Gateway (TNPG) or you can use Google FCM: + * _Tinode Push Gateway_ requires minimum configuration changes by sending pushes on behalf of Tinode. + * _Google FCM_ does not rely on Tinode infrastructure for pushes but requires you to recompile mobile apps (iOS and Android). + +### Q: How to setup push notifications with Tinode Push Gateway?
+**A**: Enabling TNPG push notifications requires two steps: + * register at console.tinode.co and obtain a TNPG token + * configure server with the token + +#### Obtain TNPG token +1. Register at https://console.tinode.co and create an organization. +2. Get the TPNG token from the _On premise_ section by following the instructions there. + +#### Configuring the server + +Update the server config [`tinode.conf`](../server/tinode.conf#L384), section `"push"` -> `"name": "tnpg"`: +```js +{ + "enabled": true, + "org": "test", // name of the organization you registered at console.tinode.co + "token": "SoMe_LonG.RaNDoM-StRiNg.123" // authentication token obtained from console.tinode.co +} +``` +Make sure the `fcm` section is disabled `"enabled": false`. + + +### Q: How to setup push notifications with Google FCM?
+**A**: Enabling FCM push notifications requires the following steps: * enable push sending from the server * enable receiving pushes in the clients @@ -30,9 +58,9 @@ Alternatively, you can instruct the docker container to save the logs to a direc 4. Update [TinodeWeb](/tinode/webapp/) config [`firebase-init.js`](https://github.com/tinode/webapp/blob/master/firebase-init.js): update `apiKey`, `messagingSenderId`, `projectId`, `appId`, `messagingVapidKey`. See more info at https://github.com/tinode/webapp/#push_notifications #### iOS and Android -1. If you are using an Android client, add `google-services.json` to [Tindroid](/tinode/tindroid/) by following instructions at https://developers.google.com/android/guides/google-services-plugin and recompile the client. +1. If you are using an Android client, add `google-services.json` to [Tindroid](/tinode/tindroid/) by following instructions at https://developers.google.com/android/guides/google-services-plugin and recompile the client. You may also optionally submit it to Google Play Store. See more info at https://github.com/tinode/tindroid/#push_notifications -2. If you are using an iOS client, add `GoogleService-Info.plist` to [Tinodios](/tinode/ios/) by following instructions at https://firebase.google.com/docs/cloud-messaging/ios/client) and recompile the client. +2. If you are using an iOS client, add `GoogleService-Info.plist` to [Tinodios](/tinode/ios/) by following instructions at https://firebase.google.com/docs/cloud-messaging/ios/client) and recompile the client. You may optionally submit the app to Apple AppStore. See more info at https://github.com/tinode/ios/#push_notifications diff --git a/server/push/fcm/payload.go b/server/push/fcm/payload.go index bf8068ca2..57cb87210 100644 --- a/server/push/fcm/payload.go +++ b/server/push/fcm/payload.go @@ -192,7 +192,7 @@ func PrepareNotifications(rcpt *push.Receipt, config *AndroidConfig) []messageDa } var titlelc, title, bodylc, body, icon, color string - if config.Enabled { + if config != nil && config.Enabled { titlelc = config.getTitleLocKey(rcpt.Payload.What) title = config.getTitle(rcpt.Payload.What) bodylc = config.getBodyLocKey(rcpt.Payload.What) @@ -218,7 +218,7 @@ func PrepareNotifications(rcpt *push.Receipt, config *AndroidConfig) []messageDa msg.Android = &fcm.AndroidConfig{ Priority: "high", } - if config.Enabled { + if config != nil && config.Enabled { // When this notification type is included and the app is not in the foreground // Android won't wake up the app and won't call FirebaseMessagingService:onMessageReceived. // See dicussion: https://github.com/firebase/quickstart-js/issues/71 diff --git a/server/push/fcm/push_fcm.go b/server/push/fcm/push_fcm.go index 5c8b0d9df..10f71eca6 100644 --- a/server/push/fcm/push_fcm.go +++ b/server/push/fcm/push_fcm.go @@ -22,11 +22,13 @@ import ( var handler Handler -// Size of the input channel buffer. -const defaultBuffer = 32 +const ( + // Size of the input channel buffer. + bufferSize = 1024 -// Maximum length of a text message in runes -const maxMessageLength = 80 + // Maximum length of a text message in runes + maxMessageLength = 80 +) // Handler represents the push handler; implements push.PushHandler interface. type Handler struct { @@ -37,7 +39,6 @@ type Handler struct { type configType struct { Enabled bool `json:"enabled"` - Buffer int `json:"buffer"` Credentials json.RawMessage `json:"credentials"` CredentialsFile string `json:"credentials_file"` TimeToLive uint `json:"time_to_live,omitempty"` @@ -82,11 +83,7 @@ func (Handler) Init(jsonconf string) error { return err } - if config.Buffer <= 0 { - config.Buffer = defaultBuffer - } - - handler.input = make(chan *push.Receipt, config.Buffer) + handler.input = make(chan *push.Receipt, bufferSize) handler.stop = make(chan bool, 1) go func() { diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go index 094af43c9..def148273 100644 --- a/server/push/tnpg/push_tnpg.go +++ b/server/push/tnpg/push_tnpg.go @@ -16,6 +16,7 @@ import ( const ( baseTargetAddress = "https://pushgw.tinode.co/push/" batchSize = 100 + bufferSize = 1024 ) var handler Handler @@ -26,11 +27,9 @@ type Handler struct { } type configType struct { - Enabled bool `json:"enabled"` - Buffer int `json:"buffer"` - User string `json:"user"` - AuthToken string `json:"auth_token"` - Android fcm.AndroidConfig `json:"android,omitempty"` + Enabled bool `json:"enabled"` + OrgName string `json:"org"` + AuthToken string `json:"token"` } // Init initializes the handler @@ -44,11 +43,11 @@ func (Handler) Init(jsonconf string) error { return nil } - if len(config.User) == 0 { - return errors.New("push.tnpg.user not specified.") + if config.OrgName == "" { + return errors.New("push.tnpg.org not specified.") } - handler.input = make(chan *push.Receipt, config.Buffer) + handler.input = make(chan *push.Receipt, bufferSize) handler.stop = make(chan bool, 1) go func() { @@ -70,7 +69,7 @@ func postMessage(body interface{}, config *configType) (int, string, error) { gz := gzip.NewWriter(buf) json.NewEncoder(gz).Encode(body) gz.Close() - targetAddress := baseTargetAddress + config.User + targetAddress := baseTargetAddress + config.OrgName req, err := http.NewRequest("POST", targetAddress, buf) if err != nil { return -1, "", err @@ -88,7 +87,7 @@ func postMessage(body interface{}, config *configType) (int, string, error) { } func sendPushes(rcpt *push.Receipt, config *configType) { - messages := fcm.PrepareNotifications(rcpt, &config.Android) + messages := fcm.PrepareNotifications(rcpt, nil) if messages == nil { return } diff --git a/server/tinode.conf b/server/tinode.conf index f3f8fa696..03de86509 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -307,9 +307,6 @@ // Disabled. Won't work without the server key anyway. See below. "enabled": false, - // Number of notifications to keep before they start to be dropped. - "buffer": 1024, - // Firebase project ID. "project_id": "your-project-id", @@ -379,6 +376,18 @@ } } } + }, + { + // Tinode Push Gateway. + "name":"tnpg", + "config": { + // Disabled. Configure first then enable. + "enabled": false, + // Name of the organization you registered at console.tinode.co. + "org": "test", + // Authentication token obtained from console.tinode.co + "token": "jwt-security-token-obtained-from-console.tinode.co", + } } ], From 42bf4a280040b969c593661e29b94ef5ebefa334 Mon Sep 17 00:00:00 2001 From: or-else Date: Mon, 30 Mar 2020 16:27:24 +0300 Subject: [PATCH 103/142] create post url once --- docs/faq.md | 4 ++-- server/push/tnpg/push_tnpg.go | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index c620fcbb8..253a5d1f9 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -64,10 +64,10 @@ See more info at https://github.com/tinode/tindroid/#push_notifications See more info at https://github.com/tinode/ios/#push_notifications -### Q: How can new users be added to Tinode?
+### Q: How to add new users?
**A**: There are three ways to create accounts: * A user can create a new account using one of the applications (web, Android, iOS). -* A new account can be created using [tn-cli](../tn-cli/) (`acc` command). The process can be scripted. +* A new account can be created using [tn-cli](../tn-cli/) (`acc` command or `useradd` macro). The process can be scripted. * If the user already exists in an external database, the Tinode account can be automatically created on the first login using the [rest authenticator](../server/auth/rest/). diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go index def148273..03ce95de4 100644 --- a/server/push/tnpg/push_tnpg.go +++ b/server/push/tnpg/push_tnpg.go @@ -22,8 +22,9 @@ const ( var handler Handler type Handler struct { - input chan *push.Receipt - stop chan bool + input chan *push.Receipt + stop chan bool + postUrl string } type configType struct { @@ -47,6 +48,7 @@ func (Handler) Init(jsonconf string) error { return errors.New("push.tnpg.org not specified.") } + handler.postUrl = baseTargetAddress + config.OrgName handler.input = make(chan *push.Receipt, bufferSize) handler.stop = make(chan bool, 1) @@ -69,8 +71,8 @@ func postMessage(body interface{}, config *configType) (int, string, error) { gz := gzip.NewWriter(buf) json.NewEncoder(gz).Encode(body) gz.Close() - targetAddress := baseTargetAddress + config.OrgName - req, err := http.NewRequest("POST", targetAddress, buf) + + req, err := http.NewRequest("POST", handler.postUrl, buf) if err != nil { return -1, "", err } From 3c0a84004eef898ca8dc883ba85a59e4668fb5de Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 31 Mar 2020 08:50:10 +0300 Subject: [PATCH 104/142] added explicit language to email templates --- server/templ/email-password-reset-ru.templ | 6 +++--- server/templ/email-validation-ru.templ | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/server/templ/email-password-reset-ru.templ b/server/templ/email-password-reset-ru.templ index 731dfefae..411037750 100644 --- a/server/templ/email-password-reset-ru.templ +++ b/server/templ/email-password-reset-ru.templ @@ -18,11 +18,11 @@

Вы прислали запрос на изменение пароля для вашего аккаунта Tinode. Для изменения пароля используйте следующую ссылку. Она действительна в течение 24 часов.

-
Кликните для изменения пароля.
+
Кликните для изменения пароля.

Если ссылка по какой-то причине не работает, скопируйте следующий URL и вставьте его в адресную строку браузера:

-{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}} +{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU
{{with .Login}} @@ -44,7 +44,7 @@ Вы прислали запрос на изменение пароля для вашего аккаунта Tinode ({{.HostUrl}}). Для изменения пароля используйте следующую ниже. Она действительна в течение 24 часов.

- {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}} + {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU Если ссылка по какой-то вы не можете кликнуть по ссылке выше, скопируйте ее и вставьте его в адресную строку браузера. diff --git a/server/templ/email-validation-ru.templ b/server/templ/email-validation-ru.templ index 67875c024..e8aae1c00 100644 --- a/server/templ/email-validation-ru.templ +++ b/server/templ/email-validation-ru.templ @@ -16,9 +16,9 @@

Вы получили это сообщение потому, что зарегистрировались в Tinode.

-

Кликните здесь чтобы подтвердить +

Кликните здесь чтобы подтвердить регистрацию или перейдите по сслыке -{{.HostUrl}}#cred?method=email +{{.HostUrl}}#cred?method=email&hl=RU и введите следующий код:

{{.Code}}

Возможно, вам потребуется ввести логин и пароль.

@@ -37,8 +37,8 @@ Вы получили это сообщение потому, что зарегистрировались в Tinode ({{.HostUrl}}). -Кликните на {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} чтобы подтвердить -регистрацию или перейдите по сслыке {{.HostUrl}}#cred?what=email">{{.HostUrl}}#cred?method=email +Кликните на {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=RU чтобы подтвердить +регистрацию или перейдите по сслыке {{.HostUrl}}#cred?what=email">{{.HostUrl}}#cred?method=email&hl=RU и введите следующий код: {{.Code}} From a136a04093450f1037cb9dc9ec2bf985d29cb62a Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 31 Mar 2020 09:07:22 +0300 Subject: [PATCH 105/142] fix rest auth documentation --- server/auth/rest/README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/server/auth/rest/README.md b/server/auth/rest/README.md index e1b60a9e6..111ff9bb2 100644 --- a/server/auth/rest/README.md +++ b/server/auth/rest/README.md @@ -44,13 +44,13 @@ Request and response payloads are formatted as JSON. Some of the request or resp ## Configuration -Add the following section to the `auth_config` in [tinode.conf](../../tinode.conf): +Add the following section to the `auth_config` in [tinode.conf](../../tinode.conf): ```js ... "auth_config": { ... - "myveryownauth": { + "rest": { // ServerUrl is the URL of the authentication server to call. "server_url": "http://127.0.0.1:5000/", // Authentication server is allowed to create new accounts. @@ -62,13 +62,12 @@ Add the following section to the `auth_config` in [tinode.conf](../../tinode.con ... }, ``` -The name `myveryownauth` is completely arbitrary, but your client has to be configured to use it. If you want to use your -config **instead** of stock `basic` (login-password) authentication, then add a logical renaming: +If you want to use your authenticator **instead** of stock `basic` (login-password) authentication, then add a logical renaming: ```js ... "auth_config": { - "logical_names": ["myveryownauth:basic", "basic:"], - "myveryownauth": { ... }, + "logical_names": ["basic:rest"], + "rest": { ... }, ... }, ... From 9833e202ff08061ee6335e7f263c8ac5e382c8cb Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 31 Mar 2020 09:11:45 +0300 Subject: [PATCH 106/142] grammar --- server/auth/rest/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/auth/rest/README.md b/server/auth/rest/README.md index 111ff9bb2..ba88a79a1 100644 --- a/server/auth/rest/README.md +++ b/server/auth/rest/README.md @@ -62,7 +62,7 @@ Add the following section to the `auth_config` in [tinode.conf](../../tinode.con ... }, ``` -If you want to use your authenticator **instead** of stock `basic` (login-password) authentication, then add a logical renaming: +If you want to use your authenticator **instead** of stock `basic` (login-password) authentication, add a logical renaming: ```js ... "auth_config": { From 8007b0df39a3d4db80130c8cb8f7bbaab8784086 Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 31 Mar 2020 10:20:26 +0300 Subject: [PATCH 107/142] push adapter documentation --- docs/API.md | 4 ++-- docs/faq.md | 21 +++------------------ server/push/fcm/README.md | 25 +++++++++++++++++++++++++ server/push/stdout/README.md | 4 ++++ server/push/tnpg/README.md | 27 +++++++++++++++++++++++++++ 5 files changed, 61 insertions(+), 20 deletions(-) create mode 100644 server/push/fcm/README.md create mode 100644 server/push/stdout/README.md create mode 100644 server/push/tnpg/README.md diff --git a/docs/API.md b/docs/API.md index 45f855104..53f6e155c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -556,7 +556,7 @@ _Important!_ As a security measure, the client should not send security credenti ## Push Notifications -Tinode uses compile-time adapters for handling push notifications. The server comes with [Tinode Push Gateway](), [Google FCM](https://firebase.google.com/docs/cloud-messaging/), and `stdout` adapters. Tinode Push Gateway and Google FCM support Android with [Play Services](https://developers.google.com/android/guides/overview) (may not be supported by some Chinese phones), iOS devices and all major web browsers excluding Safari. The `stdout` adapter does not actually send push notifications. It's mostly useful for debugging, testing and logging. Other types of push notifications such as [TPNS](https://intl.cloud.tencent.com/product/tpns) can be handled by writing appropriate adapters. +Tinode uses compile-time adapters for handling push notifications. The server comes with [Tinode Push Gateway](../server/push/tnpg/), [Google FCM](https://firebase.google.com/docs/cloud-messaging/), and `stdout` adapters. Tinode Push Gateway and Google FCM support Android with [Play Services](https://developers.google.com/android/guides/overview) (may not be supported by some Chinese phones), iOS devices and all major web browsers excluding Safari. The `stdout` adapter does not actually send push notifications. It's mostly useful for debugging, testing and logging. Other types of push notifications such as [TPNS](https://intl.cloud.tencent.com/product/tpns) can be handled by writing appropriate adapters. If you are writing a custom plugin, the notification payload is the following: ```js @@ -572,7 +572,7 @@ If you are writing a custom plugin, the notification payload is the following: ### Tinode Push Gateway -Tinode Push Gateway (TNPG) is a proprietary Tinode service which sends push notifications on behalf of Tinode. It uses Google FCM on the backend and as such supports the same platforms as FCM. The main advantage of using TNPG over FCM is simplicity of configuration: mobile clients do not need to be recompiled, all is needed is a configuration update on a server. +Tinode Push Gateway (TNPG) is a proprietary Tinode service which sends push notifications on behalf of Tinode. Internally it uses Google FCM and as such supports the same platforms as FCM. The main advantage of using TNPG over FCM is simplicity of configuration: mobile clients do not need to be recompiled, all is needed is a [configuration update](../server/push/tnpg/) on a server. ### Google FCM diff --git a/docs/faq.md b/docs/faq.md index 253a5d1f9..02e2ad5f7 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -22,24 +22,9 @@ Alternatively, you can instruct the docker container to save the logs to a direc ### Q: How to setup push notifications with Tinode Push Gateway?
**A**: Enabling TNPG push notifications requires two steps: - * register at console.tinode.co and obtain a TNPG token - * configure server with the token - -#### Obtain TNPG token -1. Register at https://console.tinode.co and create an organization. -2. Get the TPNG token from the _On premise_ section by following the instructions there. - -#### Configuring the server - -Update the server config [`tinode.conf`](../server/tinode.conf#L384), section `"push"` -> `"name": "tnpg"`: -```js -{ - "enabled": true, - "org": "test", // name of the organization you registered at console.tinode.co - "token": "SoMe_LonG.RaNDoM-StRiNg.123" // authentication token obtained from console.tinode.co -} -``` -Make sure the `fcm` section is disabled `"enabled": false`. + * register at console.tinode.co and obtain a TNPG token. + * configure server with the token. +See detailed instructions [here](../server/push/tnpg/). ### Q: How to setup push notifications with Google FCM?
diff --git a/server/push/fcm/README.md b/server/push/fcm/README.md new file mode 100644 index 000000000..0460895ee --- /dev/null +++ b/server/push/fcm/README.md @@ -0,0 +1,25 @@ +# FCM push adapter + +This adapter sends push notifications to mobile clients and web browsers using [Google FCM](https://firebase.google.com/docs/cloud-messaging/). As of the time of this writing it supports Android with [Play Services](https://developers.google.com/android/guides/overview), iOS devices, and all major web browsers [excluding Safari](https://caniuse.com/#feat=push-api). + +This adapter requires you to obtain your own credentials from Goole Firebase. If you want to use iOS and Android mobile apps with your service, they must be recompiled with your credentials obtained from Google. If you do not want to recompile mobile clients, consider using TNPG adapter instead. + + +## Configuring FCM adapter + +### Server and TinodeWeb + +1. Create a project at https://firebase.google.com/ if you have not done so already. +2. Follow instructions at https://cloud.google.com/iam/docs/creating-managing-service-account-keys to download the credentials file. +3. Update the server config [`tinode.conf`](../server/tinode.conf#L255), section `"push"` -> `"name": "fcm"`. Do _ONE_ of the following: + * _Either_ enter the path to the downloaded credentials file into `"credentials_file"`. + * _OR_ copy the file contents to `"credentials"`.

+ Remove the other entry. I.e. if you have updated `"credentials_file"`, remove `"credentials"` and vice versa. +4. Update [TinodeWeb](/tinode/webapp/) config [`firebase-init.js`](https://github.com/tinode/webapp/blob/master/firebase-init.js): update `apiKey`, `messagingSenderId`, `projectId`, `appId`, `messagingVapidKey`. See more info at https://github.com/tinode/webapp/#push_notifications + +### iOS and Android + +1. If you are using an Android client, add `google-services.json` to [Tindroid](/tinode/tindroid/) by following instructions at https://developers.google.com/android/guides/google-services-plugin and recompile the client. You may also optionally submit it to Google Play Store. +See more info at https://github.com/tinode/tindroid/#push_notifications +2. If you are using an iOS client, add `GoogleService-Info.plist` to [Tinodios](/tinode/ios/) by following instructions at https://firebase.google.com/docs/cloud-messaging/ios/client) and recompile the client. You may optionally submit the app to Apple AppStore. +See more info at https://github.com/tinode/ios/#push_notifications diff --git a/server/push/stdout/README.md b/server/push/stdout/README.md new file mode 100644 index 000000000..c742899d9 --- /dev/null +++ b/server/push/stdout/README.md @@ -0,0 +1,4 @@ +# `stdout` push adapter + +This is an adapter which logs push notifications to `STDOUT` where they can be redirected to file or processed by some other service. +This adapter is primarily intended for debugging and logging. diff --git a/server/push/tnpg/README.md b/server/push/tnpg/README.md new file mode 100644 index 000000000..b8286c698 --- /dev/null +++ b/server/push/tnpg/README.md @@ -0,0 +1,27 @@ +# TNPG: Push Gateway + +This is push notifications adapter which communicates with Tinode Push Gateway (TNPG). + +TNPG is a proprietary service intended to simplify deployment of on-premise installations. +Deploying a Tinode server without TNPG requires [configuring Google FCM](../fcm/) with your own credentials including recompiling mobile clients and releasing them to PlayStore and AppStore under your own accounts which is usually time consuming and relatively complex. + +TNPG solves this problem by allowing you to send push notifications on behalf of Tinode. Internally it uses Google FCM and as such supports the same platforms as FCM. The main advantage of using TNPG over FCM is simplicity of configuration: mobile clients do not need to be recompiled, all is needed is a configuration update on the server. + +## Configuring TNPG adapter + +### Obtain TNPG token + +1. Register at https://console.tinode.co and create an organization. +2. Get the TPNG token from the _On premise_ section by following the instructions there. + +### Configuring the server + +Update the server config [`tinode.conf`](../server/tinode.conf#L384), section `"push"` -> `"name": "tnpg"`: +```js +{ + "enabled": true, + "org": "myorg", // name of the organization you registered at console.tinode.co + "token": "SoMe_LonG.RaNDoM-StRiNg.12345" // authentication token obtained from console.tinode.co +} +``` +Make sure the `fcm` section is disabled `"enabled": false`. From 4f376a07b1ddd1a4f917218a5aa2a553b5146f6d Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 31 Mar 2020 10:33:01 +0300 Subject: [PATCH 108/142] api TOC and links --- docs/API.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/API.md b/docs/API.md index 53f6e155c..3996fe15c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -28,7 +28,6 @@ - [`sys` Topic](#sys-topic) - [Using Server-Issued Message IDs](#using-server-issued-message-ids) - [User Agent and Presence Notifications](#user-agent-and-presence-notifications) - - [Push Notifications Support](#push-notifications-support) - [Public and Private Fields](#public-and-private-fields) - [Public](#public) - [Private](#private) @@ -37,6 +36,9 @@ - [Uploading](#uploading) - [Downloading](#downloading) - [Push Notifications](#push-notifications) + - [Tinode Push Gateway](#tinode-push-gateway) + - [Google FCM](#google-fcm) + - [Stdout](#stdout) - [Messages](#messages) - [Client to Server Messages](#client-to-server-messages) - [`{hi}`](#hi) @@ -576,7 +578,7 @@ Tinode Push Gateway (TNPG) is a proprietary Tinode service which sends push noti ### Google FCM -[Google FCM](https://firebase.google.com/docs/cloud-messaging/) supports Android with [Play Services](https://developers.google.com/android/guides/overview), iPhone and iPad devices, and all major web browsers excluding Safari. In order to use FCM mobile clients (iOS, Android) must be recompiled with credentials obtained from Google. +[Google FCM](https://firebase.google.com/docs/cloud-messaging/) supports Android with [Play Services](https://developers.google.com/android/guides/overview), iPhone and iPad devices, and all major web browsers excluding Safari. In order to use FCM mobile clients (iOS, Android) must be recompiled with credentials obtained from Google. See [instructions](../server/push/fcm/) for details. ### Stdout From 54dfd1918e3fd0ff326e20da43743a0b1f8dff89 Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 31 Mar 2020 16:45:15 +0300 Subject: [PATCH 109/142] replace DisposaBoy/JsonConfigReader with tinode/jsonco --- go.mod | 2 +- go.sum | 4 +-- server/db/mongodb/tests/mongo_test.go | 2 +- server/main.go | 16 +++++------- server/utils.go | 37 --------------------------- tinode-db/main.go | 20 ++++++++++++--- 6 files changed, 28 insertions(+), 53 deletions(-) diff --git a/go.mod b/go.mod index 9c8b84490..5ba3ee849 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.14 require ( firebase.google.com/go v3.12.0+incompatible - github.com/DisposaBoy/JsonConfigReader v0.0.0-20171218180944-5ea4d0ddac55 github.com/aws/aws-sdk-go v1.29.29 github.com/bitly/go-hostpool v0.1.0 // indirect github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect @@ -16,6 +15,7 @@ require ( github.com/jmoiron/sqlx v1.2.0 github.com/prometheus/client_golang v1.5.1 github.com/prometheus/common v0.9.1 + github.com/tinode/jsonco v1.0.0 github.com/tinode/snowflake v1.0.0 go.mongodb.org/mongo-driver v1.3.1 golang.org/x/crypto v0.0.0-20200320181102-891825fb96df diff --git a/go.sum b/go.sum index c012b5340..4cdc9399a 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,6 @@ cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSR firebase.google.com/go v3.12.0+incompatible h1:q70KCp/J0oOL8kJ8oV2j3646kV4TB8Y5IvxXC0WT1bo= firebase.google.com/go v3.12.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/DisposaBoy/JsonConfigReader v0.0.0-20171218180944-5ea4d0ddac55 h1:jbGlDKdzAZ92NzK65hUP98ri0/r50vVVvmZsFP/nIqo= -github.com/DisposaBoy/JsonConfigReader v0.0.0-20171218180944-5ea4d0ddac55/go.mod h1:GCzqZQHydohgVLSIqRKZeTt8IGb1Y4NaFfim3H40uUI= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -185,6 +183,8 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tinode/jsonco v1.0.0 h1:zVcpjzDvjuA1G+HLrckI5EiiRyq9jgV3x37OQl6e5FE= +github.com/tinode/jsonco v1.0.0/go.mod h1:Bnavu3302Qfn2pILMNwASkelodgeew3IvDrbdzU84u8= github.com/tinode/snowflake v1.0.0 h1:YciQ9ZKn1TrnvpS8yZErt044XJaxWVtR9aMO9rOZVOE= github.com/tinode/snowflake v1.0.0/go.mod h1:5JiaCe3o7QdDeyRcAeZBGVghwRS+ygt2CF/hxmAoptQ= github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c h1:u40Z8hqBAAQyv+vATcGgV0YCnDjqSL7/q/JyPhhJSPk= diff --git a/server/db/mongodb/tests/mongo_test.go b/server/db/mongodb/tests/mongo_test.go index acc70c971..4b561209f 100644 --- a/server/db/mongodb/tests/mongo_test.go +++ b/server/db/mongodb/tests/mongo_test.go @@ -19,10 +19,10 @@ import ( "testing" "time" - jcr "github.com/DisposaBoy/JsonConfigReader" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" adapter "github.com/tinode/chat/server/db" + jcr "github.com/tinode/jsonco" b "go.mongodb.org/mongo-driver/bson" mdb "go.mongodb.org/mongo-driver/mongo" mdbopts "go.mongodb.org/mongo-driver/mongo/options" diff --git a/server/main.go b/server/main.go index fa8876dce..6b1cafbb1 100644 --- a/server/main.go +++ b/server/main.go @@ -22,11 +22,11 @@ import ( "strings" "time" - // For stripping comments from JSON config - jcr "github.com/DisposaBoy/JsonConfigReader" - gh "github.com/gorilla/handlers" + // For stripping comments from JSON config + jcr "github.com/tinode/jsonco" + // Authenticators "github.com/tinode/chat/server/auth" _ "github.com/tinode/chat/server/auth/anon" @@ -264,17 +264,15 @@ func main() { if file, err := os.Open(*configfile); err != nil { log.Fatal("Failed to read config file: ", err) } else { - if err = json.NewDecoder(jcr.New(file)).Decode(&config); err != nil { - // Need to reset file to start in order to convert byte offset to line number and character position. - // Ignore possible error: can't use it anyway. - file.Seek(0, 0) + jr := jcr.New(file) + if err = json.NewDecoder(jr).Decode(&config); err != nil { switch jerr := err.(type) { case *json.UnmarshalTypeError: - lnum, cnum, _ := offsetToLineAndChar(file, jerr.Offset) + lnum, cnum, _ := jr.LineAndChar(jerr.Offset) log.Fatalf("Unmarshall error in config file in %s at %d:%d (offset %d bytes): %s", jerr.Field, lnum, cnum, jerr.Offset, jerr.Error()) case *json.SyntaxError: - lnum, cnum, _ := offsetToLineAndChar(file, jerr.Offset) + lnum, cnum, _ := jr.LineAndChar(jerr.Offset) log.Fatalf("Syntax error in config file at %d:%d (offset %d bytes): %s", lnum, cnum, jerr.Offset, jerr.Error()) default: diff --git a/server/utils.go b/server/utils.go index 560f643e4..a002d634c 100644 --- a/server/utils.go +++ b/server/utils.go @@ -3,12 +3,10 @@ package main import ( - "bufio" "crypto/tls" "encoding/json" "errors" "fmt" - "io" "net" "path/filepath" "reflect" @@ -746,41 +744,6 @@ func mergeMaps(dst, src map[string]interface{}) (map[string]interface{}, bool) { return dst, changed } -// Calculate line and character position from byte offset into a file. -func offsetToLineAndChar(r io.Reader, offset int64) (int, int, error) { - if offset < 0 { - return -1, -1, errors.New("offset value cannot be negative") - } - - br := bufio.NewReader(r) - - // Count lines and characters. - lnum := 1 - cnum := 0 - // Number of bytes consumed. - var count int64 - for { - ch, size, err := br.ReadRune() - if err == io.EOF { - return -1, -1, errors.New("offset value too large") - } - count += int64(size) - - if ch == '\n' { - lnum++ - cnum = 0 - } else { - cnum++ - } - - if count >= offset { - break - } - } - - return lnum, cnum, nil -} - // netListener creates net.Listener for tcp and unix domains: // if addr is is in the form "unix:/run/tinode.sock" it's a unix socket, otherwise TCP host:port. func netListener(addr string) (net.Listener, error) { diff --git a/tinode-db/main.go b/tinode-db/main.go index 8ad153f0d..ec003318b 100644 --- a/tinode-db/main.go +++ b/tinode-db/main.go @@ -12,11 +12,11 @@ import ( "strings" "time" - jcr "github.com/DisposaBoy/JsonConfigReader" _ "github.com/tinode/chat/server/db/mongodb" _ "github.com/tinode/chat/server/db/mysql" _ "github.com/tinode/chat/server/db/rethinkdb" "github.com/tinode/chat/server/store" + jcr "github.com/tinode/jsonco" ) type configType struct { @@ -191,8 +191,22 @@ func main() { var config configType if file, err := os.Open(*conffile); err != nil { log.Fatalln("Failed to read config file:", err) - } else if err = json.NewDecoder(jcr.New(file)).Decode(&config); err != nil { - log.Fatalln("Failed to parse config file:", err) + } else { + jr := jcr.New(file) + if err = json.NewDecoder(jr).Decode(&config); err != nil { + switch jerr := err.(type) { + case *json.UnmarshalTypeError: + lnum, cnum, _ := jr.LineAndChar(jerr.Offset) + log.Fatalf("Unmarshall error in config file in %s at %d:%d (offset %d bytes): %s", + jerr.Field, lnum, cnum, jerr.Offset, jerr.Error()) + case *json.SyntaxError: + lnum, cnum, _ := jr.LineAndChar(jerr.Offset) + log.Fatalf("Syntax error in config file at %d:%d (offset %d bytes): %s", + lnum, cnum, jerr.Offset, jerr.Error()) + default: + log.Fatal("Failed to parse config file: ", err) + } + } } err := store.Open(1, config.StoreConfig) From af9269f3aaa62254738cf54b71a59db2b4cff983 Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 31 Mar 2020 18:16:42 +0300 Subject: [PATCH 110/142] gofmt -s --- monitoring/exporter/influxdb_exporter.go | 6 +++--- monitoring/exporter/prom_exporter.go | 4 ++-- monitoring/exporter/scraper.go | 4 ++-- server/store/store.go | 2 +- server/topic.go | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/monitoring/exporter/influxdb_exporter.go b/monitoring/exporter/influxdb_exporter.go index 0d489d365..568172177 100644 --- a/monitoring/exporter/influxdb_exporter.go +++ b/monitoring/exporter/influxdb_exporter.go @@ -2,8 +2,8 @@ package main import ( "bytes" - "log" "fmt" + "log" "net/http" "net/url" "time" @@ -20,9 +20,9 @@ type InfluxDBExporter struct { } // NewInfluxDBExporter returns an initialized InfluxDB exporter. -func NewInfluxDBExporter(influxDBVersion, pushBaseAddress, organization, bucket, token, instance string, scraper *Scraper) *InfluxDBExporter{ +func NewInfluxDBExporter(influxDBVersion, pushBaseAddress, organization, bucket, token, instance string, scraper *Scraper) *InfluxDBExporter { targetAddress := formPushTargetAddress(influxDBVersion, pushBaseAddress, organization, bucket) - tokenHeader := formAuthorizationHeaderValue(influxDBVersion, token) + tokenHeader := formAuthorizationHeaderValue(influxDBVersion, token) return &InfluxDBExporter{ targetAddress: targetAddress, organization: organization, diff --git a/monitoring/exporter/prom_exporter.go b/monitoring/exporter/prom_exporter.go index 666753521..bb6b2147b 100644 --- a/monitoring/exporter/prom_exporter.go +++ b/monitoring/exporter/prom_exporter.go @@ -13,7 +13,7 @@ type PromExporter struct { timeout time.Duration namespace string - scraper *Scraper + scraper *Scraper up *prometheus.Desc version *prometheus.Desc @@ -151,7 +151,7 @@ func (e *PromExporter) parseAndUpdate(ch chan<- prometheus.Metric, desc *prometh return nil } else { return err - } + } } func firstError(errs ...error) error { diff --git a/monitoring/exporter/scraper.go b/monitoring/exporter/scraper.go index 4065a68a9..ebe5a3464 100644 --- a/monitoring/exporter/scraper.go +++ b/monitoring/exporter/scraper.go @@ -10,8 +10,8 @@ import ( // Scraper collects metrics from a tinode server. type Scraper struct { - address string - metrics []string + address string + metrics []string } var errKeyNotFound = errors.New("key not found") diff --git a/server/store/store.go b/server/store/store.go index d2eb43222..55f3e7ac0 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -352,7 +352,7 @@ func (UsersObjMapper) FindSubs(id types.Uid, required, optional []string) ([]typ } allSubs := append(usubs, tsubs...) - for i, _ := range allSubs { + for i := range allSubs { // Indicate that the returned access modes are not 'N', but rather undefined. allSubs[i].ModeGiven = types.ModeUnset allSubs[i].ModeWant = types.ModeUnset diff --git a/server/topic.go b/server/topic.go index 7abaec437..803eed962 100644 --- a/server/topic.go +++ b/server/topic.go @@ -1943,7 +1943,7 @@ func (t *Topic) replyGetData(sess *Session, asUid types.Uid, id string, req *Msg // Push the list of messages to the client as {data}. if messages != nil { count = len(messages) - for i, _ := range messages { + for i := range messages { mm := &messages[i] sess.queueOut(&ServerComMessage{Data: &MsgServerData{ Topic: toriginal, From 699218304cc68e111cfe07e3395e097ab8b5c850 Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 31 Mar 2020 19:08:00 +0300 Subject: [PATCH 111/142] fixes for lint warnings --- server/auth/rest/auth_rest.go | 2 +- server/store/types/types.go | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/server/auth/rest/auth_rest.go b/server/auth/rest/auth_rest.go index 7ac2a84d0..359b210a1 100644 --- a/server/auth/rest/auth_rest.go +++ b/server/auth/rest/auth_rest.go @@ -1,4 +1,4 @@ -// Package REST provides authentication by calling a separate process over REST API. +// Package rest provides authentication by calling a separate process over REST API (technically JSON RPC, not REST). package rest import ( diff --git a/server/store/types/types.go b/server/store/types/types.go index 29e769f74..acd3fd604 100644 --- a/server/store/types/types.go +++ b/server/store/types/types.go @@ -367,9 +367,13 @@ func (ss StringSlice) Value() (driver.Value, error) { type ObjState int const ( - StateOK ObjState = 0 + // StateOK indicates normal user or topic. + StateOK ObjState = 0 + // StateSuspended indicates suspended user or topic. StateSuspended ObjState = 10 - StateDeleted ObjState = 20 + // StateDeleted indicates soft-deleted user or topic. + StateDeleted ObjState = 20 + // StateUndefined indicates state which has not been set explicitly. StateUndefined ObjState = 30 ) From c27e7d4fa644247ab53b31fcd52c6a678cb59e20 Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 31 Mar 2020 21:38:45 +0300 Subject: [PATCH 112/142] removed redundant parenthesis --- server/topic.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/topic.go b/server/topic.go index 803eed962..9a30b30fb 100644 --- a/server/topic.go +++ b/server/topic.go @@ -1363,7 +1363,7 @@ func (t *Topic) replyGetDesc(sess *Session, asUid types.Uid, id string, opts *Ms } // Check if user requested modified data - ifUpdated := (opts == nil || opts.IfModifiedSince == nil || opts.IfModifiedSince.Before(t.updated)) + ifUpdated := opts == nil || opts.IfModifiedSince == nil || opts.IfModifiedSince.Before(t.updated) desc := &MsgTopicDesc{} if !ifUpdated { From a093d402fd01a95879b2088cffb3451b808a4353 Mon Sep 17 00:00:00 2001 From: or-else Date: Wed, 1 Apr 2020 21:15:04 +0300 Subject: [PATCH 113/142] support for incognito mode --- server/push/tnpg/README.md | 2 +- server/topic.go | 33 +++++++++++++++++++++------------ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/server/push/tnpg/README.md b/server/push/tnpg/README.md index b8286c698..542a026e2 100644 --- a/server/push/tnpg/README.md +++ b/server/push/tnpg/README.md @@ -14,7 +14,7 @@ TNPG solves this problem by allowing you to send push notifications on behalf of 1. Register at https://console.tinode.co and create an organization. 2. Get the TPNG token from the _On premise_ section by following the instructions there. -### Configuring the server +### Configure the server Update the server config [`tinode.conf`](../server/tinode.conf#L384), section `"push"` -> `"name": "tnpg"`: ```js diff --git a/server/topic.go b/server/topic.go index 9a30b30fb..0455876f1 100644 --- a/server/topic.go +++ b/server/topic.go @@ -1084,7 +1084,7 @@ func (t *Topic) requestSub(h *Hub, sess *Session, asUid types.Uid, asLvl auth.Le // If user has not requested a new access mode, provide one by default. if modeWant == types.ModeUnset { // If the user has self-banned before, un-self-ban. Otherwise do not make a change. - if !userData.modeWant.IsJoiner() { + if !oldWant.IsJoiner() { log.Println("No J permissions before") // Set permissions NO WORSE than default, but possibly better (admin or owner banned himself). userData.modeWant = userData.modeGiven | t.accessFor(asLvl) @@ -1137,9 +1137,13 @@ func (t *Topic) requestSub(h *Hub, sess *Session, asUid types.Uid, asLvl auth.Le } // If topic is being muted, send "off" notification and disable updates. - // DO it before applying the new permissions. + // Do it before applying the new permissions. if (oldWant & oldGiven).IsPresencer() && !(userData.modeWant & userData.modeGiven).IsPresencer() { - t.presSingleUserOffline(asUid, "off+dis", nilPresParams, "", false) + if t.cat == types.TopicCatMe { + t.presUsersOfInterest("off+dis", t.userAgent) + } else { + t.presSingleUserOffline(asUid, "off+dis", nilPresParams, "", false) + } } // Apply changes. @@ -1401,13 +1405,13 @@ func (t *Topic) replyGetDesc(sess *Session, asUid types.Uid, id string, opts *Ms Anon: t.accessAnon.String()} } - if t.cat != types.TopicCatMe { - desc.Acs = &MsgAccessMode{ - Want: pud.modeWant.String(), - Given: pud.modeGiven.String(), - Mode: (pud.modeGiven & pud.modeWant).String()} - } else if sess.authLvl == auth.LevelRoot { - // If 'me' is in memory then user account is invariable not suspended. + desc.Acs = &MsgAccessMode{ + Want: pud.modeWant.String(), + Given: pud.modeGiven.String(), + Mode: (pud.modeGiven & pud.modeWant).String()} + + if t.cat == types.TopicCatMe && sess.authLvl == auth.LevelRoot { + // If 'me' is in memory then user account is invariably not suspended. desc.State = types.StateOK.String() } @@ -2527,8 +2531,13 @@ func (t *Topic) notifySubChange(uid, actor types.Uid, oldWant, oldGiven, } else if (newWant & newGiven).IsPresencer() && !(oldWant & oldGiven).IsPresencer() { // Subscription un-muted. - // Notify subscriber of topic's online status. - t.presSingleUserOffline(uid, "?unkn+en", nilPresParams, "", false) + if t.cat == types.TopicCatMe { + // User is visible online now, notify subscribers. + t.presUsersOfInterest("on+en", t.userAgent) + } else { + // Notify subscriber of topic's online status. + t.presSingleUserOffline(uid, "?unkn+en", nilPresParams, "", false) + } } // Notify requester's other sessions that permissions have changed. From 00cdd35194bf2e0c8246844100ce409e2775fc80 Mon Sep 17 00:00:00 2001 From: or-else Date: Thu, 2 Apr 2020 09:48:01 +0300 Subject: [PATCH 114/142] incognito mode works as expected --- server/hdl_longpoll.go | 2 -- server/pres.go | 20 +++++++++++++++++--- server/topic.go | 8 ++++---- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/server/hdl_longpoll.go b/server/hdl_longpoll.go index 7fdeaf50a..926c618fe 100644 --- a/server/hdl_longpoll.go +++ b/server/hdl_longpoll.go @@ -91,7 +91,6 @@ func (sess *Session) readOnce(wrt http.ResponseWriter, req *http.Request) (int, // - if payload exists, process it and close // - if sid is not empty but there is no session, report an error func serveLongPoll(wrt http.ResponseWriter, req *http.Request) { - now := time.Now().UTC().Round(time.Millisecond) // Use the lowest common denominator - this is a legacy handler after all (otherwise would use application/json) @@ -138,7 +137,6 @@ func serveLongPoll(wrt http.ResponseWriter, req *http.Request) { enc.Encode(pkt) return - } // Existing session diff --git a/server/pres.go b/server/pres.go index 8c2a92d0f..b9d5a55de 100644 --- a/server/pres.go +++ b/server/pres.go @@ -198,7 +198,7 @@ func (t *Topic) presProcReq(fromUserID, what string, wantReply bool) string { } } - // log.Println("in-what:", debugWhat, "out-what", what, "from:", fromUserID, "to:", t.name, "reply:", replyAs) + // log.Println("out-what", what, "from:", fromUserID, "to:", t.name, "reply:", replyAs) // If requester's online status has not changed, do not reply, otherwise an endless loop will happen. // wantReply is needed to ensure unnecessary {pres} is not sent: @@ -222,11 +222,25 @@ func (t *Topic) presProcReq(fromUserID, what string, wantReply bool) string { // Case C: user agent change, "ua", ua // Case D: User updated 'public', "upd" func (t *Topic) presUsersOfInterest(what, ua string) { + parts := strings.Split(what, "+") + wantReply := parts[0] == "on" + goOffline := len(parts) > 1 && parts[1] == "dis" + // Push update to subscriptions - for topic := range t.perSubs { + for topic, psd := range t.perSubs { globals.hub.route <- &ServerComMessage{ - Pres: &MsgServerPres{Topic: "me", What: what, Src: t.name, UserAgent: ua, WantReply: (what == "on")}, + Pres: &MsgServerPres{ + Topic: "me", + What: what, + Src: t.name, + UserAgent: ua, + WantReply: wantReply}, rcptto: topic} + + if psd.online && goOffline { + psd.online = false + t.perSubs[topic] = psd + } } } diff --git a/server/topic.go b/server/topic.go index 0455876f1..ac9334e14 100644 --- a/server/topic.go +++ b/server/topic.go @@ -2531,12 +2531,12 @@ func (t *Topic) notifySubChange(uid, actor types.Uid, oldWant, oldGiven, } else if (newWant & newGiven).IsPresencer() && !(oldWant & oldGiven).IsPresencer() { // Subscription un-muted. - if t.cat == types.TopicCatMe { + // Notify subscriber of topic's online status. + if t.cat == types.TopicCatGrp { + t.presSingleUserOffline(uid, "?unkn+en", nilPresParams, "", false) + } else if t.cat == types.TopicCatMe { // User is visible online now, notify subscribers. t.presUsersOfInterest("on+en", t.userAgent) - } else { - // Notify subscriber of topic's online status. - t.presSingleUserOffline(uid, "?unkn+en", nilPresParams, "", false) } } From 4bde83932a701b60125012c748068d178ea9479d Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 3 Apr 2020 10:14:55 +0300 Subject: [PATCH 115/142] more informative logging, code formatting, readme restructured --- monitoring/exporter/README.md | 59 +++++++++++++----------- monitoring/exporter/influxdb_exporter.go | 27 ++++++++--- monitoring/exporter/main.go | 52 +++++++++++++-------- 3 files changed, 87 insertions(+), 51 deletions(-) diff --git a/monitoring/exporter/README.md b/monitoring/exporter/README.md index 261fd4226..8a0852727 100644 --- a/monitoring/exporter/README.md +++ b/monitoring/exporter/README.md @@ -1,15 +1,17 @@ # Tinode Metric Exporter -This is a simple service which reads JSON monitoring data exposed by Tinode server using [expvar](https://golang.org/pkg/expvar/) and re-publishes it in other formats. -Currently, supported are: -* [Prometheus](https://prometheus.io/) [exporter](https://prometheus.io/docs/instrumenting/exporters/) which exports data in [prometheus format](https://prometheus.io/docs/concepts/data_model/). -Note that the monitoring service is expected to pull/scrape data from Prometheus exporter. -* [InfluxDB](https://www.influxdata.com/) [exporter](https://docs.influxdata.com/influxdb/v1.7/tools/api/#write-http-endpoint), on the contrary, pushes data to its target backend. +This is a simple service which reads JSON monitoring data exposed by Tinode server using [expvar](https://golang.org/pkg/expvar/) and re-publishes it in other formats. Currently the supported formats are: + +* [InfluxDB](https://www.influxdata.com/) [exporter](https://docs.influxdata.com/influxdb/v1.7/tools/api/#write-http-endpoint) **pushes** data to its target backend. This is the default mode. +* [Prometheus](https://prometheus.io/) [exporter](https://prometheus.io/docs/instrumenting/exporters/) which exports data in [prometheus format](https://prometheus.io/docs/concepts/data_model/). The Prometheus monitoring service is expected to **pull/scrape** data from the exporter. ## Usage -Exporters are expected to run next to (pair with) Tinode servers: one Exporter per one Tinode server, i.e. a single Exporter provides metrics from a single Tinode server. -Currently, the Exporter is fully configured via command line flags. There are three sets of flags: +Exporters are intended to run next to (pair with) Tinode servers: one Exporter per one Tinode server, i.e. a single Exporter provides metrics from a single Tinode server. + +## Configuration + +The exporters are configured by command-line flags: ### Common flags * `serve_for` specifies which monitoring service the Exporter will gather metrics for; accepted values: `influxdb`, `prometheus`; default: `influxdb`. @@ -18,11 +20,6 @@ Currently, the Exporter is fully configured via command line flags. There are th * `instance` is the Exporter instance name (it may be exported to the upstream backend); default: `exporter`. * `metric_list` is a comma-separated list of metrics to export; default: `Version,LiveTopics,TotalTopics,LiveSessions,ClusterLeader,TotalClusterNodes,LiveClusterNodes,memstats.Alloc`. -### Prometheus -* `prom_namespace` is a prefix to use for metrics names. If you are monitoring multiple tinode instances you may want to use different namespaces; default: `tinode`. -* `prom_metrics_path` is the path under which to expose the metrics for scraping; default: `/metrics`. -* `prom_timeout` is the Tinode connection timeout in seconds in response to Prometheus scrapes; default: `15`. - ### InfluxDB * `influx_push_addr` is the address of InfluxDB target server where the data gets sent; default: `http://localhost:9999/write`. * `influx_db_version` is the version of InfluxDB (only 1.7 and 2.0 are supported); default: `1.7`. @@ -31,21 +28,7 @@ Currently, the Exporter is fully configured via command line flags. There are th * `influx_auth_token` - InfluxDB authentication token; no default value. * `influx_push_interval` - InfluxDB push interval in seconds; default: `30`. -## Examples -Run Prometheus Exporter as -``` -./exporter \ - --serve_for=prometheus \ - --tinode_addr=http://localhost:6060/stats/expvar \ - --listen_at=:6222 \ - --instance=exp-0 \ - --prom_namespace=tinode \ - --prom_metrics_path=/metrics \ - --prom_timeout=15 -``` - -This exporter will serve data at path /metrics, on port 6222. -Once running, configure your Prometheus monitoring installation to collect data from this exporter. +#### Example Run InfluxDB Exporter as ``` @@ -62,3 +45,25 @@ Run InfluxDB Exporter as ``` This exporter will push the collected metrics to the specified backend once every 30 seconds. + + +### Prometheus +* `prom_namespace` is a prefix to use for metrics names. If you are monitoring multiple tinode instances you may want to use different namespaces; default: `tinode`. +* `prom_metrics_path` is the path under which to expose the metrics for scraping; default: `/metrics`. +* `prom_timeout` is the Tinode connection timeout in seconds in response to Prometheus scrapes; default: `15`. + +#### Example +Run Prometheus Exporter as +``` +./exporter \ + --serve_for=prometheus \ + --tinode_addr=http://localhost:6060/stats/expvar \ + --listen_at=:6222 \ + --instance=exp-0 \ + --prom_namespace=tinode \ + --prom_metrics_path=/metrics \ + --prom_timeout=15 +``` + +This exporter will serve data at path /metrics, on port 6222. +Once running, configure your Prometheus monitoring installation to collect data from this exporter. diff --git a/monitoring/exporter/influxdb_exporter.go b/monitoring/exporter/influxdb_exporter.go index 568172177..bf657354b 100644 --- a/monitoring/exporter/influxdb_exporter.go +++ b/monitoring/exporter/influxdb_exporter.go @@ -3,9 +3,11 @@ package main import ( "bytes" "fmt" + "io/ioutil" "log" "net/http" "net/url" + "strings" "time" ) @@ -20,7 +22,9 @@ type InfluxDBExporter struct { } // NewInfluxDBExporter returns an initialized InfluxDB exporter. -func NewInfluxDBExporter(influxDBVersion, pushBaseAddress, organization, bucket, token, instance string, scraper *Scraper) *InfluxDBExporter { +func NewInfluxDBExporter(influxDBVersion, pushBaseAddress, organization, + bucket, token, instance string, scraper *Scraper) *InfluxDBExporter { + targetAddress := formPushTargetAddress(influxDBVersion, pushBaseAddress, organization, bucket) tokenHeader := formAuthorizationHeaderValue(influxDBVersion, token) return &InfluxDBExporter{ @@ -34,10 +38,10 @@ func NewInfluxDBExporter(influxDBVersion, pushBaseAddress, organization, bucket, } // Push scrapes metrics from Tinode server and pushes these metrics to InfluxDB. -func (e *InfluxDBExporter) Push() (string, error) { +func (e *InfluxDBExporter) Push() error { metrics, err := e.scraper.CollectRaw() if err != nil { - return "", err + return err } b := new(bytes.Buffer) ts := time.Now().UnixNano() @@ -46,15 +50,26 @@ func (e *InfluxDBExporter) Push() (string, error) { } req, err := http.NewRequest("POST", e.targetAddress, b) if err != nil { - return "", err + return err } req.Header.Add("Authorization", e.tokenHeader) resp, err := http.DefaultClient.Do(req) if err != nil { - return "", err + return err } defer resp.Body.Close() - return resp.Status, nil + + if resp.StatusCode >= 400 { + var body string + if rb, err := ioutil.ReadAll(resp.Body); err != nil { + body = err.Error() + } else { + body = strings.TrimSpace(string(rb)) + } + + return fmt.Errorf("HTTP %s: %s", resp.Status, body) + } + return nil } func formPushTargetAddress(influxDBVersion, baseAddr, organization, bucket string) string { diff --git a/monitoring/exporter/main.go b/monitoring/exporter/main.go index c4fe43833..d48da6d21 100644 --- a/monitoring/exporter/main.go +++ b/monitoring/exporter/main.go @@ -45,11 +45,17 @@ func main() { log.Printf("Tinode metrics exporter.") var ( - serveFor = flag.String("serve_for", "influxdb", "Monitoring service to gather metrics for. Available: influxdb, prometheus.") - tinodeAddr = flag.String("tinode_addr", "http://localhost:6060/stats/expvar", "Address of the Tinode instance to scrape.") - listenAt = flag.String("listen_at", ":6222", "Host name and port to listen for incoming requests on.") - metricList = flag.String("metric_list", "Version,LiveTopics,TotalTopics,LiveSessions,ClusterLeader,TotalClusterNodes,LiveClusterNodes,memstats.Alloc", "Comma-separated list of metrics to scrape and export.") - instance = flag.String("instance", "exporter", "Exporter instance name.") + serveFor = flag.String("serve_for", "influxdb", + "Monitoring service to gather metrics for. Available: influxdb, prometheus.") + tinodeAddr = flag.String("tinode_addr", "http://localhost:6060/stats/expvar", + "Address of the Tinode instance to scrape.") + listenAt = flag.String("listen_at", ":6222", + "Host name and port to listen for incoming requests on.") + metricList = flag.String("metric_list", + "Version,LiveTopics,TotalTopics,LiveSessions,ClusterLeader,TotalClusterNodes,LiveClusterNodes,memstats.Alloc", + "Comma-separated list of metrics to scrape and export.") + instance = flag.String("instance", "exporter", + "Exporter instance name.") // Prometheus-specific arguments. promNamespace = flag.String("prom_namespace", "tinode", "Prometheus namespace for metrics '_...'") @@ -57,12 +63,18 @@ func main() { promTimeout = flag.Int("prom_timeout", 15, "Tinode connection timeout in seconds in response to Prometheus scrapes.") // InfluxDB-specific arguments. - influxPushAddr = flag.String("influx_push_addr", "http://localhost:9999/write", "Address of InfluxDB target server where the data gets sent.") - influxDBVersion = flag.String("influx_db_version", "1.7", "Version of InfluxDB (only 1.7 and 2.0 are supported).") - influxOrganization = flag.String("influx_organization", "test", "InfluxDB organization to push metrics as.") - influxBucket = flag.String("influx_bucket", "test", "InfluxDB storage bucket to store data in (used only in InfluxDB 2.0).") - influxAuthToken = flag.String("influx_auth_token", "", "InfluxDB authentication token.") - influxPushInterval = flag.Int("influx_push_interval", 30, "InfluxDB push interval in seconds.") + influxPushAddr = flag.String("influx_push_addr", "http://localhost:9999/write", + "Address of InfluxDB target server where the data gets sent.") + influxDBVersion = flag.String("influx_db_version", "1.7", + "Version of InfluxDB (only 1.7 and 2.0 are supported).") + influxOrganization = flag.String("influx_organization", "test", + "InfluxDB organization to push metrics as.") + influxBucket = flag.String("influx_bucket", "test", + "InfluxDB storage bucket to store data in (used only in InfluxDB 2.0).") + influxAuthToken = flag.String("influx_auth_token", "", + "InfluxDB authentication token.") + influxPushInterval = flag.Int("influx_push_interval", 30, + "InfluxDB push interval in seconds.") ) flag.Parse() @@ -91,7 +103,7 @@ func main() { log.Fatal("Must specify --influx_bucket") } if *influxDBVersion != "1.7" && *influxDBVersion != "2.0" { - log.Fatal("Please, set --influx_db_version to either 1.7 or 2.0") + log.Fatal("The --influx_db_version must be either 1.7 or 2.0") } if *influxPushInterval > 0 && *influxPushInterval < minPushInterval { *influxPushInterval = minPushInterval @@ -118,6 +130,9 @@ func main() { }) metrics := strings.Split(*metricList, ",") + for i, m := range metrics { + metrics[i] = strings.TrimSpace(m) + } scraper := Scraper{address: *tinodeAddr, metrics: metrics} var serverTypeString string // Create exporters. @@ -141,15 +156,16 @@ func main() { ) case InfluxDB: serverTypeString = fmt.Sprintf("%s, version %s", *serveFor, *influxDBVersion) - influxDBExporter := NewInfluxDBExporter(*influxDBVersion, *influxPushAddr, *influxOrganization, *influxBucket, *influxAuthToken, *instance, &scraper) + influxDBExporter := NewInfluxDBExporter(*influxDBVersion, *influxPushAddr, *influxOrganization, *influxBucket, + *influxAuthToken, *instance, &scraper) if *influxPushInterval > 0 { go func() { interval := time.Duration(*influxPushInterval) * time.Second ch := time.Tick(interval) for { if _, ok := <-ch; ok { - if status, err := influxDBExporter.Push(); err != nil { - log.Printf("InfluxDB push failed (status '%s'), error: %s", status, err.Error()) + if err := influxDBExporter.Push(); err != nil { + log.Println("InfluxDB push failed:", err) } } else { return @@ -162,10 +178,10 @@ func main() { // Forces a data push. http.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) { var msg string - if http_status, err := influxDBExporter.Push(); err == nil { - msg = "ok - " + http_status + if err := influxDBExporter.Push(); err == nil { + msg = "HTTP 200 OK" } else { - msg = "fail - " + err.Error() + msg = err.Error() } w.Write([]byte(`Tinode Push From a4369009cd606831748602663afb4a9ed59da467 Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 5 Apr 2020 14:13:11 +0300 Subject: [PATCH 116/142] fix for #409 and probably for #400 --- server/validate/email/validate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/validate/email/validate.go b/server/validate/email/validate.go index 94c489063..60f6480e7 100644 --- a/server/validate/email/validate.go +++ b/server/validate/email/validate.go @@ -173,7 +173,7 @@ func (v *validator) Init(jsonconf string) error { // Optionally resolve paths against the location of this executable file. v.ValidationTemplFile = resolveTemplatePath(v.ValidationTemplFile) - v.ResetTemplFile = resolveTemplatePath(v.ValidationTemplFile) + v.ResetTemplFile = resolveTemplatePath(v.ResetTemplFile) // Paths to templates could be templates themselves: they may be language-dependent. var validationPathTempl, resetPathTempl *textt.Template From f9a0ae8921c10fbe1433fba967558b1451196ada Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 5 Apr 2020 20:37:30 +0300 Subject: [PATCH 117/142] adding tpng to docker --- docker/tinode/Dockerfile | 12 +++++++++--- docker/tinode/config.template | 5 ++--- docker/tinode/entrypoint.sh | 6 +++++- server/tinode.conf | 2 +- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index 147b33c2b..738189391 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -1,6 +1,6 @@ # Docker file builds an image with a tinode chat server. # -# In order to run the image you have to link it to a running database container. For example, to +# In order to run the image you have to link it to a running database container. For example, to # to use RethinkDB (named 'rethinkdb') and map the port where the tinode server accepts connections: # # $ docker run -p 6060:6060 -d --link rethinkdb \ @@ -83,6 +83,12 @@ ENV TLS_ENABLED=false # Disable push notifications by default. ENV FCM_PUSH_ENABLED=false +# Declare FCM-related vars +ENV FCM_API_KEY= +ENV FCM_APP_ID= +ENV FCM_SENDER_ID= +ENV FCM_PROJECT_ID= +ENV FCM_VAPID_KEY= # Enable Android-specific notifications by default. ENV FCM_INCLUDE_ANDROID_NOTIFICATION=true @@ -93,8 +99,8 @@ ENV TNPG_PUSH_ENABLED=false # Tinode Push Gateway authentication token. ENV TNPG_AUTH_TOKEN= -# Tinode Push Gateway user name. -ENV TNPG_USER= +# Tinode Push Gateway organization name as registered at console.tinode.co +ENV TNPG_ORG= # Use the target db by default. # When TARGET_DB is "alldbs", it is the user's responsibility diff --git a/docker/tinode/config.template b/docker/tinode/config.template index 76c66535c..498102f3f 100644 --- a/docker/tinode/config.template +++ b/docker/tinode/config.template @@ -109,15 +109,14 @@ "name":"tnpg", "config": { "enabled": $TNPG_PUSH_ENABLED, - "auth_token": "$TNPG_AUTH_TOKEN", - "user": "$TNPG_USER" + "token": "$TNPG_AUTH_TOKEN", + "org": "$TNPG_USER" } }, { "name":"fcm", "config": { "enabled": $FCM_PUSH_ENABLED, - "buffer": 1024, "project_id": "$FCM_PROJECT_ID", "credentials_file": "$FCM_CRED_FILE", "time_to_live": 3600, diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index 0fd201dbb..fdd970ba2 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -35,6 +35,10 @@ else FCM_PUSH_ENABLED=true fi + if [ ! -z "$TNPG_AUTH_TOKEN" ] ; then + TNPG_PUSH_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 @@ -60,7 +64,7 @@ if [ "$UPGRADE_DB" = "true" ] ; then fi # If push notifications are enabled, generate client-side firebase config file. -if [ ! -z "$FCM_PUSH_ENABLED" ] ; then +if [ ! -z "$FCM_PUSH_ENABLED" ] || [ ! -z "$TNPG_PUSH_ENABLED" ] ; then # Write client config to $STATIC_DIR/firebase-init.js cat > $STATIC_DIR/firebase-init.js <<- EOM const FIREBASE_INIT = { diff --git a/server/tinode.conf b/server/tinode.conf index 03de86509..c0a8a0925 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -383,7 +383,7 @@ "config": { // Disabled. Configure first then enable. "enabled": false, - // Name of the organization you registered at console.tinode.co. + // Name of the organization you registered at console.tinode.co. "org": "test", // Authentication token obtained from console.tinode.co "token": "jwt-security-token-obtained-from-console.tinode.co", From 785be0323dcee545c33b390384af7f01e8f9f440 Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 7 Apr 2020 09:06:21 +0300 Subject: [PATCH 118/142] update docs per #412 --- docs/API.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 3996fe15c..a59139e7a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -628,6 +628,8 @@ The user agent `ua` is expected to follow [RFC 7231 section 5.5.3](http://tools. Message `{acc}` creates users or updates `tags` or authentication credentials `scheme` and `secret` of exiting users. To create a new user set `user` to the string `new` optionally followed by any character sequence, e.g. `newr15gsr`. Either authenticated or anonymous session can send an `{acc}` message to create a new user. To update authentication data or validate a credential of the current user leave `user` unset. +The `{acc}` message **cannot** be used to modify `desc` of an existing user. Update user's `me` topic instead. + ```js acc: { id: "1a2b3", // string, client-provided message id, optional @@ -656,7 +658,7 @@ acc: { ], desc: { // object, user initialisation data closely matching that of table - // initialisation; optional + // initialisation; used only when creating an account; optional defacs: { auth: "JRWS", // string, default access mode for peer to peer conversations // between this user and other authenticated users From f7c8b0ff2b785c93d9a01d6a3aa1f5fea576e5ab Mon Sep 17 00:00:00 2001 From: or-else Date: Wed, 8 Apr 2020 09:05:53 +0300 Subject: [PATCH 119/142] minor doc correction --- docs/API.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index a59139e7a..42770409f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -628,7 +628,7 @@ The user agent `ua` is expected to follow [RFC 7231 section 5.5.3](http://tools. Message `{acc}` creates users or updates `tags` or authentication credentials `scheme` and `secret` of exiting users. To create a new user set `user` to the string `new` optionally followed by any character sequence, e.g. `newr15gsr`. Either authenticated or anonymous session can send an `{acc}` message to create a new user. To update authentication data or validate a credential of the current user leave `user` unset. -The `{acc}` message **cannot** be used to modify `desc` of an existing user. Update user's `me` topic instead. +The `{acc}` message **cannot** be used to modify `desc` or `cred` of an existing user. Update user's `me` topic instead. ```js acc: { From e0d4b17f84ddc45f7df7bb9dea50eaf555a0aaec Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 10 Apr 2020 18:46:05 +0300 Subject: [PATCH 120/142] workaroud for rethinkdb/rethinkdb-go/issues/486; a few other bug fixes --- server/db/rethinkdb/adapter.go | 14 +++++---- server/topic.go | 16 +++++++--- server/user.go | 53 ++++++++++++++++++++++++---------- 3 files changed, 59 insertions(+), 24 deletions(-) diff --git a/server/db/rethinkdb/adapter.go b/server/db/rethinkdb/adapter.go index 8cb0cf953..1a992c353 100644 --- a/server/db/rethinkdb/adapter.go +++ b/server/db/rethinkdb/adapter.go @@ -889,16 +889,20 @@ func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]stri return nil, err } - // Get the new tags - cursor, err := q.Field("Tags").Run(a.conn) + // Get the new tags. + // Using Pluck instead of Field because of https://github.com/rethinkdb/rethinkdb-go/issues/486 + cursor, err := q.Pluck("Tags").Run(a.conn) if err != nil { return nil, err } defer cursor.Close() - var tags []string - err = cursor.One(&tags) - return tags, err + var tagsField struct{ Tags []string } + err = cursor.One(&tagsField) + if err != nil { + return nil, err + } + return tagsField.Tags, nil } // UserGetByCred returns user ID for the given validated credential. diff --git a/server/topic.go b/server/topic.go index ac9334e14..eebe201d1 100644 --- a/server/topic.go +++ b/server/topic.go @@ -2104,7 +2104,7 @@ func (t *Topic) replySetCred(sess *Session, asUid types.Uid, authLevel auth.Leve _, tags, err = addCreds(asUid, creds, nil, sess.lang, tmpToken) } - if err == nil && len(tags) > 0 { + if tags != nil { t.tags = tags t.presSubsOnline("tags", "", nilPresParams, nilPresFilters, "") } @@ -2287,11 +2287,19 @@ func (t *Topic) replyDelCred(h *Hub, sess *Session, asUid types.Uid, authLvl aut sess.queueOut(ErrPermissionDenied(del.Id, t.original(asUid), now)) return errors.New("del.cred: invalid topic category") } + if del.Cred == nil || del.Cred.Method == "" { + sess.queueOut(ErrMalformed(del.Id, t.original(asUid), now)) + return errors.New("del.cred: missing method") + } tags, err := deleteCred(asUid, authLvl, del.Cred) - if err == nil { - t.tags = tags - t.presSubsOnline("tags", "", nilPresParams, nilPresFilters, "") + if tags != nil { + // Check if anything has been actuallt removed. + _, removed := stringSliceDelta(t.tags, tags) + if len(removed) > 0 { + t.tags = tags + t.presSubsOnline("tags", "", nilPresParams, nilPresFilters, "") + } } sess.queueOut(decodeStoreError(err, del.Id, del.Topic, now, nil)) return err diff --git a/server/user.go b/server/user.go index 19c5e4f3c..651eb2fcf 100644 --- a/server/user.go +++ b/server/user.go @@ -311,8 +311,10 @@ func updateUserAuth(msg *ClientComMessage, user *types.User, rec *auth.Rec) erro } // Tags may have been changed by authhdl.UpdateRecord, reset them. - // Can't do much with the error here, so ignoring it. - store.Users.UpdateTags(user.Uid(), nil, nil, rec.Tags) + // Can't do much with the error here, logging it but not returning. + if _, err = store.Users.UpdateTags(user.Uid(), nil, nil, rec.Tags); err != nil { + log.Println("updateUserAuth tags update failed:", err) + } return nil } @@ -322,9 +324,8 @@ func updateUserAuth(msg *ClientComMessage, user *types.User, rec *auth.Rec) erro // addCreds adds new credentials and re-send validation request for existing ones. It also adds credential-defined // tags if necessary. -// Returns methods validated in this call only, full set of tags. -func addCreds(uid types.Uid, creds []MsgCredClient, tags []string, lang string, - tmpToken []byte) ([]string, []string, error) { +// Returns methods validated in this call only. Returns either a full set of tags or nil for tags when tags are unchanged. +func addCreds(uid types.Uid, creds []MsgCredClient, extraTags []string, lang string, tmpToken []byte) ([]string, []string, error) { var validated []string for i := range creds { cr := &creds[i] @@ -346,20 +347,27 @@ func addCreds(uid types.Uid, creds []MsgCredClient, tags []string, lang string, // Generate tags for these confirmed credentials. if globals.validators[cr.Method].addToTags { - tags = append(tags, cr.Method+":"+cr.Value) + extraTags = append(extraTags, cr.Method+":"+cr.Value) } } } // Save tags potentially changed by the validator. - if len(tags) > 0 { - tags, _ = store.Users.UpdateTags(uid, tags, nil, nil) + if len(extraTags) > 0 { + if utags, err := store.Users.UpdateTags(uid, extraTags, nil, nil); err != nil { + extraTags = utags + } else { + log.Println("add cred tags update failed:", err) + } + } else { + extraTags = nil } - return validated, tags, nil + return validated, extraTags, nil } // validatedCreds returns the list of validated credentials including those validated in this call. -// Returns all validated methods including those validated earlier and now, full set of tags. +// Returns all validated methods including those validated earlier and now. +// Returns either a full set of tags or nil for tags if tags are unchanged. func validatedCreds(uid types.Uid, authLvl auth.Level, creds []MsgCredClient, errorOnFail bool) ([]string, []string, error) { // Check if credential validation is required. @@ -417,7 +425,14 @@ func validatedCreds(uid types.Uid, authLvl auth.Level, creds []MsgCredClient, er var tags []string if len(tagsToAdd) > 0 { // Save update to tags - tags, _ = store.Users.UpdateTags(uid, tagsToAdd, nil, nil) + if utags, err := store.Users.UpdateTags(uid, tagsToAdd, nil, nil); err == nil { + tags = utags + } else { + log.Println("validated creds tags update failed:", err) + tags = nil + } + } else { + tags = nil } var validated []string @@ -429,6 +444,7 @@ func validatedCreds(uid types.Uid, authLvl auth.Level, creds []MsgCredClient, er } // deleteCred deletes user's credential. +// Returns full set of remaining tags or nil if tags are unchanged. func deleteCred(uid types.Uid, authLvl auth.Level, cred *MsgCredClient) ([]string, error) { vld := store.GetValidator(cred.Method) if vld == nil || cred.Value == "" { @@ -447,7 +463,6 @@ func deleteCred(uid types.Uid, authLvl auth.Level, cred *MsgCredClient) ([]strin // If credential is required, make sure the method remains validated even after this credential is deleted. if isRequired { - // There could be multiple validated credentials for the same method thus we are getting a map with count // for each method. @@ -457,10 +472,10 @@ func deleteCred(uid types.Uid, authLvl auth.Level, cred *MsgCredClient) ([]strin return nil, err } - // Check if it's OK to delete: there is another validated value. + // Check if it's OK to delete: there is another validated value or this value is not validated in the first place. var okTodelete bool for _, cr := range allCreds { - if cr.Done && cr.Value != cred.Value { + if (cr.Done && cr.Value != cred.Value) || (!cr.Done && cr.Value == cred.Value) { okTodelete = true break } @@ -482,8 +497,16 @@ func deleteCred(uid types.Uid, authLvl auth.Level, cred *MsgCredClient) ([]strin var tags []string if globals.validators[cred.Method].addToTags { // This error should not be returned to user. - tags, _ = store.Users.UpdateTags(uid, nil, []string{cred.Method + ":" + cred.Value}, nil) + if utags, err := store.Users.UpdateTags(uid, nil, []string{cred.Method + ":" + cred.Value}, nil); err == nil { + tags = utags + } else { + log.Println("delete cred: failed to update tags:", err) + tags = nil + } + } else { + tags = nil } + return tags, nil } From 16313e8998c4bac3082541a4917b93b859650c43 Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 12 Apr 2020 18:34:46 +0300 Subject: [PATCH 121/142] typo in comment --- server/topic.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/topic.go b/server/topic.go index eebe201d1..1758e98c1 100644 --- a/server/topic.go +++ b/server/topic.go @@ -2294,7 +2294,7 @@ func (t *Topic) replyDelCred(h *Hub, sess *Session, asUid types.Uid, authLvl aut tags, err := deleteCred(asUid, authLvl, del.Cred) if tags != nil { - // Check if anything has been actuallt removed. + // Check if anything has been actually removed. _, removed := stringSliceDelta(t.tags, tags) if len(removed) > 0 { t.tags = tags From 31f0c29108afb83578887d9e5d70ba3a1dbf68f3 Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 14 Apr 2020 10:53:41 +0300 Subject: [PATCH 122/142] allow "acs" notifications even if topic is muted --- server/topic.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/topic.go b/server/topic.go index 1758e98c1..47a93b486 100644 --- a/server/topic.go +++ b/server/topic.go @@ -447,8 +447,8 @@ func (t *Topic) run(hub *Hub) { // Check presence filters pud := t.perUser[pssd.uid] - // Send "gone" notification even if the topic is muted. - if (!(pud.modeGiven & pud.modeWant).IsPresencer() && msg.Pres.What != "gone") || + // Send "gone" and "acs" notifications even if the topic is muted. + if (!(pud.modeGiven & pud.modeWant).IsPresencer() && msg.Pres.What != "gone" && msg.Pres.What != "acs") || (msg.Pres.FilterIn != 0 && int(pud.modeGiven&pud.modeWant)&msg.Pres.FilterIn == 0) || (msg.Pres.FilterOut != 0 && int(pud.modeGiven&pud.modeWant)&msg.Pres.FilterOut != 0) { continue From d047cd6a0049e4ec6d92e33aa67a5853cb2a43ca Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 14 Apr 2020 14:08:04 +0300 Subject: [PATCH 123/142] missing state assignment in rdb and mongo adapters --- server/db/mongodb/adapter.go | 6 ++++-- server/db/rethinkdb/adapter.go | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/server/db/mongodb/adapter.go b/server/db/mongodb/adapter.go index dcbdfdb15..163256262 100644 --- a/server/db/mongodb/adapter.go +++ b/server/db/mongodb/adapter.go @@ -1228,6 +1228,7 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ( } else { topq = append(topq, sub.Topic) } + sub.Private = unmarshalBsonD(sub.Private) join[sub.Topic] = sub } cur.Close(a.ctx) @@ -1251,9 +1252,9 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ( } sub = join[top.Id] sub.ObjHeader.MergeTimes(&top.ObjHeader) - sub.SetSeqId(top.SeqId) + sub.SetState(top.State) sub.SetTouchedAt(top.TouchedAt) - sub.Private = unmarshalBsonD(sub.Private) + sub.SetSeqId(top.SeqId) if t.GetTopicCat(sub.Topic) == t.TopicCatGrp { // all done with a grp topic sub.SetPublic(unmarshalBsonD(top.Public)) @@ -1286,6 +1287,7 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ( uid2 := t.ParseUid(usr.Id) if sub, ok := join[uid.P2PName(uid2)]; ok { sub.ObjHeader.MergeTimes(&usr.ObjHeader) + sub.SetState(usr.State) sub.SetPublic(unmarshalBsonD(usr.Public)) sub.SetWith(uid2.UserId()) sub.SetDefaultAccess(usr.Access.Auth, usr.Access.Anon) diff --git a/server/db/rethinkdb/adapter.go b/server/db/rethinkdb/adapter.go index 1a992c353..fbc65ce27 100644 --- a/server/db/rethinkdb/adapter.go +++ b/server/db/rethinkdb/adapter.go @@ -1112,8 +1112,9 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ( for cursor.Next(&top) { sub = join[top.Id] sub.ObjHeader.MergeTimes(&top.ObjHeader) - sub.SetSeqId(top.SeqId) + sub.SetState(top.State) sub.SetTouchedAt(top.TouchedAt) + sub.SetSeqId(top.SeqId) if t.GetTopicCat(sub.Topic) == t.TopicCatGrp { // all done with a grp topic sub.SetPublic(top.Public) @@ -1142,6 +1143,7 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ( uid2 := t.ParseUid(usr.Id) if sub, ok := join[uid.P2PName(uid2)]; ok { sub.ObjHeader.MergeTimes(&usr.ObjHeader) + sub.SetState(usr.State) sub.SetPublic(usr.Public) sub.SetWith(uid2.UserId()) sub.SetDefaultAccess(usr.Access.Auth, usr.Access.Anon) From 0a312077d6c19d1305bcaf56fbd58f1662854361 Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 14 Apr 2020 18:28:31 +0300 Subject: [PATCH 124/142] don't attempt to generate default mongo _id --- server/store/types/types.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/server/store/types/types.go b/server/store/types/types.go index acd3fd604..9d209e143 100644 --- a/server/store/types/types.go +++ b/server/store/types/types.go @@ -299,9 +299,8 @@ func ParseP2P(p2p string) (uid1, uid2 Uid, err error) { // ObjHeader is the header shared by all stored objects. type ObjHeader struct { // using string to get around rethinkdb's problems with uint64; - // `bson:"_id"` tag is for mongodb to use as primary key '_id' - // 'omitempty' causes mongodb automaticaly create "_id" field if field not set explicitly - Id string `bson:"_id,omitempty"` + // `bson:"_id"` tag is for mongodb to use as primary key '_id'. + Id string `bson:"_id"` id Uid CreatedAt time.Time UpdatedAt time.Time From abb2a7b7b51060edf39afe491fa447ac0c7acee9 Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 14 Apr 2020 18:33:58 +0300 Subject: [PATCH 125/142] when deleting topic use del.Hard instead of defaulting to true --- server/hub.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/server/hub.go b/server/hub.go index 55f13a74a..7e8ea90db 100644 --- a/server/hub.go +++ b/server/hub.go @@ -206,7 +206,8 @@ func (h *Hub) run() { log.Println("hub: topic's broadcast queue is full", dst.name) } } - } else if (strings.HasPrefix(msg.rcptto, "usr") || strings.HasPrefix(msg.rcptto, "grp")) && globals.cluster.isRemoteTopic(msg.rcptto) { + } else if (strings.HasPrefix(msg.rcptto, "usr") || strings.HasPrefix(msg.rcptto, "grp")) && + globals.cluster.isRemoteTopic(msg.rcptto) { // It is a remote topic. if err := globals.cluster.routeToTopicIntraCluster(msg.rcptto, msg); err != nil { log.Printf("hub: routing to '%s' failed", msg.rcptto) @@ -364,7 +365,7 @@ func (h *Hub) topicUnreg(sess *Session, topic string, msg *ClientComMessage, rea // Case 1.1.1: requester is the owner or last sub in a p2p topic t.markPaused(true) - if err := store.Topics.Delete(topic, true); err != nil { + if err := store.Topics.Delete(topic, msg.Del.Hard); err != nil { t.markPaused(false) sess.queueOut(ErrUnknown(msg.id, msg.topic, now)) return err @@ -422,7 +423,7 @@ func (h *Hub) topicUnreg(sess *Session, topic string, msg *ClientComMessage, rea if tcat == types.TopicCatP2P && len(subs) < 2 { // This is a P2P topic and fewer than 2 subscriptions, delete the entire topic - if err := store.Topics.Delete(topic, true); err != nil { + if err := store.Topics.Delete(topic, msg.Del.Hard); err != nil { sess.queueOut(ErrUnknown(msg.id, msg.topic, now)) return err } @@ -454,7 +455,7 @@ func (h *Hub) topicUnreg(sess *Session, topic string, msg *ClientComMessage, rea } else { // Case 1.2.1.1: owner, delete the group topic from db. // Only group topics have owners. - if err := store.Topics.Delete(topic, true); err != nil { + if err := store.Topics.Delete(topic, msg.Del.Hard); err != nil { sess.queueOut(ErrUnknown(msg.id, msg.topic, now)) return err } From db431ba8429a54eb5d68daf79682adf0a6419ff6 Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 14 Apr 2020 18:42:44 +0300 Subject: [PATCH 126/142] fix for the first crash in #420 --- server/cluster.go | 20 ++++++++++++++++---- server/hub.go | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/server/cluster.go b/server/cluster.go index 81032de04..9bfae44b9 100644 --- a/server/cluster.go +++ b/server/cluster.go @@ -408,13 +408,21 @@ func (Cluster) Proxy(msg *ClusterResp, unused *bool) error { func (c *Cluster) Route(msg *ClusterReq, rejected *bool) error { *rejected = false if msg.Signature != c.ring.Signature() { - log.Println("cluster Route: session signature mismatch", msg.Sess.Sid) + sid := "" + if msg.Sess != nil { + sid = msg.Sess.Sid + } + log.Println("cluster Route: session signature mismatch", sid) *rejected = true return nil } if msg.SrvMsg == nil { + sid := "" + if msg.Sess != nil { + sid = msg.Sess.Sid + } // TODO: maybe panic here. - log.Println("cluster Route: nil server message", msg.Sess.Sid) + log.Println("cluster Route: nil server message", sid) *rejected = true return nil } @@ -606,7 +614,7 @@ func (c *Cluster) routeToTopic(msg *ClientComMessage, topic string, sess *Sessio } // Forward server response message to the node that owns topic. -func (c *Cluster) routeToTopicIntraCluster(topic string, msg *ServerComMessage) error { +func (c *Cluster) routeToTopicIntraCluster(topic string, msg *ServerComMessage, sess *Session) error { n := c.nodeForTopic(topic) if n == nil { return errors.New("node for topic not found (intra)") @@ -617,8 +625,12 @@ func (c *Cluster) routeToTopicIntraCluster(topic string, msg *ServerComMessage) Signature: c.ring.Signature(), Fingerprint: c.fingerprint, RcptTo: topic, - SrvMsg: msg} + SrvMsg: msg, + } + if sess != nil { + req.Sess = &ClusterSess{Sid: sess.sid} + } return n.route(req) } diff --git a/server/hub.go b/server/hub.go index 7e8ea90db..6e86720b8 100644 --- a/server/hub.go +++ b/server/hub.go @@ -209,7 +209,7 @@ func (h *Hub) run() { } else if (strings.HasPrefix(msg.rcptto, "usr") || strings.HasPrefix(msg.rcptto, "grp")) && globals.cluster.isRemoteTopic(msg.rcptto) { // It is a remote topic. - if err := globals.cluster.routeToTopicIntraCluster(msg.rcptto, msg); err != nil { + if err := globals.cluster.routeToTopicIntraCluster(msg.rcptto, msg, msg.sess); err != nil { log.Printf("hub: routing to '%s' failed", msg.rcptto) } } else if msg.Pres == nil && msg.Info == nil { From 00a596440ed1150db25dd91aec14bc306b048c9c Mon Sep 17 00:00:00 2001 From: or-else Date: Thu, 16 Apr 2020 11:23:29 +0300 Subject: [PATCH 127/142] reply with InfoNoAction if credential being deleted is not found --- server/topic.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/topic.go b/server/topic.go index 47a93b486..a209f7b06 100644 --- a/server/topic.go +++ b/server/topic.go @@ -2299,9 +2299,13 @@ func (t *Topic) replyDelCred(h *Hub, sess *Session, asUid types.Uid, authLvl aut if len(removed) > 0 { t.tags = tags t.presSubsOnline("tags", "", nilPresParams, nilPresFilters, "") + sess.queueOut(decodeStoreError(err, del.Id, del.Topic, now, nil)) + } else { + sess.queueOut(InfoNoAction(del.Id, del.Topic, now)) } + } else { + sess.queueOut(decodeStoreError(err, del.Id, del.Topic, now, nil)) } - sess.queueOut(decodeStoreError(err, del.Id, del.Topic, now, nil)) return err } From 4da4a465759c56c02f21fae9fd609383ad2c553a Mon Sep 17 00:00:00 2001 From: or-else Date: Thu, 16 Apr 2020 12:29:18 +0300 Subject: [PATCH 128/142] respond with InfoNoAction to delete requests of credential is not found --- server/db/mongodb/adapter.go | 22 ++++++++++++++++++---- server/db/mysql/adapter.go | 31 +++++++++++++++++++++++++------ server/db/rethinkdb/adapter.go | 21 +++++++++++++++++---- server/topic.go | 11 ++++++----- server/user.go | 4 ++++ 5 files changed, 70 insertions(+), 19 deletions(-) diff --git a/server/db/mongodb/adapter.go b/server/db/mongodb/adapter.go index 163256262..17a6af9c2 100644 --- a/server/db/mongodb/adapter.go +++ b/server/db/mongodb/adapter.go @@ -601,7 +601,7 @@ func (a *adapter) UserDelete(uid t.Uid, hard bool) error { } // Delete credentials. - if err = a.credDel(sc, uid, "", ""); err != nil { + if err = a.credDel(sc, uid, "", ""); err != nil && err != t.ErrNotFound { return err } @@ -921,7 +921,12 @@ func (a *adapter) credDel(ctx context.Context, uid t.Uid, method, value string) filter["value"] = value } } else { - _, err := credCollection.DeleteMany(ctx, filter) + res, err := credCollection.DeleteMany(ctx, filter) + if err == nil { + if res.DeletedCount == 0 { + err = t.ErrNotFound + } + } return err } @@ -930,12 +935,21 @@ func (a *adapter) credDel(ctx context.Context, uid t.Uid, method, value string) hardDeleteFilter["$or"] = b.A{ b.M{"done": true}, b.M{"retries": 0}} - if _, err := credCollection.DeleteMany(ctx, hardDeleteFilter); err != nil { + res, err := credCollection.DeleteMany(ctx, hardDeleteFilter) + if err != nil { return err } + if res.DeletedCount > 0 { + return nil + } // Soft-delete all other values. - _, err := credCollection.UpdateMany(ctx, filter, b.M{"$set": b.M{"deletedat": t.TimeNow()}}) + res, err = credCollection.UpdateMany(ctx, filter, b.M{"$set": b.M{"deletedat": t.TimeNow()}}) + if err == nil { + if res.DeletedCount == 0 { + err = t.ErrNotFound + } + } return err } diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index 6505d0350..eb03c7d95 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -905,7 +905,7 @@ func (a *adapter) UserDelete(uid t.Uid, hard bool) error { } // Delete all credentials. - if err = credDel(tx, uid, "", ""); err != nil { + if err = credDel(tx, uid, "", ""); err != nil && err != t.ErrNotFound { return err } @@ -2342,8 +2342,10 @@ func deviceDelete(tx *sqlx.Tx, uid t.Uid, deviceID string) error { res, err = tx.Exec("DELETE FROM devices WHERE userid=? AND hash=?", store.DecodeUid(uid), deviceHasher(deviceID)) } - if count, _ := res.RowsAffected(); count == 0 && err == nil { - err = t.ErrNotFound + if err == nil { + if count, _ := res.RowsAffected(); count == 0 { + err = t.ErrNotFound + } } return err @@ -2465,19 +2467,36 @@ func credDel(tx *sqlx.Tx, uid t.Uid, method, value string) error { } } + var err error + var res sql.Result if method == "" { - _, err := tx.Exec("DELETE FROM credentials"+constraints, args...) + // Case 1 + res, err = tx.Exec("DELETE FROM credentials"+constraints, args...) + if err == nil { + if count, _ := res.RowsAffected(); count == 0 { + err = t.ErrNotFound + } + } return err } // Case 2.1 - if _, err := tx.Exec("DELETE FROM credentials"+constraints+" AND (done=true OR retries=0)", args...); err != nil { + res, err = tx.Exec("DELETE FROM credentials"+constraints+" AND (done=true OR retries=0)", args...) + if err != nil { return err } + if count, _ := res.RowsAffected(); count > 0 { + return nil + } // Case 2.2 args = append([]interface{}{t.TimeNow()}, args...) - _, err := tx.Exec("UPDATE credentials SET deletedat=?"+constraints, args...) + res, err = tx.Exec("UPDATE credentials SET deletedat=?"+constraints, args...) + if err == nil { + if count, _ := res.RowsAffected(); count >= 0 { + err = t.ErrNotFound + } + } return err } diff --git a/server/db/rethinkdb/adapter.go b/server/db/rethinkdb/adapter.go index fbc65ce27..d3b0be8e3 100644 --- a/server/db/rethinkdb/adapter.go +++ b/server/db/rethinkdb/adapter.go @@ -772,7 +772,7 @@ func (a *adapter) UserDelete(uid t.Uid, hard bool) error { } // Delete credentials. - if err = a.CredDel(uid, "", ""); err != nil { + if err = a.CredDel(uid, "", ""); err != nil && err != t.ErrNotFound { return err } // And finally delete the user. @@ -2069,18 +2069,31 @@ func (a *adapter) CredDel(uid t.Uid, method, value string) error { } if method == "" { - _, err := q.Delete().RunWrite(a.conn) + res, err := q.Delete().RunWrite(a.conn) + if err == nil { + if res.Deleted == 0 { + err = t.ErrNotFound + } + } return err } // Hard-delete all confirmed values or values with no attempts at confirmation. - _, err := q.Filter(rdb.Or(rdb.Row.Field("Done").Eq(true), rdb.Row.Field("Retries").Eq(0))).Delete().RunWrite(a.conn) + res, err := q.Filter(rdb.Or(rdb.Row.Field("Done").Eq(true), rdb.Row.Field("Retries").Eq(0))).Delete().RunWrite(a.conn) if err != nil { return err } + if res.Deleted > 0 { + return nil + } // Soft-delete all other values. - _, err = q.Update(map[string]interface{}{"DeletedAt": t.TimeNow()}).RunWrite(a.conn) + res, err = q.Update(map[string]interface{}{"DeletedAt": t.TimeNow()}).RunWrite(a.conn) + if err == nil { + if res.Deleted == 0 { + err = t.ErrNotFound + } + } return err } diff --git a/server/topic.go b/server/topic.go index a209f7b06..ddd53c5ae 100644 --- a/server/topic.go +++ b/server/topic.go @@ -2299,13 +2299,14 @@ func (t *Topic) replyDelCred(h *Hub, sess *Session, asUid types.Uid, authLvl aut if len(removed) > 0 { t.tags = tags t.presSubsOnline("tags", "", nilPresParams, nilPresFilters, "") - sess.queueOut(decodeStoreError(err, del.Id, del.Topic, now, nil)) - } else { - sess.queueOut(InfoNoAction(del.Id, del.Topic, now)) } - } else { - sess.queueOut(decodeStoreError(err, del.Id, del.Topic, now, nil)) + } else if err == nil { + sess.queueOut(InfoNoAction(del.Id, del.Topic, now)) + return nil } + + sess.queueOut(decodeStoreError(err, del.Id, del.Topic, now, nil)) + return err } diff --git a/server/user.go b/server/user.go index 651eb2fcf..b7d853d3f 100644 --- a/server/user.go +++ b/server/user.go @@ -490,6 +490,10 @@ func deleteCred(uid types.Uid, authLvl auth.Level, cred *MsgCredClient) ([]strin // The credential is either not required or more than one credential is validated for the given method. err := vld.Remove(uid, cred.Value) if err != nil { + if err == types.ErrNotFound { + // Credential is not deleted because it's not found + err = nil + } return nil, err } From 005e53fed9dae84513b49aa2273bd69e34a619e5 Mon Sep 17 00:00:00 2001 From: or-else Date: Thu, 16 Apr 2020 17:58:05 +0300 Subject: [PATCH 129/142] documentation updates --- README.md | 8 ++++---- server/push/tnpg/README.md | 9 ++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ca8ea4d04..1f7d84f14 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,9 @@ When you register a new account you are asked for an email address to send valid * Persistent message store, paginated message history. * Javascript bindings with no external dependencies. * Java bindings (dependencies: [Jackson](https://github.com/FasterXML/jackson), [Java-Websocket](https://github.com/TooTallNate/Java-WebSocket)). Suitable for Android but with no Android SDK dependencies. -* Websocket, long polling, and [gRPC](https://grpc.io/) over TCP transports. +* Websocket, long polling, and [gRPC](https://grpc.io/) over TCP or Unix sockets. * JSON or [protobuf version 3](https://developers.google.com/protocol-buffers/) wire protocols. -* [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security) with [Letsencrypt](https://letsencrypt.org/) or conventional certificates. +* Optional built-in [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security) with [Letsencrypt](https://letsencrypt.org/) or conventional certificates. * User search/discovery. * Rich formatting of messages, markdown-style: \*style\* → **style**. * Inline images and file attachments. @@ -86,9 +86,9 @@ When you register a new account you are asked for an email address to send valid * Support for client-side data caching. * Ability to block unwanted communication server-side. * Anonymous users (important for use cases related to tech support over chat). -* Push notifications using [FCM](https://firebase.google.com/docs/cloud-messaging/). +* Push notifications using [FCM](https://firebase.google.com/docs/cloud-messaging/) or [TNPG](server/push/tnpg/). * Storage and out of band transfer of large objects like video files using local file system or Amazon S3. -* Plugins to extend functionality, for example to enable chatbots. +* Plugins to extend functionality, for example, to enable chatbots. ### Planned diff --git a/server/push/tnpg/README.md b/server/push/tnpg/README.md index 542a026e2..b99384d79 100644 --- a/server/push/tnpg/README.md +++ b/server/push/tnpg/README.md @@ -3,9 +3,9 @@ This is push notifications adapter which communicates with Tinode Push Gateway (TNPG). TNPG is a proprietary service intended to simplify deployment of on-premise installations. -Deploying a Tinode server without TNPG requires [configuring Google FCM](../fcm/) with your own credentials including recompiling mobile clients and releasing them to PlayStore and AppStore under your own accounts which is usually time consuming and relatively complex. +Deploying a Tinode server without TNPG requires [configuring Google FCM](../fcm/) with your own credentials, recompiling Android and iOS clients, releasing them to PlayStore and AppStore under your own accounts. It's usually time consuming and relatively complex. -TNPG solves this problem by allowing you to send push notifications on behalf of Tinode. Internally it uses Google FCM and as such supports the same platforms as FCM. The main advantage of using TNPG over FCM is simplicity of configuration: mobile clients do not need to be recompiled, all is needed is a configuration update on the server. +TNPG solves this problem by allowing you to send push notifications on behalf of Tinode: you hand a notification over to Tinode, Tinode sends it to client using its own credentials and certificates. Internally it uses [Google FCM](https://firebase.google.com/docs/cloud-messaging/) and as such supports the same platforms as FCM. The main advantage of using TNPG over FCM is simplicity of configuration: mobile clients don't have to be recompiled, all is needed is a configuration update on the server. ## Configuring TNPG adapter @@ -15,8 +15,7 @@ TNPG solves this problem by allowing you to send push notifications on behalf of 2. Get the TPNG token from the _On premise_ section by following the instructions there. ### Configure the server - -Update the server config [`tinode.conf`](../server/tinode.conf#L384), section `"push"` -> `"name": "tnpg"`: +Update the server config [`tinode.conf`](../../tinode.conf#L384), section `"push"` -> `"name": "tnpg"`: ```js { "enabled": true, @@ -24,4 +23,4 @@ Update the server config [`tinode.conf`](../server/tinode.conf#L384), section `" "token": "SoMe_LonG.RaNDoM-StRiNg.12345" // authentication token obtained from console.tinode.co } ``` -Make sure the `fcm` section is disabled `"enabled": false`. +Make sure the `fcm` section is disabled `"enabled": false` or removed altogether. From c1a6e4c875170faf188a53f6f0bf8f7f175bc173 Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 17 Apr 2020 15:29:59 +0300 Subject: [PATCH 130/142] mongo adapter fix --- server/db/mongodb/adapter.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/server/db/mongodb/adapter.go b/server/db/mongodb/adapter.go index 17a6af9c2..c1ceca89f 100644 --- a/server/db/mongodb/adapter.go +++ b/server/db/mongodb/adapter.go @@ -935,18 +935,16 @@ func (a *adapter) credDel(ctx context.Context, uid t.Uid, method, value string) hardDeleteFilter["$or"] = b.A{ b.M{"done": true}, b.M{"retries": 0}} - res, err := credCollection.DeleteMany(ctx, hardDeleteFilter) - if err != nil { + if res, err := credCollection.DeleteMany(ctx, hardDeleteFilter); err != nil { return err - } - if res.DeletedCount > 0 { + } else if res.DeletedCount > 0 { return nil } // Soft-delete all other values. - res, err = credCollection.UpdateMany(ctx, filter, b.M{"$set": b.M{"deletedat": t.TimeNow()}}) + res, err := credCollection.UpdateMany(ctx, filter, b.M{"$set": b.M{"deletedat": t.TimeNow()}}) if err == nil { - if res.DeletedCount == 0 { + if res.ModifiedCount == 0 { err = t.ErrNotFound } } From 32527a4943a0387745df856df762812eb2f8214e Mon Sep 17 00:00:00 2001 From: or-else Date: Fri, 17 Apr 2020 18:10:44 +0300 Subject: [PATCH 131/142] add explanation of the file descriptors limit on linux --- docs/faq.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/faq.md b/docs/faq.md index 02e2ad5f7..36bfa8c1f 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -68,3 +68,7 @@ USE 'tinode'; UPDATE auth SET authlvl=30 WHERE uname='basic:login-of-the-user-to-make-root'; ``` The test database has a stock user `xena` which has root access. + + +### Q: Once the number of connection reaches about 1000 per node, all kinds of problems start. Is this a bug?
+**A**: It is likely not a bug. To ensure good server performance Linux limits the total number of open file descriptors (live network connections, open files) for each process at the kernel level. The default limit is usually 1024. There are other possible restrictions on the number of file descriptors. The problems you are experiencing are likely caused by exceeding one of the OS limits. Please seek assistance of a system administrator. From 58d0c11498349a7b3574afcf19011d27846b1382 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 18 Apr 2020 10:51:34 +0300 Subject: [PATCH 132/142] doc updates --- docs/faq.md | 2 +- docs/ios-account.png | Bin 97075 -> 111280 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/faq.md b/docs/faq.md index 36bfa8c1f..07f55230e 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -71,4 +71,4 @@ The test database has a stock user `xena` which has root access. ### Q: Once the number of connection reaches about 1000 per node, all kinds of problems start. Is this a bug?
-**A**: It is likely not a bug. To ensure good server performance Linux limits the total number of open file descriptors (live network connections, open files) for each process at the kernel level. The default limit is usually 1024. There are other possible restrictions on the number of file descriptors. The problems you are experiencing are likely caused by exceeding one of the OS limits. Please seek assistance of a system administrator. +**A**: It is likely not a bug. To ensure good server performance Linux limits the total number of open file descriptors (live network connections, open files) for each process at the kernel level. The default limit is usually 1024. There are other possible restrictions on the number of file descriptors. The problems you are experiencing are likely caused by exceeding one of the Linux-imposed limits. Please seek assistance of a system administrator. diff --git a/docs/ios-account.png b/docs/ios-account.png index 8294ca91aca07fec67ad23f0c3b9440b241a8194..fca4ee2b62df61e666e6b26d90078fc7faa3a504 100644 GIT binary patch literal 111280 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%0D`DilWk0k6g9E({+>`=$nc^?U7c1Y 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 literal 97075 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⋘g76KduymuI=`+0#ZS1Jf zMs;zCPfTOQzB1a4G>iaBUO`XlT`HQsPB|ZP=!zt5K4X&}SdIBOXjvXh7i(sh4y@WO z+mUBe>kT%_lZ=`#8M$RLQU*72m4Zw1d6=r+vE-ubCUgHs-+v88l-cJI>$+avq)`!i zvDu#4pmv3e%<5K04W>5xz`4Z^VN18Z8T1)fM&}+0DI@sg`@yBYYfK3(X_SYa;1;C< zv+B--Ot4FT`DB&SsxGu7A2c;%Usxiy%qB#P>L zs&lu09adc+vGldLU-I+J4x!e3!t zLKK}`d`ZfY<(8yM=u${Lbh|_O;myDQid9jo;2%kWtl-hiEcn?%fr1EC6qr2QVpa8| zCf4u$LplpxpJ}v7wM$#hDmVwyy!y3pN9VSubGEr3>q^KXC}tt$A*d~3;bBbDNM_mO z6C)B^Fl|tnLnm%n4yw>V_V6QQ)o!o|R0pyw@~KW{GiZfp`+TTulY9J!DaK03K+)pA zI=W;MUQ_hi!_L|sgA@F43bEh}P&@*(DI(s5oSZ~&LPTXX%Le&WEFXw=)_1PzzN=mv zWhenH$0~p&b2=5`x3ZbzU&Ci=>~Ih}FOxj=BbsQ(IOmlL{&or z2jUw+s}u(yGK!G9*QZ9)wq60=<|%Bg72kCG%=jgvm|zM_6sQ+}k8GAcF9VPa!C}p; zt&)5|othzIXxFas0Rv+IJdSQJY}+}eQl$L}k7Jo|rL@C6gPf%Tcj()E05GUvaIfB5 ztCtE(_OVmyEjL1P{zUm2BJ3)P8$FKXpM`sX+UT3gVXdX3pQ9_(GWM5EkRaB!s1p>k%e=u3>o!(ltQ*e}rYo76kZnKV zB4m1aK`**mTJ>YHSC;K`dxr`pRSNfeu8;7(bVxX1F1Xq-dOfYAg{FX4^ce*4#i>E0{wQUapEQHO<$0R6rF3!fI(w7P!C~HHc74OJ<6^za{cfg4`mfl3!#gZWBZmK#rrx@SoGCNe8Vb9&pU>S- z1x@A7%&v3d_SiOxW{Wa7lBhtdI|Du$RIMv5vl*MvB18Kg)XG05?UWNS5$68S^rVp; zYC`RPKR8aEV2>@}doynO7LWR0=lMi<{#~JlihG7z;GJJP#5s&7cD&*1kL|=h-k~D0 zY?8vAs2sosO-IX&N&LvJI-EGYG%ia`8MrTm2j0+~!4#G+O&cwzf`dFnNSwj~OTt3i zpP6ZUZ7=ZH;C62xZSQAQm(NdF%r6G3r8b{b!~oO{)k%ky8lA&iXjpOx(US#qdq+nCu6Ys2iWdAYLjW)qZOB)Ho7Nb*8V|hD`kvqM}FWaVJM=%pVYHL_fpf z;rty|p2sk9(g4o@=V$EcQIc3#9dLA7FIGnhogb&ZKOa*?6~pXl+1

    !|4(Q<3>Om!c90Jt_x$QBLj7t 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¿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 Date: Sun, 19 Apr 2020 10:56:42 +0300 Subject: [PATCH 133/142] create a copy of ServerComMessage for using in a cluster --- server/datamodel.go | 69 +++++++++++++++++++++++++++++++++++++++++++++ server/session.go | 5 ++-- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/server/datamodel.go b/server/datamodel.go index f1044d475..49c6e6ef3 100644 --- a/server/datamodel.go +++ b/server/datamodel.go @@ -439,6 +439,15 @@ type MsgServerCtrl struct { Timestamp time.Time `json:"ts"` } +// Deep-shallow copy. +func (src *MsgServerCtrl) copy() *MsgServerCtrl { + if src == nil { + return nil + } + dst := *src + return &dst +} + // MsgServerData is a server {data} message. type MsgServerData struct { Topic string `json:"topic"` @@ -451,6 +460,15 @@ type MsgServerData struct { Content interface{} `json:"content"` } +// Deep-shallow copy. +func (src *MsgServerData) copy() *MsgServerData { + if src == nil { + return nil + } + dst := *src + return &dst +} + // MsgServerPres is presence notification {pres} (authoritative update). type MsgServerPres struct { Topic string `json:"topic"` @@ -488,6 +506,15 @@ type MsgServerPres struct { ExcludeUser string `json:"-"` } +// Deep-shallow copy. +func (src *MsgServerPres) copy() *MsgServerPres { + if src == nil { + return nil + } + dst := *src + return &dst +} + // MsgServerMeta is a topic metadata {meta} update. type MsgServerMeta struct { Id string `json:"id,omitempty"` @@ -507,6 +534,15 @@ type MsgServerMeta struct { Cred []*MsgCredServer `json:"cred,omitempty"` } +// Deep-shallow copy of meta message. Deep copy of Id and Topic fields, shallow copy of payload. +func (src *MsgServerMeta) copy() *MsgServerMeta { + if src == nil { + return nil + } + dst := *src + return &dst +} + // MsgServerInfo is the server-side copy of MsgClientNote with From added (non-authoritative). type MsgServerInfo struct { Topic string `json:"topic"` @@ -518,6 +554,15 @@ type MsgServerInfo struct { SeqId int `json:"seq,omitempty"` } +// Deep copy +func (src *MsgServerInfo) copy() *MsgServerInfo { + if src == nil { + return nil + } + dst := *src + return &dst +} + // ServerComMessage is a wrapper for server-side messages. type ServerComMessage struct { Ctrl *MsgServerCtrl `json:"ctrl,omitempty"` @@ -540,6 +585,30 @@ type ServerComMessage struct { skipSid string } +// Deep-shallow copy of ServerComMessage. Deep copy of service fields, +// shallow copy of session and payload. +func (src *ServerComMessage) copy() *ServerComMessage { + if src == nil { + return nil + } + dst := &ServerComMessage{ + id: src.id, + rcptto: src.rcptto, + timestamp: src.timestamp, + from: src.from, + sess: src.sess, + skipSid: src.skipSid, + } + + dst.Ctrl = src.Ctrl.copy() + dst.Data = src.Data.copy() + dst.Meta = src.Meta.copy() + dst.Pres = src.Pres.copy() + dst.Info = src.Info.copy() + + return dst +} + // Generators of server-side error messages {ctrl}. // NoErr indicates successful completion (200) diff --git a/server/session.go b/server/session.go index 42169ab0a..82d720870 100644 --- a/server/session.go +++ b/server/session.go @@ -1064,8 +1064,9 @@ func (s *Session) serialize(msg *ServerComMessage) interface{} { } if s.proto == CLUSTER { - // No need to serialize the message to bytes within the cluster. - return msg + // No need to serialize the message to bytes within the cluster, + // but we have to create a copy because the original msg can be mutated. + return msg.copy() } out, _ := json.Marshal(msg) From b27d484b08943e9f149c11173de6654af0818026 Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 19 Apr 2020 17:04:30 +0300 Subject: [PATCH 134/142] add error handling to tnpg --- server/push/fcm/payload.go | 16 ++--- server/push/tnpg/push_tnpg.go | 111 ++++++++++++++++++++++++++++++---- server/topic.go | 15 +++-- 3 files changed, 118 insertions(+), 24 deletions(-) diff --git a/server/push/fcm/payload.go b/server/push/fcm/payload.go index 57cb87210..57796848b 100644 --- a/server/push/fcm/payload.go +++ b/server/push/fcm/payload.go @@ -113,7 +113,8 @@ type androidPayload struct { ClickAction string `json:"click_action,omitempty"` } -type messageData struct { +// MessageData adds user ID and device token to push message. This is needed for error handling. +type MessageData struct { Uid t.Uid DeviceId string Message *fcm.Message @@ -123,7 +124,6 @@ func payloadToData(pl *push.Payload) (map[string]string, error) { if pl == nil { return nil, nil } - data := make(map[string]string) var err error data["what"] = pl.What @@ -161,7 +161,7 @@ func payloadToData(pl *push.Payload) (map[string]string, error) { // PrepareNotifications creates notification payloads ready to be posted // to push notification server for the provided receipt. -func PrepareNotifications(rcpt *push.Receipt, config *AndroidConfig) []messageData { +func PrepareNotifications(rcpt *push.Receipt, config *AndroidConfig) []MessageData { data, _ := payloadToData(&rcpt.Payload) if data == nil { log.Println("fcm push: could not parse payload") @@ -170,15 +170,15 @@ func PrepareNotifications(rcpt *push.Receipt, config *AndroidConfig) []messageDa // List of UIDs for querying the database uids := make([]t.Uid, len(rcpt.To)) - skipDevices := make(map[string]bool) + // These devices were online in the topic when the message was sent. + skipDevices := make(map[string]struct{}) i := 0 for uid, to := range rcpt.To { uids[i] = uid i++ - // Some devices were online and received the message. Skip them. for _, deviceID := range to.Devices { - skipDevices[deviceID] = true + skipDevices[deviceID] = struct{}{} } } @@ -204,7 +204,7 @@ func PrepareNotifications(rcpt *push.Receipt, config *AndroidConfig) []messageDa color = config.getIconColor(rcpt.Payload.What) } - var messages []messageData + var messages []MessageData for uid, devList := range devices { for i := range devList { d := &devList[i] @@ -260,7 +260,7 @@ func PrepareNotifications(rcpt *push.Receipt, config *AndroidConfig) []messageDa Body: body, } } - messages = append(messages, messageData{Uid: uid, DeviceId: d.DeviceId, Message: &msg}) + messages = append(messages, MessageData{Uid: uid, DeviceId: d.DeviceId, Message: &msg}) } } } diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go index 03ce95de4..737fe5ef3 100644 --- a/server/push/tnpg/push_tnpg.go +++ b/server/push/tnpg/push_tnpg.go @@ -11,6 +11,7 @@ import ( "github.com/tinode/chat/server/push" "github.com/tinode/chat/server/push/fcm" + "github.com/tinode/chat/server/store" ) const ( @@ -33,6 +34,41 @@ type configType struct { AuthToken string `json:"token"` } +type TNPGResponse struct { + MessageID string `json:"msg_id,omitempty"` + ErrorCode string `json:"errcode,omitempty"` + ErrorMessage string `json:"errmsg,omitempty"` +} + +type BatchResponse struct { + // Number of successfully sent messages. + SuccessCount int `json:"sent_count"` + // Number of failures. + FailureCount int `json:"fail_count"` + // Error code and message if the entire batch failed. + FatalCode string `json:"errcode,omitempty"` + FatalMessage string `json:"errmsg,omitempty"` + // Individual reponses in the same order as messages. Could be nil if the entire batch failed. + Responses []*TNPGResponse `json:"resp,omitempty"` + + // Local values + httpCode int + httpStatus string +} + +// Error codes copied from https://github.com/firebase/firebase-admin-go/blob/master/messaging/messaging.go +const ( + internalError = "internal-error" + invalidAPNSCredentials = "invalid-apns-credentials" + invalidArgument = "invalid-argument" + messageRateExceeded = "message-rate-exceeded" + mismatchedCredential = "mismatched-credential" + registrationTokenNotRegistered = "registration-token-not-registered" + serverUnavailable = "server-unavailable" + tooManyTopics = "too-many-topics" + unknownError = "unknown-error" +) + // Init initializes the handler func (Handler) Init(jsonconf string) error { var config configType @@ -66,15 +102,18 @@ func (Handler) Init(jsonconf string) error { return nil } -func postMessage(body interface{}, config *configType) (int, string, error) { +func postMessage(body interface{}, config *configType) (*BatchResponse, error) { buf := new(bytes.Buffer) - gz := gzip.NewWriter(buf) - json.NewEncoder(gz).Encode(body) - gz.Close() + gzw := gzip.NewWriter(buf) + err := json.NewEncoder(gzw).Encode(body) + gzw.Close() + if err != nil { + return nil, err + } req, err := http.NewRequest("POST", handler.postUrl, buf) if err != nil { - return -1, "", err + return nil, err } req.Header.Add("Authorization", "Bearer "+config.AuthToken) req.Header.Set("Content-Type", "application/json; charset=utf-8") @@ -82,10 +121,26 @@ func postMessage(body interface{}, config *configType) (int, string, error) { resp, err := http.DefaultClient.Do(req) if err != nil { - return -1, "", err + return nil, err + } + + var batch BatchResponse + gzr, err := gzip.NewReader(resp.Body) + if err == nil { + err = json.NewDecoder(gzr).Decode(&batch) + gzr.Close() } - defer resp.Body.Close() - return resp.StatusCode, resp.Status, nil + resp.Body.Close() + + if err != nil { + // Just log the error, but don't report it to caller. The push succeeded. + log.Println("tnpg failed to decode response", err) + } + + batch.httpCode = resp.StatusCode + batch.httpStatus = resp.Status + + return &batch, nil } func sendPushes(rcpt *push.Receipt, config *configType) { @@ -104,12 +159,46 @@ func sendPushes(rcpt *push.Receipt, config *configType) { for j := i; j < upper; j++ { payloads = append(payloads, messages[j].Message) } - if code, status, err := postMessage(payloads, config); err != nil { + if resp, err := postMessage(payloads, config); err != nil { log.Println("tnpg push failed:", err) break - } else if code >= 300 { - log.Println("tnpg push rejected:", status, err) + } else if resp.httpCode >= 300 { + log.Println("tnpg push rejected:", resp.httpStatus) break + } else if resp.FatalCode != "" { + log.Println("tnpg push failed:", resp.FatalMessage) + break + } else { + // Check for expired tokens and other errors. + handleResponse(resp, messages[i:upper]) + } + } +} + +func handleResponse(batch *BatchResponse, messages []fcm.MessageData) { + if batch.FailureCount <= 0 { + return + } + + for i, resp := range batch.Responses { + switch resp.ErrorCode { + case "": // no error + case messageRateExceeded, serverUnavailable, internalError, unknownError: + // Transient errors. Stop sending this batch. + log.Println("tnpg transient failure", resp.ErrorMessage) + return + case mismatchedCredential, invalidArgument: + // Config errors + log.Println("tnpg invalid config", resp.ErrorMessage) + return + case registrationTokenNotRegistered: + // Token is no longer valid. + log.Println("tnpg invalid token", resp.ErrorMessage) + if err := store.Devices.Delete(messages[i].Uid, messages[i].DeviceId); err != nil { + log.Println("tnpg: failed to delete invalid token", err) + } + default: + log.Println("tnpg returned error", resp.ErrorMessage) } } } diff --git a/server/topic.go b/server/topic.go index ddd53c5ae..6c2c7c383 100644 --- a/server/topic.go +++ b/server/topic.go @@ -483,7 +483,11 @@ func (t *Topic) run(hub *Hub) { // Update device map with the device ID which should NOT receive the notification. if pushRcpt != nil { if addr, ok := pushRcpt.To[pssd.uid]; ok { - addr.Delivered++ + if pssd.ref == nil { + // Count foreground sessions only, background sessions are automated + // and should not affect pushes to other devices. + addr.Delivered++ + } if sess.deviceID != "" { // List of device IDs which already received the message. Push should // skip them. @@ -2584,10 +2588,11 @@ func (t *Topic) pushForData(fromUid types.Uid, data *MsgServerData) *push.Receip for uid := range t.perUser { // Send only to those who have notifications enabled, exclude the originating user. - if uid != fromUid && - (t.perUser[uid].modeWant & t.perUser[uid].modeGiven).IsPresencer() && - !t.perUser[uid].deleted { - + if uid == fromUid { + continue + } + mode := t.perUser[uid].modeWant & t.perUser[uid].modeGiven + if mode.IsPresencer() && mode.IsReader() && !t.perUser[uid].deleted { receipt.To[uid] = push.Recipient{} } } From 2ed5cc0704a554c4f209a4324686867773254adb Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 19 Apr 2020 17:18:27 +0300 Subject: [PATCH 135/142] make types private to package --- server/push/tnpg/push_tnpg.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go index 737fe5ef3..f54a21875 100644 --- a/server/push/tnpg/push_tnpg.go +++ b/server/push/tnpg/push_tnpg.go @@ -34,13 +34,13 @@ type configType struct { AuthToken string `json:"token"` } -type TNPGResponse struct { +type tnpgResponse struct { MessageID string `json:"msg_id,omitempty"` ErrorCode string `json:"errcode,omitempty"` ErrorMessage string `json:"errmsg,omitempty"` } -type BatchResponse struct { +type batchResponse struct { // Number of successfully sent messages. SuccessCount int `json:"sent_count"` // Number of failures. @@ -49,7 +49,7 @@ type BatchResponse struct { FatalCode string `json:"errcode,omitempty"` FatalMessage string `json:"errmsg,omitempty"` // Individual reponses in the same order as messages. Could be nil if the entire batch failed. - Responses []*TNPGResponse `json:"resp,omitempty"` + Responses []*tnpgResponse `json:"resp,omitempty"` // Local values httpCode int @@ -102,7 +102,7 @@ func (Handler) Init(jsonconf string) error { return nil } -func postMessage(body interface{}, config *configType) (*BatchResponse, error) { +func postMessage(body interface{}, config *configType) (*batchResponse, error) { buf := new(bytes.Buffer) gzw := gzip.NewWriter(buf) err := json.NewEncoder(gzw).Encode(body) @@ -124,7 +124,7 @@ func postMessage(body interface{}, config *configType) (*BatchResponse, error) { return nil, err } - var batch BatchResponse + var batch batchResponse gzr, err := gzip.NewReader(resp.Body) if err == nil { err = json.NewDecoder(gzr).Decode(&batch) @@ -175,7 +175,7 @@ func sendPushes(rcpt *push.Receipt, config *configType) { } } -func handleResponse(batch *BatchResponse, messages []fcm.MessageData) { +func handleResponse(batch *batchResponse, messages []fcm.MessageData) { if batch.FailureCount <= 0 { return } From c786d69d6d7f70bcc9646cdbd9c50ad8deae7fa4 Mon Sep 17 00:00:00 2001 From: or-else Date: Mon, 20 Apr 2020 10:58:33 +0300 Subject: [PATCH 136/142] fix handling of response gzip --- server/push/tnpg/push_tnpg.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go index f54a21875..a79a09f9b 100644 --- a/server/push/tnpg/push_tnpg.go +++ b/server/push/tnpg/push_tnpg.go @@ -6,8 +6,10 @@ import ( "compress/gzip" "encoding/json" "errors" + "io" "log" "net/http" + "strings" "github.com/tinode/chat/server/push" "github.com/tinode/chat/server/push/fcm" @@ -118,6 +120,7 @@ func postMessage(body interface{}, config *configType) (*batchResponse, error) { req.Header.Add("Authorization", "Bearer "+config.AuthToken) req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Add("Content-Encoding", "gzip") + req.Header.Add("Accept-Encoding", "gzip") resp, err := http.DefaultClient.Do(req) if err != nil { @@ -125,10 +128,18 @@ func postMessage(body interface{}, config *configType) (*batchResponse, error) { } var batch batchResponse - gzr, err := gzip.NewReader(resp.Body) + var reader io.ReadCloser + if strings.Contains(resp.Header.Get("Content-Encoding"), "gzip") { + reader, err = gzip.NewReader(resp.Body) + if err == nil { + defer reader.Close() + } + } else { + reader = resp.Body + } + if err == nil { - err = json.NewDecoder(gzr).Decode(&batch) - gzr.Close() + err = json.NewDecoder(reader).Decode(&batch) } resp.Body.Close() From cb613f00a3579f9b472b9e4652774c5990beac1b Mon Sep 17 00:00:00 2001 From: or-else Date: Mon, 20 Apr 2020 11:45:23 +0300 Subject: [PATCH 137/142] use explicit base tag instead of :latest --- docker/chatbot/Dockerfile | 2 +- docker/exporter/Dockerfile | 2 +- docker/tinode/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/chatbot/Dockerfile b/docker/chatbot/Dockerfile index bb8c5111b..e0555f998 100644 --- a/docker/chatbot/Dockerfile +++ b/docker/chatbot/Dockerfile @@ -1,6 +1,6 @@ # Dockerfile builds an image with a chatbot (Tino) for Tinode. -FROM python:3.7-slim +FROM python:3.8-slim ARG VERSION=0.16 ARG LOGIN_AS= diff --git a/docker/exporter/Dockerfile b/docker/exporter/Dockerfile index c22909d17..cca4aee31 100644 --- a/docker/exporter/Dockerfile +++ b/docker/exporter/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:latest +FROM alpine:3.11 ARG VERSION=0.16.4 ENV VERSION=$VERSION diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index 738189391..1baac7d72 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -9,7 +9,7 @@ # --env AUTH_TOKEN_KEY=base64+encoded+32+bytes \ # tinode-server -FROM alpine:latest +FROM alpine:3.11 ARG VERSION=0.16 ENV VERSION=$VERSION From 9d431bd4441bf8833144fdb1edc7270e5cd03e04 Mon Sep 17 00:00:00 2001 From: or-else Date: Mon, 20 Apr 2020 14:44:18 +0300 Subject: [PATCH 138/142] alpine 3.11 wants update-ca-certificates --- docker/tinode/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index 1baac7d72..06ba781be 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -110,7 +110,8 @@ ENV STORE_USE_ADAPTER=$TARGET_DB # Install root certificates, they are needed for email validator to work # with the TLS SMTP servers like Gmail. Also add bash and grep. RUN apk update && \ - apk add --no-cache ca-certificates bash grep + apk add --no-cache ca-certificates bash grep && \ + update-ca-certificates WORKDIR /opt/tinode From 8df04eb3615bb160e546d7db79e17aaa4dd5094f Mon Sep 17 00:00:00 2001 From: or-else Date: Mon, 20 Apr 2020 14:45:06 +0300 Subject: [PATCH 139/142] guard reading from session cache --- server/sessionstore.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/sessionstore.go b/server/sessionstore.go index d2badc905..0d64231c1 100644 --- a/server/sessionstore.go +++ b/server/sessionstore.go @@ -45,10 +45,11 @@ func (ss *SessionStore) NewSession(conn interface{}, sid string) (*Session, int) s.sid = sid } + ss.lock.Lock() if _, found := ss.sessCache[s.sid]; found { - // TODO: change to panic or log.Fatal - log.Println("ERROR! duplicate session ID", s.sid) + log.Fatalln("ERROR! duplicate session ID", s.sid) } + ss.lock.Unlock() switch c := conn.(type) { case *websocket.Conn: From 21f7a7c842f1bbc6bf16472082648ecbee837302 Mon Sep 17 00:00:00 2001 From: or-else Date: Mon, 20 Apr 2020 15:56:13 +0300 Subject: [PATCH 140/142] roll back update-ca-certificates, the bug was elsewhere --- docker/tinode/Dockerfile | 5 ++--- server/push/tnpg/push_tnpg.go | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index 06ba781be..8b6c454de 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -108,10 +108,9 @@ ENV TNPG_ORG= ENV STORE_USE_ADAPTER=$TARGET_DB # Install root certificates, they are needed for email validator to work -# with the TLS SMTP servers like Gmail. Also add bash and grep. +# with the TLS SMTP servers like Gmail or Mailjet. Also add bash and grep. RUN apk update && \ - apk add --no-cache ca-certificates bash grep && \ - update-ca-certificates + apk add --no-cache ca-certificates bash grep WORKDIR /opt/tinode diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go index a79a09f9b..2194c6cc6 100644 --- a/server/push/tnpg/push_tnpg.go +++ b/server/push/tnpg/push_tnpg.go @@ -171,7 +171,7 @@ func sendPushes(rcpt *push.Receipt, config *configType) { payloads = append(payloads, messages[j].Message) } if resp, err := postMessage(payloads, config); err != nil { - log.Println("tnpg push failed:", err) + log.Println("tnpg push request failed:", err) break } else if resp.httpCode >= 300 { log.Println("tnpg push rejected:", resp.httpStatus) From 4d4d62b37adddfe7b890e05601484e7b4029b544 Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 21 Apr 2020 15:28:44 +0300 Subject: [PATCH 141/142] adding support for silent pushes --- server/push/fcm/payload.go | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/server/push/fcm/payload.go b/server/push/fcm/payload.go index 57796848b..5b5abd186 100644 --- a/server/push/fcm/payload.go +++ b/server/push/fcm/payload.go @@ -122,7 +122,7 @@ type MessageData struct { func payloadToData(pl *push.Payload) (map[string]string, error) { if pl == nil { - return nil, nil + return nil, errors.New("empty push payload") } data := make(map[string]string) var err error @@ -159,12 +159,20 @@ func payloadToData(pl *push.Payload) (map[string]string, error) { return data, nil } +func clonePayload(src map[string]string) map[string]string { + dst := make(map[string]string, len(src)) + for key, val := range src { + dst[key] = val + } + return dst +} + // PrepareNotifications creates notification payloads ready to be posted // to push notification server for the provided receipt. func PrepareNotifications(rcpt *push.Receipt, config *AndroidConfig) []MessageData { - data, _ := payloadToData(&rcpt.Payload) - if data == nil { - log.Println("fcm push: could not parse payload") + data, err := payloadToData(&rcpt.Payload) + if err != nil { + log.Println("fcm push: could not parse payload;", err) return nil } @@ -206,12 +214,18 @@ func PrepareNotifications(rcpt *push.Receipt, config *AndroidConfig) []MessageDa var messages []MessageData for uid, devList := range devices { + userData := data + if rcpt.To[uid].Delivered > 0 { + // Silence the push for user who have received the data interactively. + userData = clonePayload(data) + userData["silent"] = "true" + } for i := range devList { d := &devList[i] if _, ok := skipDevices[d.DeviceId]; !ok && d.DeviceId != "" { msg := fcm.Message{ Token: d.DeviceId, - Data: data, + Data: userData, } if d.Platform == "android" { @@ -240,7 +254,7 @@ func PrepareNotifications(rcpt *push.Receipt, config *AndroidConfig) []MessageDa // Need to duplicate these in APNS.Payload.Aps.Alert so // iOS may call NotificationServiceExtension (if present). title := "New message" - body := data["content"] + body := userData["content"] msg.APNS = &fcm.APNSConfig{ Payload: &fcm.APNSPayload{ Aps: &fcm.Aps{ From 7c23d0f132f4014375aa4be9034238b961c0fde1 Mon Sep 17 00:00:00 2001 From: or-else Date: Wed, 22 Apr 2020 09:49:04 +0300 Subject: [PATCH 142/142] incorrect order of arguments in fnd query --- server/db/mysql/adapter.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index eb03c7d95..eb8e77eda 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -1860,6 +1860,7 @@ func (a *adapter) SubsDelForUser(user t.Uid, hard bool) error { func (a *adapter) FindUsers(uid t.Uid, req, opt []string) ([]t.Subscription, error) { index := make(map[string]struct{}) var args []interface{} + args = append(args, t.StateOK) for _, tag := range append(req, opt...) { args = append(args, tag) index[tag] = struct{}{} @@ -1869,7 +1870,6 @@ func (a *adapter) FindUsers(uid t.Uid, req, opt []string) ([]t.Subscription, err "FROM users AS u LEFT JOIN usertags AS t ON t.userid=u.id " + "WHERE u.state=? AND t.tag IN (?" + strings.Repeat(",?", len(req)+len(opt)-1) + ") " + "GROUP BY u.id,u.createdat,u.updatedat,u.public,u.tags " - args = append(args, t.StateOK) if len(req) > 0 { query += "HAVING COUNT(t.tag IN (?" + strings.Repeat(",?", len(req)-1) + ") OR NULL)>=? " for _, tag := range req { @@ -1927,6 +1927,7 @@ func (a *adapter) FindUsers(uid t.Uid, req, opt []string) ([]t.Subscription, err func (a *adapter) FindTopics(req, opt []string) ([]t.Subscription, error) { index := make(map[string]struct{}) var args []interface{} + args = append(args, t.StateOK) for _, tag := range append(req, opt...) { args = append(args, tag) index[tag] = struct{}{} @@ -1936,7 +1937,6 @@ func (a *adapter) FindTopics(req, opt []string) ([]t.Subscription, error) { "FROM topics AS t LEFT JOIN topictags AS tt ON t.name=tt.topic " + "WHERE t.state=? AND tt.tag IN (?" + strings.Repeat(",?", len(req)+len(opt)-1) + ") " + "GROUP BY t.name,t.createdat,t.updatedat,t.public,t.tags " - args = append(args, t.StateOK) if len(req) > 0 { query += "HAVING COUNT(tt.tag IN (?" + strings.Repeat(",?", len(req)-1) + ") OR NULL)>=? " for _, tag := range append(req) {