def __init__(self, protocol=PROTOCOL_TCP): self.cm_servers = CMServerList() if protocol == CMClient.PROTOCOL_TCP: self.connection = TCPConnection() else: raise ValueError("Only TCP is supported") self.on(EMsg.ChannelEncryptRequest, self.__handle_encrypt_request), self.on(EMsg.Multi, self.__handle_multi), self.on(EMsg.ClientLogOnResponse, self._handle_logon), self.on(EMsg.ClientCMList, self._handle_cm_list),
def __init__(self, protocol=0): self._LOG = logging.getLogger("CMClient") self.cm_servers = CMServerList() if protocol == CMClient.TCP: self.connection = TCPConnection() else: raise ValueError("Only TCP is supported") self.on(EMsg.ChannelEncryptRequest, self.__handle_encrypt_request), self.on(EMsg.Multi, self.__handle_multi), self.on(EMsg.ClientLogOnResponse, self._handle_logon), self.on(EMsg.ClientCMList, self._handle_cm_list),
class CMClient(EventEmitter): """ CMClient provides a secure message channel to Steam CM servers Can be used as mixing or on it's own. Incoming messages are parsed and emitted as events using their :class:`steam.enums.emsg.EMsg` as event identifier """ EVENT_CONNECTED = 'connected' """Connection establed to CM server """ EVENT_DISCONNECTED = 'disconnected' """Connection closed """ EVENT_RECONNECT = 'reconnect' """Delayed connect :param delay: delay in seconds :type delay: int """ EVENT_CHANNEL_SECURED = 'channel_secured' """After successful completion of encryption handshake """ EVENT_ERROR = 'error' """When login is denied :param eresult: reason :type eresult: :class:`.EResult` """ EVENT_EMSG = 0 """All incoming messages are emitted with their :class:`.EMsg` number. """ PROTOCOL_TCP = 0 #: TCP protocol enum PROTOCOL_UDP = 1 #: UDP protocol enum verbose_debug = False #: print message connects in debug auto_discovery = True #: enables automatic CM discovery cm_servers = None #: a instance of :class:`.CMServerList` current_server_addr = None #: (ip, port) tuple _seen_logon = False _connecting = False connected = False #: :class:`True` if connected to CM channel_secured = False #: :class:`True` once secure channel handshake is complete channel_key = None #: channel encryption key channel_hmac = None #: HMAC secret steam_id = SteamID() #: :class:`.SteamID` of the current user session_id = None #: session id when logged in cell_id = 0 #: cell id provided by CM _recv_loop = None _heartbeat_loop = None _LOG = None def __init__(self, protocol=PROTOCOL_TCP): self._LOG = logging.getLogger("CMClient") self.cm_servers = CMServerList() if protocol == CMClient.PROTOCOL_TCP: self.connection = TCPConnection() else: raise ValueError("Only TCP is supported") self.on(EMsg.ChannelEncryptRequest, self.__handle_encrypt_request), self.on(EMsg.Multi, self.__handle_multi), self.on(EMsg.ClientLogOnResponse, self._handle_logon), self.on(EMsg.ClientCMList, self._handle_cm_list), def emit(self, event, *args): if event is not None: self._LOG.debug("Emit event: %s" % repr(event)) super(CMClient, self).emit(event, *args) def connect(self, retry=0, delay=0): """Initiate connection to CM. Blocks until connected unless ``retry`` is specified. :param retry: number of retries before returning. Unlimited when set to ``None`` :type retry: :class:`int` :param delay: delay in seconds before connection attempt :type delay: :class:`int` :return: successful connection :rtype: :class:`bool` """ if self.connected: self._LOG.debug("Connect called, but we are connected?") return if self._connecting: self._LOG.debug("Connect called, but we are already connecting.") return self._connecting = True if delay: self._LOG.debug("Delayed connect: %d seconds" % delay) self.emit(self.EVENT_RECONNECT, delay) self.sleep(delay) self._LOG.debug("Connect initiated.") i = count(0) while len(self.cm_servers) == 0: if not self.auto_discovery or (retry and next(i) >= retry): if not self.auto_discovery: self._LOG.error( "CM server list is empty. Auto discovery is off.") self._connecting = False return False if not self.cm_servers.bootstrap_from_webapi(): self.cm_servers.bootstrap_from_dns() for i, server_addr in enumerate(cycle(self.cm_servers), start=next(i) - 1): if retry and i >= retry: self._connecting = False return False start = time() if self.connection.connect(server_addr): break self._LOG.debug("Failed to connect. Retrying...") diff = time() - start if diff < 5: self.sleep(5 - diff) self.current_server_addr = server_addr self.connected = True self.emit(self.EVENT_CONNECTED) self._recv_loop = gevent.spawn(self._recv_messages) self._connecting = False return True def disconnect(self): """Close connection""" if not self.connected: return self.connected = False self.connection.disconnect() if self._heartbeat_loop: self._heartbeat_loop.kill() self._recv_loop.kill() self._reset_attributes() self.emit(self.EVENT_DISCONNECTED) def _reset_attributes(self): for name in [ 'connected', 'channel_secured', 'channel_key', 'channel_hmac', 'steam_id', 'session_id', '_seen_logon', '_recv_loop', '_heartbeat_loop', ]: self.__dict__.pop(name, None) def send(self, message): """ Send a message :param message: a message instance :type message: :class:`steam.core.msg.Msg`, :class:`steam.core.msg.MsgProto` """ if not isinstance(message, (Msg, MsgProto)): raise ValueError("Expected Msg or MsgProto, got %s" % message) if self.steam_id: message.steamID = self.steam_id if self.session_id: message.sessionID = self.session_id if self.verbose_debug: self._LOG.debug("Outgoing: %s\n%s" % (repr(message), str(message))) else: self._LOG.debug("Outgoing: %s", repr(message)) data = message.serialize() if self.channel_key: if self.channel_hmac: data = crypto.symmetric_encrypt_HMAC(data, self.channel_key, self.channel_hmac) else: data = crypto.symmetric_encrypt(data, self.channel_key) self.connection.put_message(data) def _recv_messages(self): for message in self.connection: if not self.connected: break if self.channel_key: if self.channel_hmac: try: message = crypto.symmetric_decrypt_HMAC( message, self.channel_key, self.channel_hmac) except RuntimeError as e: self._LOG.exception(e) break else: message = crypto.symmetric_decrypt(message, self.channel_key) gevent.spawn(self._parse_message, message) self.idle() if not self._seen_logon and self.channel_secured: if self.wait_event('disconnected', timeout=5) is not None: return gevent.spawn(self.disconnect) def _parse_message(self, message): emsg_id, = struct.unpack_from("<I", message) emsg = EMsg(clear_proto_bit(emsg_id)) if not self.connected and emsg != EMsg.ClientLogOnResponse: self._LOG.debug( "Dropped unexpected message: %s (is_proto: %s)", repr(emsg), is_proto(emsg_id), ) return if emsg in ( EMsg.ChannelEncryptRequest, EMsg.ChannelEncryptResponse, EMsg.ChannelEncryptResult, ): msg = Msg(emsg, message, parse=False) else: try: if is_proto(emsg_id): msg = MsgProto(emsg, message, parse=False) else: msg = Msg(emsg, message, extended=True, parse=False) except Exception as e: self._LOG.fatal( "Failed to deserialize message: %s (is_proto: %s)", repr(emsg), is_proto(emsg_id)) self._LOG.exception(e) return if self.count_listeners(emsg) or self.verbose_debug: msg.parse() if self.verbose_debug: self._LOG.debug("Incoming: %s\n%s" % (repr(msg), str(msg))) else: self._LOG.debug("Incoming: %s", repr(msg)) self.emit(emsg, msg) return emsg, msg def __handle_encrypt_request(self, req): self._LOG.debug("Securing channel") try: if req.body.protocolVersion != 1: raise RuntimeError("Unsupported protocol version") if req.body.universe != EUniverse.Public: raise RuntimeError("Unsupported universe") except RuntimeError as e: self._LOG.exception(e) gevent.spawn(self.disconnect) return resp = Msg(EMsg.ChannelEncryptResponse) challenge = req.body.challenge key, resp.body.key = crypto.generate_session_key(challenge) resp.body.crc = binascii.crc32(resp.body.key) & 0xffffffff self.send(resp) result = self.wait_event(EMsg.ChannelEncryptResult, timeout=5) if result is None: self.cm_servers.mark_bad(self.current_server_addr) gevent.spawn(self.disconnect) return eresult = result[0].body.eresult if eresult != EResult.OK: self._LOG.error("Failed to secure channel: %s" % eresult) gevent.spawn(self.disconnect) return self.channel_key = key if challenge: self._LOG.debug("Channel secured") self.channel_hmac = key[:16] else: self._LOG.debug("Channel secured (legacy mode)") self.channel_secured = True self.emit(self.EVENT_CHANNEL_SECURED) def __handle_multi(self, msg): self._LOG.debug("Multi: Unpacking") if msg.body.size_unzipped: self._LOG.debug("Multi: Decompressing payload (%d -> %s)" % ( len(msg.body.message_body), msg.body.size_unzipped, )) with GzipFile(fileobj=BytesIO(msg.body.message_body)) as f: data = f.read() if len(data) != msg.body.size_unzipped: self._LOG.fatal("Unzipped size mismatch") gevent.spawn(self.disconnect) return else: data = msg.body.message_body while len(data) > 0: size, = struct.unpack_from("<I", data) self._parse_message(data[4:4 + size]) data = data[4 + size:] def __heartbeat(self, interval): message = MsgProto(EMsg.ClientHeartBeat) while True: self.sleep(interval) self.send(message) def _handle_logon(self, msg): result = msg.body.eresult if result in (EResult.TryAnotherCM, EResult.ServiceUnavailable): self.cm_servers.mark_bad(self.current_server_addr) self.disconnect() elif result == EResult.OK: self._seen_logon = True self._LOG.debug("Logon completed") self.steam_id = SteamID(msg.header.steamid) self.session_id = msg.header.client_sessionid self.cell_id = msg.body.cell_id if self._heartbeat_loop: self._heartbeat_loop.kill() self._LOG.debug("Heartbeat started.") interval = msg.body.out_of_game_heartbeat_seconds self._heartbeat_loop = gevent.spawn(self.__heartbeat, interval) else: self.emit(self.EVENT_ERROR, EResult(result)) self.disconnect() def _handle_cm_list(self, msg): self._LOG.debug("Updating CM list") new_servers = zip(map(ip_from_int, msg.body.cm_addresses), msg.body.cm_ports) self.cm_servers.clear() self.cm_servers.merge_list(new_servers) self.cm_servers.cell_id = self.cell_id def sleep(self, seconds): """Yeild and sleep N seconds. Allows other greenlets to run""" gevent.sleep(seconds) def idle(self): """Yeild in the current greenlet and let other greenlets run""" gevent.idle()
class CMClient(EventEmitter): """ CMClient provides a secure message channel to Steam CM servers Can be used as mixing or on it's own. Incoming messages are parsed and emitted as events using their :class:`steam.enums.emsg.EMsg` as event identifier """ TCP = 0 #: TCP protocol enum UDP = 1 #: UDP protocol enum verbose_debug = False #: print message connects in debug cm_servers = None #: a instance of :class:`steam.core.cm.CMServerList` current_server_addr = None #: (ip, port) tuple _seen_logon = False _connecting = False connected = False #: :class:`True` if connected to CM channel_secured = False #: :class:`True` once secure channel handshake is complete channel_key = None #: channel encryption key channel_hmac = None #: HMAC secret steam_id = SteamID() #: :class:`steam.steamid.SteamID` of the current user session_id = None #: session id when logged in webapi_authenticate_user_nonce = None #: nonce for the getting a web session _recv_loop = None _heartbeat_loop = None _LOG = None def __init__(self, protocol=0): self._LOG = logging.getLogger("CMClient") self.cm_servers = CMServerList() if protocol == CMClient.TCP: self.connection = TCPConnection() else: raise ValueError("Only TCP is supported") self.on(EMsg.ChannelEncryptRequest, self.__handle_encrypt_request), self.on(EMsg.Multi, self.__handle_multi), self.on(EMsg.ClientLogOnResponse, self._handle_logon), self.on(EMsg.ClientCMList, self._handle_cm_list), def emit(self, event, *args): if event is not None: self._LOG.debug("Emit event: %s" % repr(event)) super(CMClient, self).emit(event, *args) def connect(self, retry=0, delay=0): """Initiate connection to CM. Blocks until connected unless ``retry`` is specified. :param retry: number of retries before returning. Unlimited when set to ``None`` :type retry: :class:`int` :param delay: delay in secnds before connection attempt :type delay: :class:`int` :return: successful connection :rtype: :class:`bool` """ if self.connected: self._LOG.debug("Connect called, but we are connected?") return if self._connecting: self._LOG.debug("Connect called, but we are already connecting.") return self._connecting = True if delay: self._LOG.debug("Delayed connect: %d seconds" % delay) self.emit('reconnect', delay) gevent.sleep(delay) self._LOG.debug("Connect initiated.") for i, server_addr in enumerate(self.cm_servers): if retry and i > retry: return False start = time() if self.connection.connect(server_addr): break diff = time() - start self._LOG.debug("Failed to connect. Retrying...") if diff < 5: gevent.sleep(5 - diff) self.current_server_addr = server_addr self.connected = True self.emit("connected") self._recv_loop = gevent.spawn(self._recv_messages) self._connecting = False return True def disconnect(self): """Close connection .. note:: When ``reconnect`` is ``True``, the delay before reconnect is determined by exponential backoff algorithm starting from 0 seconds and up to 31 seconds :param reconnect: attempt to reconnect :type reconnect: :class:`bool` :param nodelay: set to ``True`` to ignore reconnect delay :type nodelay: :class:`bool` Event: ``disconnected`` Event: ``reconnect`` instead of ``disconnected`` when going to reconnect :param delay_seconds: seconds delay before reconnect is attempted :type delay_seconds: :class:`int` """ if not self.connected: return self.connected = False self.connection.disconnect() if self._heartbeat_loop: self._heartbeat_loop.kill() self._recv_loop.kill() self._reset_attributes() self.emit('disconnected') def _reset_attributes(self): for name in ['connected', 'channel_secured', 'channel_key', 'channel_hmac', 'steam_id', 'session_id', 'webapi_authenticate_user_nonce', '_seen_logon', '_recv_loop', '_heartbeat_loop', ]: self.__dict__.pop(name, None) def send(self, message): """ Send a message :param message: a message instance :type message: :class:`steam.core.msg.Msg`, :class:`steam.core.msg.MsgProto` """ if not isinstance(message, (Msg, MsgProto)): raise ValueError("Expected Msg or MsgProto, got %s" % message) if self.steam_id: message.steamID = self.steam_id if self.session_id: message.sessionID = self.session_id if self.verbose_debug: self._LOG.debug("Outgoing: %s\n%s" % (repr(message), str(message))) else: self._LOG.debug("Outgoing: %s", repr(message)) data = message.serialize() if self.channel_key: if self.channel_hmac: data = crypto.symmetric_encrypt_HMAC(data, self.channel_key, self.channel_hmac) else: data = crypto.symmetric_encrypt(data, self.channel_key) self.connection.put_message(data) def _recv_messages(self): for message in self.connection: if not self.connected: break if self.channel_key: if self.channel_hmac: try: message = crypto.symmetric_decrypt_HMAC(message, self.channel_key, self.channel_hmac) except RuntimeError as e: self._LOG.exception(e) break else: message = crypto.symmetric_decrypt(message, self.channel_key) gevent.spawn(self._parse_message, message) gevent.idle() if not self._seen_logon and self.channel_secured: if self.wait_event('disconnected', timeout=5) is not None: return gevent.spawn(self.disconnect) def _parse_message(self, message): emsg_id, = struct.unpack_from("<I", message) emsg = EMsg(clear_proto_bit(emsg_id)) if not self.connected and emsg != EMsg.ClientLogOnResponse: return if emsg in (EMsg.ChannelEncryptRequest, EMsg.ChannelEncryptResponse, EMsg.ChannelEncryptResult, ): msg = Msg(emsg, message) else: try: if is_proto(emsg_id): msg = MsgProto(emsg, message) else: msg = Msg(emsg, message, extended=True) except Exception as e: self._LOG.fatal("Failed to deserialize message: %s (is_proto: %s)", str(emsg), is_proto(emsg_id) ) self._LOG.exception(e) if self.verbose_debug: self._LOG.debug("Incoming: %s\n%s" % (repr(msg), str(msg))) else: self._LOG.debug("Incoming: %s", repr(msg)) self.emit(emsg, msg) def __handle_encrypt_request(self, req): self._LOG.debug("Securing channel") try: if req.body.protocolVersion != 1: raise RuntimeError("Unsupported protocol version") if req.body.universe != EUniverse.Public: raise RuntimeError("Unsupported universe") except RuntimeError as e: self._LOG.exception(e) gevent.spawn(self.disconnect) return resp = Msg(EMsg.ChannelEncryptResponse) challenge = req.body.challenge key, resp.body.key = crypto.generate_session_key(challenge) resp.body.crc = binascii.crc32(resp.body.key) & 0xffffffff self.send(resp) result = self.wait_event(EMsg.ChannelEncryptResult, timeout=5) if result is None: self.cm_servers.mark_bad(self.current_server_addr) gevent.spawn(self.disconnect) return eresult = result[0].body.eresult if eresult != EResult.OK: self._LOG.error("Failed to secure channel: %s" % eresult) gevent.spawn(self.disconnect) return self.channel_key = key if challenge: self._LOG.debug("Channel secured") self.channel_hmac = key[:16] else: self._LOG.debug("Channel secured (legacy mode)") self.channel_secured = True self.emit('channel_secured') def __handle_multi(self, msg): self._LOG.debug("Multi: Unpacking") if msg.body.size_unzipped: self._LOG.debug("Multi: Decompressing payload (%d -> %s)" % ( len(msg.body.message_body), msg.body.size_unzipped, )) with GzipFile(fileobj=BytesIO(msg.body.message_body)) as f: data = f.read() if len(data) != msg.body.size_unzipped: self._LOG.fatal("Unzipped size mismatch") gevent.spawn(self.disconnect) return else: data = msg.body.message_body while len(data) > 0: size, = struct.unpack_from("<I", data) self._parse_message(data[4:4+size]) data = data[4+size:] def __heartbeat(self, interval): message = MsgProto(EMsg.ClientHeartBeat) while True: gevent.sleep(interval) self.send(message) def _handle_logon(self, msg): result = msg.body.eresult if result in (EResult.TryAnotherCM, EResult.ServiceUnavailable ): self.cm_servers.mark_bad(self.current_server_addr) self.disconnect() elif result == EResult.OK: self._seen_logon = True self._LOG.debug("Logon completed") self.steam_id = SteamID(msg.header.steamid) self.session_id = msg.header.client_sessionid self.webapi_authenticate_user_nonce = msg.body.webapi_authenticate_user_nonce.encode('ascii') if self._heartbeat_loop: self._heartbeat_loop.kill() self._LOG.debug("Heartbeat started.") interval = msg.body.out_of_game_heartbeat_seconds self._heartbeat_loop = gevent.spawn(self.__heartbeat, interval) else: self.emit("error", EResult(result)) self.disconnect() def _handle_cm_list(self, msg): self._LOG.debug("Updating CM list") new_servers = zip(map(ip_from_int, msg.body.cm_addresses), msg.body.cm_ports) self.cm_servers.merge_list(new_servers)