Skip to content
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
86 changes: 55 additions & 31 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,13 @@ type Client struct {
responseWaitersLock sync.Mutex
businessCatalogAuth atomic.Pointer[businessCatalogAuthState]

handlerQueue chan *waBinary.Node
handlerQueue chan *waBinary.Node
// nodeHandlers maps a top-level stanza tag to its handler. Upstream
// whatsmeow exposes the same unexported map; keeping it (instead of a
// closed switch) lets a headless shadow Client dispatch injected nodes
// synchronously and lets embedders that reach for it via reflection keep
// working.
nodeHandlers map[string]nodeHandler
eventHandlers []wrappedEventHandler
eventHandlersLock sync.RWMutex

Expand Down Expand Up @@ -226,6 +232,11 @@ type Client struct {
// after decoding but before standard dispatch. See [RawNodeHandler].
RawNodeHandler RawNodeHandler

// shadowRelay, if non-nil, marks this client as a headless "shadow"
// client (see [NewShadowClient]): it has no socket, Connect is guarded,
// and outbound nodes plus Signal/keying oracle ops are delegated here.
shadowRelay ShadowRelay

// DisabledFeatures controls which built-in processing paths are
// skipped. See [DisabledFeatures].
DisabledFeatures DisabledFeatures
Expand Down Expand Up @@ -345,6 +356,7 @@ func NewClient(deviceStore *store.Device, log waLog.Logger) *Client {
WebSocketHeaders: http.Header{},
}
cli.paired.Store(deviceStore.ID != nil)
cli.nodeHandlers = cli.defaultNodeHandlers()
return cli
}

Expand Down Expand Up @@ -585,6 +597,12 @@ func (cli *Client) connect(ctx context.Context) error {
}

func (cli *Client) unlockedConnect(ctx context.Context) error {
if cli.shadowRelay != nil {
// Guard: a shadow client must never open a socket. All Connect
// paths (Connect, ConnectContext, autoReconnect) funnel through
// here, so failing closed here blocks every one of them.
return ErrShadowClientNoConnect
}
if cli.Store.Deleted {
return store.ErrDeviceDeleted
}
Expand Down Expand Up @@ -995,40 +1013,34 @@ Loop:
}

func (cli *Client) hasNodeHandler(tag string) bool {
switch tag {
case "message", "status", "appdata", "receipt", "call", "chatstate", "presence", "notification", "success", "failure", "stream:error", "iq", "ib":
return true
default:
return false
_, ok := cli.nodeHandlers[tag]
return ok
}

type nodeHandler func(ctx context.Context, node *waBinary.Node)

// defaultNodeHandlers returns the built-in top-level stanza dispatch table.
func (cli *Client) defaultNodeHandlers() map[string]nodeHandler {
return map[string]nodeHandler{
"message": cli.handleEncryptedMessage,
"appdata": cli.handleEncryptedMessage,
"status": cli.handleUnencryptedMessage,
"receipt": cli.handleReceipt,
"call": cli.handleCallEvent,
"chatstate": cli.handleChatState,
"presence": cli.handlePresence,
"notification": cli.handleNotification,
"success": cli.handleConnectSuccess,
"failure": cli.handleConnectFailure,
"stream:error": cli.handleStreamError,
"iq": cli.handleIQ,
"ib": cli.handleIB,
}
}

func (cli *Client) handleNode(ctx context.Context, node *waBinary.Node) {
switch node.Tag {
case "message", "appdata":
cli.handleEncryptedMessage(ctx, node)
case "status":
cli.handleUnencryptedMessage(ctx, node)
case "receipt":
cli.handleReceipt(ctx, node)
case "call":
cli.handleCallEvent(ctx, node)
case "chatstate":
cli.handleChatState(ctx, node)
case "presence":
cli.handlePresence(ctx, node)
case "notification":
cli.handleNotification(ctx, node)
case "success":
cli.handleConnectSuccess(ctx, node)
case "failure":
cli.handleConnectFailure(ctx, node)
case "stream:error":
cli.handleStreamError(ctx, node)
case "iq":
cli.handleIQ(ctx, node)
case "ib":
cli.handleIB(ctx, node)
if handler, ok := cli.nodeHandlers[node.Tag]; ok {
handler(ctx, node)
}
}

Expand All @@ -1040,6 +1052,18 @@ func (cli *Client) sendNodeAndGetData(ctx context.Context, node waBinary.Node) (
sock := cli.socket
cli.socketLock.RUnlock()
if sock == nil {
// A headless (shadow) client has no socket by design; route the
// outbound node to its relay instead. Fail closed if there is
// neither a socket nor a relay so a write can never silently
// escape or nil-panic on the absent socket.
if cli.shadowRelay != nil {
payload, err := waBinary.Marshal(node)
if err != nil {
return nil, fmt.Errorf("failed to marshal node: %w", err)
}
cli.sendLog.Debugf("%s", &node)
return payload, cli.shadowRelay.SendNode(ctx, payload)
}
return nil, ErrNotConnected
}

Expand Down
24 changes: 24 additions & 0 deletions message.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,20 @@ func (cli *Client) decryptMessages(ctx context.Context, info *types.MessageInfo,
} else if errors.Is(err, signalerror.ErrOldCounter) {
cli.Log.Warnf("Ignoring message %s from %s: %v", info.ID, info.SourceString(), err)
continue
} else if errors.Is(err, ErrShadowGroupUnsupported) {
// A shadow can never make group ciphertext decryptable, so a retry
// receipt would only provoke redeliveries. Acknowledge, surface the
// message as undecryptable, and move on.
cli.Log.Warnf("Ignoring group message %s from %s: %v", info.ID, info.SourceString(), err)
cli.backgroundIfAsyncAck(func() {
cli.sendAck(ctx, node, 0)
})
cli.dispatchEvent(&events.UndecryptableMessage{
Info: *info,
IsUnavailable: true,
DecryptFailMode: events.DecryptFailMode(ag.OptionalString("decrypt-fail")),
})
continue
} else if err != nil {
cli.Log.Warnf("Error decrypting message %s from %s: %v", info.ID, info.SourceString(), err)
if ctx.Err() != nil || errors.Is(err, context.Canceled) {
Expand Down Expand Up @@ -602,6 +616,11 @@ func (cli *Client) bufferedDecrypt(
}

func (cli *Client) decryptDM(ctx context.Context, child *waBinary.Node, from types.JID, isPreKey bool, serverTS time.Time) ([]byte, *[32]byte, error) {
if cli.isShadow() {
// A headless client holds no live Signal session; delegate
// decryption to the relay oracle.
return cli.shadowRelay.DecryptDM(ctx, child, from, isPreKey)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
content, ok := child.Content.([]byte)
if !ok {
return nil, nil, fmt.Errorf("message content is not a byte slice")
Expand Down Expand Up @@ -652,6 +671,11 @@ func (cli *Client) decryptDM(ctx context.Context, child *waBinary.Node, from typ
}

func (cli *Client) decryptGroupMsg(ctx context.Context, child *waBinary.Node, from types.JID, chat types.JID, serverTS time.Time) ([]byte, *[32]byte, error) {
if cli.isShadow() {
// Sender-key state must never be created or read in a headless shadow;
// the relay oracle covers direct messages only. Fail closed.
return nil, nil, ErrShadowGroupUnsupported
Comment thread
purpshell marked this conversation as resolved.
}
content, ok := child.Content.([]byte)
if !ok {
return nil, nil, fmt.Errorf("message content is not a byte slice")
Expand Down
10 changes: 10 additions & 0 deletions prekeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ func (cli *Client) fetchPreKeysNoError(ctx context.Context, retryDevices []types
if len(retryDevices) == 0 {
return nil
}
if cli.isShadow() {
// A headless client has no socket to run the prekey IQ; delegate to
// the relay oracle.
bundles, err := cli.shadowRelay.FetchPreKeys(ctx, retryDevices)
if err != nil {
cli.Log.Warnf("Failed to fetch prekeys for %v via relay: %v", retryDevices, err)
return nil
}
return bundles
}
bundlesResp, err := cli.fetchPreKeys(ctx, retryDevices)
if err != nil {
cli.Log.Warnf("Failed to fetch prekeys for %v with no existing session: %v", retryDevices, err)
Expand Down
9 changes: 9 additions & 0 deletions send.go
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,10 @@ func (cli *Client) sendGroup(
timings *MessageDebugTimings,
extraParams nodeExtraParams,
) (string, []byte, error) {
if cli.isShadow() {
// A shadow would otherwise mint a sender key into the seeded snapshot.
return "", nil, ErrShadowGroupUnsupported
}
start := time.Now()
plaintext, _, err := marshalMessage(to, message)
timings.Marshal = time.Since(start)
Expand Down Expand Up @@ -1438,6 +1442,11 @@ func (cli *Client) encryptMessageForDevice(
extraAttrs waBinary.Attrs,
existingSessions map[string]bool,
) (*waBinary.Node, bool, error) {
if cli.isShadow() {
// A headless client holds no live Signal session; delegate
// per-device encryption to the relay oracle.
return cli.shadowRelay.EncryptForDevice(ctx, plaintext, to, bundle, extraAttrs)
}
builder := session.NewBuilderFromSignal(cli.Store, to.SignalAddress(), pbSerializer)
if bundle != nil {
cli.Log.Debugf("Processing prekey bundle for %s", to)
Expand Down
3 changes: 3 additions & 0 deletions sendfb.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ func (cli *Client) sendGroupV3(
frankingTag []byte,
timings *MessageDebugTimings,
) (string, []byte, error) {
if cli.isShadow() {
return "", nil, ErrShadowGroupUnsupported
}
var groupMeta *groupMetaCache
var err error
start := time.Now()
Expand Down
Loading
Loading