415 lines
12 KiB
Go
415 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
type ctrlPacket struct {
|
|
ID string `json:"id"`
|
|
Topic string `json:"topic"`
|
|
Code int `json:"code"`
|
|
Text string `json:"text"`
|
|
Params map[string]json.RawMessage `json:"params"`
|
|
}
|
|
|
|
type dataPacket struct {
|
|
Topic string `json:"topic"`
|
|
From string `json:"from"`
|
|
Timestamp time.Time `json:"ts"`
|
|
Seq int64 `json:"seq"`
|
|
Head map[string]any `json:"head"`
|
|
Content json.RawMessage `json:"content"`
|
|
}
|
|
|
|
type ctrlError struct {
|
|
Code int
|
|
Text string
|
|
}
|
|
|
|
func (e *ctrlError) Error() string {
|
|
return fmt.Sprintf("ctrl code=%d text=%s", e.Code, e.Text)
|
|
}
|
|
|
|
type WSClient struct {
|
|
account AccountRecord
|
|
conn *websocket.Conn
|
|
commandTimeout time.Duration
|
|
onData func(AccountRecord, dataPacket)
|
|
onDisconnect func(AccountRecord, error)
|
|
|
|
sendMu sync.Mutex
|
|
mu sync.Mutex
|
|
seq uint64
|
|
waits map[string]chan ctrlPacket
|
|
done chan struct{}
|
|
closed atomic.Bool
|
|
}
|
|
|
|
func connectWSClient(ctx context.Context, cfg *Config, account AccountRecord, apiKey, password string,
|
|
onData func(AccountRecord, dataPacket), onDisconnect func(AccountRecord, error)) (*WSClient, error) {
|
|
timeout, err := configDuration(cfg.CommandTimeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
endpoint, err := websocketEndpoint(account.Endpoint, apiKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dialer := *websocket.DefaultDialer
|
|
if cfg.InsecureTLS {
|
|
dialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402: explicit load-test option.
|
|
}
|
|
conn, response, err := dialer.DialContext(ctx, endpoint, http.Header{})
|
|
if err != nil {
|
|
if response != nil {
|
|
return nil, fmt.Errorf("websocket dial status=%d: %w", response.StatusCode, err)
|
|
}
|
|
return nil, err
|
|
}
|
|
client := &WSClient{
|
|
account: account, conn: conn, commandTimeout: timeout, onData: onData, onDisconnect: onDisconnect,
|
|
waits: make(map[string]chan ctrlPacket), done: make(chan struct{}),
|
|
}
|
|
go client.readLoop()
|
|
if _, err = client.command(ctx, "hi", map[string]any{"hi": map[string]any{
|
|
"id": client.nextID("hi"), "ver": "0.25", "ua": "im-pressure/1.0",
|
|
"platf": "loadtest", "tenant": cfg.Tenant,
|
|
}}); err != nil {
|
|
client.Close()
|
|
return nil, fmt.Errorf("hi %s: %w", account.Username, err)
|
|
}
|
|
login := map[string]any{"id": client.nextID("login")}
|
|
if account.Token != "" {
|
|
login["scheme"] = "token"
|
|
login["secret"] = account.Token
|
|
} else {
|
|
login["scheme"] = "basic"
|
|
login["secret"] = base64.StdEncoding.EncodeToString([]byte(account.Username + ":" + password))
|
|
login["login_type"] = 0
|
|
login["tenant_code"] = cfg.Tenant
|
|
}
|
|
ctrl, err := client.command(ctx, "login", map[string]any{"login": login})
|
|
if err != nil && account.Token != "" && password != "" {
|
|
login["id"] = client.nextID("login-basic")
|
|
login["scheme"] = "basic"
|
|
login["secret"] = base64.StdEncoding.EncodeToString([]byte(account.Username + ":" + password))
|
|
login["login_type"] = 0
|
|
login["tenant_code"] = cfg.Tenant
|
|
ctrl, err = client.command(ctx, "login", map[string]any{"login": login})
|
|
}
|
|
if err != nil {
|
|
client.Close()
|
|
return nil, fmt.Errorf("login %s: %w", account.Username, err)
|
|
}
|
|
client.applyLogin(ctrl)
|
|
pingInterval, err := configDuration(cfg.WSPingInterval)
|
|
if err != nil {
|
|
client.Close()
|
|
return nil, err
|
|
}
|
|
client.startKeepalive(pingInterval)
|
|
return client, nil
|
|
}
|
|
|
|
func connectForPrepare(ctx context.Context, cfg *Config, account AccountRecord, api *apiClient, password, verificationCode string) (*WSClient, bool, error) {
|
|
timeout, err := configDuration(cfg.CommandTimeout)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
endpoint, err := websocketEndpoint(account.Endpoint, api.apiKey)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
dialer := *websocket.DefaultDialer
|
|
if cfg.InsecureTLS {
|
|
dialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402: explicit load-test option.
|
|
}
|
|
conn, _, err := dialer.DialContext(ctx, endpoint, http.Header{})
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
client := &WSClient{account: account, conn: conn, commandTimeout: timeout, waits: make(map[string]chan ctrlPacket), done: make(chan struct{})}
|
|
go client.readLoop()
|
|
if _, err = client.command(ctx, "hi", map[string]any{"hi": map[string]any{
|
|
"id": client.nextID("hi"), "ver": "0.25", "ua": "im-pressure-prepare/1.0", "tenant": cfg.Tenant,
|
|
}}); err != nil {
|
|
client.Close()
|
|
return nil, false, err
|
|
}
|
|
secret := base64.StdEncoding.EncodeToString([]byte(account.Username + ":" + password))
|
|
ctrl, loginErr := client.command(ctx, "login", map[string]any{"login": map[string]any{
|
|
"id": client.nextID("login"), "scheme": "basic", "secret": secret,
|
|
"login_type": 0, "tenant_code": cfg.Tenant,
|
|
}})
|
|
created := false
|
|
if loginErr != nil {
|
|
var ce *ctrlError
|
|
if !errors.As(loginErr, &ce) || ce.Code != http.StatusUnauthorized {
|
|
client.Close()
|
|
return nil, false, loginErr
|
|
}
|
|
credential, credentialErr := api.createRegistrationCredential(ctx, account, verificationCode)
|
|
if credentialErr != nil {
|
|
client.Close()
|
|
return nil, false, credentialErr
|
|
}
|
|
acc := map[string]any{
|
|
"id": client.nextID("acc"), "user": "new", "scheme": "basic", "username": account.Username,
|
|
"registration_type": 0, "tenant_code": cfg.Tenant, "secret": secret, "login": true,
|
|
"desc": map[string]any{"public": map[string]any{"fn": strings.ToUpper(account.Username)}},
|
|
}
|
|
if credential != nil {
|
|
acc["phone"] = account.Phone
|
|
acc["cred"] = []map[string]any{{"meth": "tel", "val": account.Phone,
|
|
"resp": credential.VerificationToken, "params": map[string]any{"challenge_id": credential.ChallengeID}}}
|
|
}
|
|
ctrl, err = client.command(ctx, "acc", map[string]any{"acc": acc})
|
|
if err != nil {
|
|
client.Close()
|
|
return nil, false, err
|
|
}
|
|
created = true
|
|
}
|
|
client.applyLogin(ctrl)
|
|
return client, created, nil
|
|
}
|
|
|
|
func websocketEndpoint(raw, apiKey string) (string, error) {
|
|
u, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if u.Scheme != "ws" && u.Scheme != "wss" {
|
|
return "", fmt.Errorf("unsupported websocket scheme %q", u.Scheme)
|
|
}
|
|
q := u.Query()
|
|
if q.Get("apikey") == "" {
|
|
q.Set("apikey", apiKey)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
return u.String(), nil
|
|
}
|
|
|
|
func (c *WSClient) applyLogin(ctrl ctrlPacket) {
|
|
if raw := ctrl.Params["user"]; len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &c.account.UserID)
|
|
}
|
|
if raw := ctrl.Params["token"]; len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &c.account.Token)
|
|
}
|
|
if raw := ctrl.Params["expires"]; len(raw) > 0 {
|
|
var value string
|
|
if json.Unmarshal(raw, &value) == nil {
|
|
c.account.TokenExpires, _ = time.Parse(time.RFC3339Nano, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *WSClient) Account() AccountRecord { return c.account }
|
|
|
|
func (c *WSClient) Subscribe(ctx context.Context, topic string) error {
|
|
if topic == "" {
|
|
return errors.New("empty topic")
|
|
}
|
|
id := c.nextID("sub")
|
|
_, err := c.command(ctx, "sub", map[string]any{"sub": map[string]any{
|
|
"id": id, "topic": topic, "get": map[string]any{"what": "desc"},
|
|
}})
|
|
return err
|
|
}
|
|
|
|
func (c *WSClient) GetData(ctx context.Context, topic string, since, before int64, limit int) error {
|
|
if topic == "" || since <= 0 || before <= since || limit <= 0 {
|
|
return errors.New("invalid get data range")
|
|
}
|
|
id := c.nextID("get-data")
|
|
_, err := c.command(ctx, "get", map[string]any{"get": map[string]any{
|
|
"id": id, "topic": topic, "what": "data",
|
|
"data": map[string]any{"since": since, "before": before, "limit": limit},
|
|
}})
|
|
return err
|
|
}
|
|
|
|
func (c *WSClient) Publish(ctx context.Context, topic, clientMid, runID, scenario string, messageIndex int) (AcceptedMessage, error) {
|
|
id := c.nextID("pub")
|
|
sentAt := time.Now().UTC()
|
|
ctrl, err := c.command(ctx, "pub", map[string]any{"pub": map[string]any{
|
|
"id": id, "topic": topic, "noecho": false,
|
|
"head": map[string]any{"mime": "application/json", "x-im-schema": 1, "x-im-type": "text", "x-im-client-mid": clientMid},
|
|
"content": map[string]any{"text": "loadtest", "run_id": runID, "scenario": scenario,
|
|
"sender_machine": c.account.Machine, "sender_index": c.account.Index,
|
|
"message_index": messageIndex, "sent_at_unix_nano": sentAt.UnixNano()},
|
|
}})
|
|
if err != nil {
|
|
return AcceptedMessage{}, err
|
|
}
|
|
seq, err := ctrlInt64(ctrl.Params["seq"])
|
|
if err != nil || seq <= 0 {
|
|
return AcceptedMessage{}, fmt.Errorf("publish ACK has invalid seq: %w", err)
|
|
}
|
|
kind := "p2p"
|
|
if strings.HasPrefix(topic, "grp") {
|
|
kind = "group"
|
|
}
|
|
return AcceptedMessage{RunID: runID, Scenario: scenario, Kind: kind, ClientMid: clientMid,
|
|
Topic: topic, Seq: seq, SenderMachine: c.account.Machine, SenderIndex: c.account.Index,
|
|
SentAt: sentAt, AckAt: time.Now().UTC()}, nil
|
|
}
|
|
|
|
func ctrlInt64(raw json.RawMessage) (int64, error) {
|
|
if len(raw) == 0 {
|
|
return 0, errors.New("missing value")
|
|
}
|
|
var number json.Number
|
|
if err := json.Unmarshal(raw, &number); err == nil {
|
|
return strconv.ParseInt(number.String(), 10, 64)
|
|
}
|
|
var text string
|
|
if err := json.Unmarshal(raw, &text); err != nil {
|
|
return 0, err
|
|
}
|
|
return strconv.ParseInt(text, 10, 64)
|
|
}
|
|
|
|
func (c *WSClient) command(ctx context.Context, _ string, message map[string]any) (ctrlPacket, error) {
|
|
id := nestedMessageID(message)
|
|
if id == "" {
|
|
return ctrlPacket{}, errors.New("command id is required")
|
|
}
|
|
wait := make(chan ctrlPacket, 1)
|
|
c.mu.Lock()
|
|
c.waits[id] = wait
|
|
c.mu.Unlock()
|
|
c.sendMu.Lock()
|
|
err := c.conn.WriteJSON(message)
|
|
c.sendMu.Unlock()
|
|
if err != nil {
|
|
c.removeWait(id)
|
|
return ctrlPacket{}, err
|
|
}
|
|
timer := time.NewTimer(c.commandTimeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case ctrl := <-wait:
|
|
if ctrl.Code < 200 || ctrl.Code >= 300 {
|
|
return ctrl, &ctrlError{Code: ctrl.Code, Text: ctrl.Text}
|
|
}
|
|
return ctrl, nil
|
|
case <-timer.C:
|
|
c.removeWait(id)
|
|
return ctrlPacket{}, fmt.Errorf("ctrl timeout id=%s", id)
|
|
case <-ctx.Done():
|
|
c.removeWait(id)
|
|
return ctrlPacket{}, ctx.Err()
|
|
case <-c.done:
|
|
c.removeWait(id)
|
|
return ctrlPacket{}, errors.New("websocket closed")
|
|
}
|
|
}
|
|
|
|
func nestedMessageID(message map[string]any) string {
|
|
for _, value := range message {
|
|
if body, ok := value.(map[string]any); ok {
|
|
if id, ok := body["id"].(string); ok {
|
|
return id
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (c *WSClient) nextID(prefix string) string {
|
|
n := atomic.AddUint64(&c.seq, 1)
|
|
return fmt.Sprintf("%s-%s-%04d-%d", prefix, c.account.Machine, c.account.Index, n)
|
|
}
|
|
|
|
func (c *WSClient) removeWait(id string) {
|
|
c.mu.Lock()
|
|
delete(c.waits, id)
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (c *WSClient) readLoop() {
|
|
defer close(c.done)
|
|
for {
|
|
var envelope map[string]json.RawMessage
|
|
if err := c.conn.ReadJSON(&envelope); err != nil {
|
|
if !c.closed.Load() && c.onDisconnect != nil {
|
|
c.onDisconnect(c.account, err)
|
|
}
|
|
return
|
|
}
|
|
if raw := envelope["ctrl"]; len(raw) > 0 {
|
|
var ctrl ctrlPacket
|
|
if json.Unmarshal(raw, &ctrl) == nil && ctrl.ID != "" {
|
|
c.mu.Lock()
|
|
wait := c.waits[ctrl.ID]
|
|
delete(c.waits, ctrl.ID)
|
|
c.mu.Unlock()
|
|
if wait != nil {
|
|
wait <- ctrl
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if raw := envelope["data"]; len(raw) > 0 && c.onData != nil {
|
|
var data dataPacket
|
|
if json.Unmarshal(raw, &data) == nil {
|
|
c.onData(c.account, data)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *WSClient) startKeepalive(interval time.Duration) {
|
|
go func() {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
deadline := time.Now().Add(interval / 2)
|
|
if err := c.conn.WriteControl(websocket.PingMessage, nil, deadline); err != nil {
|
|
_ = c.conn.Close()
|
|
return
|
|
}
|
|
case <-c.done:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (c *WSClient) alive() bool {
|
|
if c == nil || c.closed.Load() {
|
|
return false
|
|
}
|
|
select {
|
|
case <-c.done:
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func (c *WSClient) Close() {
|
|
if c == nil || !c.closed.CompareAndSwap(false, true) {
|
|
return
|
|
}
|
|
_ = c.conn.Close()
|
|
}
|