Skip to content

Navigation Menu

Sign in
Sign up

Race WhatsApp chat sockets like the web client #44

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
purpshell wants to merge 4 commits into main
base: main
Choose a base branch
Loading
from bansafe/persona-socket
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions client.go
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,11 @@ type Client struct {
// The user agent to use (for non-Messenger connections).
UserAgent string
WebSocketHeaders http.Header
// DisablePostConnectPassiveIQ stops the library sending the
// `passive`/`active` IQ after a successful connect. Set it when the
// client payload already carries `passive: false`, which is what
// WhatsApp Web sends; the real client has no such IQ.
DisablePostConnectPassiveIQ bool
}

type groupMetaCache struct {
Expand All @@ -281,6 +286,19 @@ type SocketConfig struct {
URL string
Origin string
NoiseCertificateAuthority *[32]byte
// RaceURLs, when it has more than one entry, makes Connect open every
// URL concurrently and keep the one that connects first, closing the
// others with code 1000 and the reason "loser socket".
//
// This is what WhatsApp Web does on every connect
// (WAWebOpenSocket.js:10, 44-52). A client that makes exactly one
// attempt per connect and never closes a second socket differs from the
// real one by nothing more than counting. Use socket.RaceURLs for the
// endpoints the client itself races.
//
// Empty (the default) keeps the single-socket behaviour and the URL
// field above.
RaceURLs []string
}

const handlerQueueSize = 256
Expand Down Expand Up @@ -610,18 +628,27 @@ func (cli *Client) unlockedConnect(ctx context.Context) error {
fs.HTTPHeaders.Set("Origin", cli.MessengerConfig.BaseURL)
}
maps.Copy(fs.HTTPHeaders, cli.WebSocketHeaders)
var raceURLs []string
if cli.SocketConfig != nil {
if cli.SocketConfig.URL != "" {
fs.URL = cli.SocketConfig.URL
}
if cli.SocketConfig.Origin != "" {
fs.HTTPHeaders.Set("Origin", cli.SocketConfig.Origin)
}
raceURLs = cli.SocketConfig.RaceURLs
}
if err := fs.Connect(ctx); err != nil {
if len(raceURLs) > 1 {
raced, err := socket.ConnectRace(ctx, cli.Log.Sub("Socket"), client, raceURLs, fs.HTTPHeaders)
if err != nil {
return err
}
fs = raced
} else if err := fs.Connect(ctx); err != nil {
fs.Close(0)
return err
} else if err = cli.doHandshake(ctx, fs, *keys.NewKeyPair()); err != nil {
}
if err := cli.doHandshake(ctx, fs, *keys.NewKeyPair()); err != nil {
fs.Close(0)
return fmt.Errorf("noise handshake failed: %w", err)
}
Expand Down
13 changes: 10 additions & 3 deletions connectionevents.go
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,16 @@ func (cli *Client) handleConnectSuccess(ctx context.Context, node *waBinary.Node
cli.Log.Debugf("Prekey count after upload: %d", sc)
}
}
err := cli.SetPassive(ctx, false)
if err != nil {
cli.Log.Warnf("Failed to send post-connect passive IQ: %v", err)
// WhatsApp Web sends `passive: false` in the login payload itself
// (WAWebGetClientPayloadForLogin.js:14-19) and never sends this IQ.
// A client that logs in passive and then immediately asks to become
// active does something no real page does, so a caller that already
// carries the right value in its payload turns this off.
if !cli.DisablePostConnectPassiveIQ {
err := cli.SetPassive(ctx, false)
if err != nil {
cli.Log.Warnf("Failed to send post-connect passive IQ: %v", err)
}
}
cli.dispatchEvent(&events.Connected{})
cli.closeSocketWaitChan()
Expand Down
29 changes: 24 additions & 5 deletions socket/framesocket.go
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ func (fs *FrameSocket) IsConnected() bool {
}

func (fs *FrameSocket) Close(code websocket.StatusCode) {
fs.CloseWithReason(code, "")
}

// CloseWithReason closes the socket with an explicit close reason.
//
// WhatsApp Web races two websockets on every connect and closes the one that
// loses with code 1000 and the reason "loser socket"
// (WAWebOpenSocket.js:44-52). A close with an empty reason where the client
// sends one is observable, so the reason is part of the wire behaviour and not
// a log detail.
func (fs *FrameSocket) CloseWithReason(code websocket.StatusCode, reason string) {
fs.lock.Lock()
defer fs.lock.Unlock()

Expand All @@ -75,7 +86,7 @@ func (fs *FrameSocket) Close(code websocket.StatusCode) {

fs.closed.Store(true)
if code > 0 {
err := conn.Close(code, "")
err := conn.Close(code, reason)
if err != nil {
fs.log.Warnf("Error sending close to websocket: %v", err)
}
Expand All @@ -93,16 +104,24 @@ func (fs *FrameSocket) Close(code websocket.StatusCode) {
}

func (fs *FrameSocket) Connect(ctx context.Context) error {
return fs.connect(ctx, ctx)
}

// connect separates the lifetime of the opened socket from the context used
// for the HTTP upgrade. Most callers use the same context for both through
// Connect. Socket racing uses a short-lived dial context so aborting another
// in-flight upgrade cannot cancel a connection that has already opened.
func (fs *FrameSocket) connect(parentCtx, dialCtx context.Context) error {
fs.lock.Lock()
defer fs.lock.Unlock()
if fs.conn.Load() != nil {
return ErrSocketAlreadyOpen
}
fs.parentCtx = ctx
fs.cancelCtx, fs.cancel = context.WithCancel(ctx)
fs.parentCtx = parentCtx
fs.cancelCtx, fs.cancel = context.WithCancel(parentCtx)

fs.log.Debugf("Dialing %s", fs.URL)
conn, resp, err := websocket.Dial(ctx, fs.URL, fs.makeDialOptions())
conn, resp, err := websocket.Dial(dialCtx, fs.URL, fs.makeDialOptions())
if err != nil {
if resp != nil {
err = ErrWithStatusCode{err, resp.StatusCode}
Expand All @@ -114,7 +133,7 @@ func (fs *FrameSocket) Connect(ctx context.Context) error {

fs.conn.Store(conn)

go fs.readPump(conn, ctx)
go fs.readPump(conn, fs.cancelCtx)
return nil
}

Expand Down
149 changes: 149 additions & 0 deletions socket/race.go
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
//go:build !js

package socket

import (
"context"
"errors"
"net/http"
"sync"

"github.com/coder/websocket"

waLog "github.com/polymorfa/hypermeow/util/log"
)

// LoserSocketCloseReason is the close reason WhatsApp Web sends on the socket
// that loses the race (WAWebOpenSocket.js:47). The code is 1000.
const LoserSocketCloseReason = "loser socket"

// RaceURLs are the two endpoints WhatsApp Web opens concurrently on every
// connect (WAWebOpenSocket.js:10). The second exists because some networks
// block or throttle 443 for long-lived upgrades, and which one wins is a
// property of the network the client is on.
var RaceURLs = []string{
"wss://web.whatsapp.com/ws/chat",
"wss://web.whatsapp.com:5222/ws/chat",
}

// ConnectRace opens every URL concurrently and returns the frame socket that
// connected first, closing the others the way the client closes them: code
// 1000 with the reason "loser socket". It mirrors
// openWebSocketsConcurrently (WAWebOpenSocket.js:44-64) — the first success
// wins, the outstanding dials are aborted, and the call fails only when every
// URL failed.
//
// A client that makes one attempt per connect and never closes a second socket
// is distinguishable from the real one by nothing more than counting, which is
// why this is worth having.
//
// The returned socket is connected and its read pump is running; the caller
// owns it exactly as it owns one from NewFrameSocket plus Connect.
func ConnectRace(
ctx context.Context,
log waLog.Logger,
httpClient *http.Client,
urls []string,
headers http.Header,
) (*FrameSocket, error) {
return connectRace(ctx, log, httpClient, urls, headers, nil)
}

func connectRace(
ctx context.Context,
log waLog.Logger,
httpClient *http.Client,
urls []string,
headers http.Header,
afterConnect func(int),
) (*FrameSocket, error) {
if len(urls) == 0 {
return nil, ErrDialFailed
}
if len(urls) == 1 {
fs := newRacer(log, httpClient, urls[0], headers)
if err := fs.Connect(ctx); err != nil {
fs.Close(0)
return nil, err
}
return fs, nil
}

// The decision is taken inside the racer goroutines under one lock, so a
// socket that opens at the same instant as the winner is chosen either
// becomes the winner or closes itself as the loser — there is no window
// in which an already-open loser has its context cancelled from outside
// and is torn down before it can send the close frame.
var (
mu sync.Mutex
winner *FrameSocket
errs []error
)
racerContexts := make([]context.Context, len(urls))
cancels := make([]context.CancelFunc, len(urls))
for i := range urls {
racerContexts[i], cancels[i] = context.WithCancel(ctx)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
var wg sync.WaitGroup
for i, url := range urls {
wg.Add(1)
go func(i int, url string, racerCtx context.Context) {
defer wg.Done()
defer cancels[i]()
fs := newRacer(log, httpClient, url, headers)
if err := fs.connect(ctx, racerCtx); err != nil {
fs.Close(0)
mu.Lock()
errs = append(errs, err)
mu.Unlock()
return
}
if afterConnect != nil {
afterConnect(i)
}
mu.Lock()
if winner == nil {
winner = fs
mu.Unlock()
log.Debugf("Opened socket with %s (race winner)", url)
// Abort every dial still outstanding, exactly as the client
// aborts its AbortController on the first success. The
// dial contexts do not own sockets after their upgrades complete.
for j, cancel := range cancels {
if j != i {
cancel()
Comment thread
purpshell marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return
}
mu.Unlock()
// A socket that opened after the winner is closed with the
// client's own code and reason.
fs.CloseWithReason(websocket.StatusNormalClosure, LoserSocketCloseReason)
}(i, url, racerContexts[i])
}
wg.Wait()

if winner == nil {
for _, cancel := range cancels {
cancel()
}
if len(errs) == 0 {
return nil, ErrDialFailed
}
return nil, errors.Join(errs...)
}
return winner, nil
}

func newRacer(log waLog.Logger, httpClient *http.Client, url string, headers http.Header) *FrameSocket {
fs := NewFrameSocket(log, httpClient)
fs.URL = url
for name, values := range headers {
if len(values) == 0 {
continue
}
fs.HTTPHeaders[name] = append([]string(nil), values...)
}
return fs
}
Loading
Loading

AltStyle によって変換されたページ (->オリジナル) /