Files
MySMSAPio/WEBSOCKET_FIX.md
2025-10-22 17:22:17 +08:00

6.5 KiB

WebSocket Subscription Rejection Fix

Issue

Error: "GatewayChannel is transmitting the subscription rejection"

Root Cause

The GatewayChannel#subscribed method was attempting to re-authenticate the gateway using params[:api_key_digest], but this caused two problems:

  1. Double Authentication: The gateway was already authenticated at the connection level in ApplicationCable::Connection#connect
  2. Missing Parameter: The channel was expecting api_key_digest as a channel parameter, but it was never being passed
  3. Wrong Layer: Authentication should happen at the connection level (before subscription), not at the channel subscription level

Authentication Flow

Correct Flow (After Fix)

1. Client connects to WebSocket
   URL: ws://host/cable?api_key=gw_live_...

2. ApplicationCable::Connection#connect
   - Extracts api_key from query params
   - Hashes it with SHA256
   - Finds gateway by api_key_digest
   - Sets current_gateway
   - Connection accepted ✅

3. Client subscribes to GatewayChannel
   Message: {"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"}

4. GatewayChannel#subscribed
   - Uses current_gateway (already authenticated)
   - Streams to gateway-specific channel
   - Updates heartbeat
   - Subscription accepted ✅

Previous Flow (Before Fix)

1. Client connects to WebSocket
   URL: ws://host/cable?api_key=gw_live_...

2. ApplicationCable::Connection#connect
   - Authenticates gateway
   - Connection accepted ✅

3. Client subscribes to GatewayChannel
   Message: {"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"}

4. GatewayChannel#subscribed (BROKEN)
   - Looks for params[:api_key_digest] ❌ (doesn't exist)
   - Gateway lookup fails ❌
   - Subscription rejected ❌

The Fix

Before (Broken)

class GatewayChannel < ApplicationCable::Channel
  def subscribed
    # Authenticate gateway using API key from params
    api_key_digest = params[:api_key_digest]  # ❌ This was never passed!

    unless api_key_digest
      reject  # ❌ Always rejected here
      return
    end

    @gateway = Gateway.find_by(api_key_digest: api_key_digest, active: true)

    unless @gateway
      reject
      return
    end

    stream_for @gateway
    @gateway.heartbeat!
    Rails.logger.info("Gateway #{@gateway.device_id} connected via WebSocket")
  end
end

After (Fixed)

class GatewayChannel < ApplicationCable::Channel
  def subscribed
    # Gateway is already authenticated at the connection level
    # current_gateway is set by ApplicationCable::Connection
    @gateway = current_gateway  # ✅ Use already-authenticated gateway

    unless @gateway
      reject
      return
    end

    # Subscribe to gateway-specific channel
    stream_for @gateway

    # Update gateway status
    @gateway.heartbeat!

    Rails.logger.info("Gateway #{@gateway.device_id} subscribed to GatewayChannel")
  end
end

Key Changes

  1. Removed params[:api_key_digest] lookup (it was never passed)
  2. Use current_gateway which is set by the connection authentication
  3. Simplified authentication - it only happens once at the connection level
  4. Removed redundant authentication check

Connection Authentication

The connection-level authentication in ApplicationCable::Connection is the correct place for this:

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_gateway

    def connect
      self.current_gateway = find_verified_gateway
      logger.info "Gateway #{current_gateway.device_id} connected"
    end

    private

    def find_verified_gateway
      # Get API key from request params (query string)
      api_key = request.params[:api_key]

      if api_key.blank?
        reject_unauthorized_connection
      end

      # Hash the API key and find gateway
      api_key_digest = Digest::SHA256.hexdigest(api_key)
      gateway = Gateway.find_by(api_key_digest: api_key_digest, active: true)

      if gateway
        gateway
      else
        reject_unauthorized_connection
      end
    end
  end
end

Testing the Fix

Test with wscat

wscat -c "ws://localhost:3000/cable?api_key=gw_live_your_key_here"

After connection, subscribe to the channel:

{"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"}

Expected Response:

{
  "identifier": "{\"channel\":\"GatewayChannel\"}",
  "type": "confirm_subscription"
}

Test from Android

val wsUrl = "ws://192.168.1.100:3000/cable?api_key=$apiKey"
val request = Request.Builder().url(wsUrl).build()

webSocket = client.newWebSocket(request, object : WebSocketListener() {
    override fun onOpen(webSocket: WebSocket, response: Response) {
        // Connection successful

        // Subscribe to channel
        val subscribeMsg = JSONObject().apply {
            put("command", "subscribe")
            put("identifier", """{"channel":"GatewayChannel"}""")
        }
        webSocket.send(subscribeMsg.toString())
    }

    override fun onMessage(webSocket: WebSocket, text: String) {
        val json = JSONObject(text)
        if (json.optString("type") == "confirm_subscription") {
            // Subscription successful! ✅
            Log.d(TAG, "Subscribed to GatewayChannel")
        }
    }
})

Why This Matters

Before Fix

  • All subscription attempts were rejected
  • Gateways couldn't receive SMS send commands
  • Real-time communication was broken
  • Messages stuck in "pending" state

After Fix

  • Subscriptions work correctly
  • Gateways receive SMS send commands in real-time
  • Bidirectional communication enabled
  • Messages sent immediately to online gateways
  • app/channels/application_cable/connection.rb - Connection authentication (unchanged, already correct)
  • app/channels/gateway_channel.rb - Channel subscription (FIXED)
  • CABLE_DOCUMENTATION.md - WebSocket integration guide (already correct)
  • API_DOCUMENTATION.md - Full API docs (already correct)

Summary

The fix removes redundant authentication from the channel subscription layer and properly uses the current_gateway that was already authenticated at the connection level. This follows Action Cable best practices where:

  1. Connection authentication happens when WebSocket connects
  2. Channel subscription happens after connection is authenticated
  3. No re-authentication needed in channels - use current_gateway or other identifiers set at connection level

WebSocket connections from Android devices should now work correctly! 🚀