steamrb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ require 'logger'
3
+
4
+ module Steam
5
+ # Small wrapper around the Ruby Logger class
6
+ class Logger < ::Logger
7
+ # Log level WARN
8
+ WARN = ::Logger::WARN
9
+ # Log level INFO
10
+ INFO = ::Logger::INFO
11
+ # Log level ERROR
12
+ ERROR = ::Logger::ERROR
13
+ # Log level DEBUG
14
+ DEBUG = ::Logger::DEBUG
15
+ # Log level FATAL
16
+ FATAL = ::Logger::FATAL
17
+
18
+ # Create a Logger that logs to a given IO object
19
+ #
20
+ # @param io [#read] The io object
21
+ # @param level [Integer] log lvel
22
+ def initialize(io, level = DEBUG)
23
+ self.level = level
24
+ super(io)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+ require 'socket'
3
+ require 'ostruct'
4
+
5
+ module Steam
6
+ module Networking
7
+ # Represents a connection to Steam. Holds the session key, yields
8
+ # packets to the caller
9
+ #
10
+ # @example Creating a Connection
11
+ # connection = Connection.new(Server.new(ip, port))
12
+ # connection.each_packet do |packet|
13
+ # end
14
+ #
15
+ # @see Packet
16
+ class Connection
17
+ # The internal list of Packet objects read from the TCP socket
18
+ attr_reader :packets
19
+
20
+ def initialize(server, socket = nil)
21
+ @session_key = nil
22
+ @packets = PacketList.new
23
+ @socket = socket
24
+ @mutex = Mutex.new
25
+ @server = server
26
+ self
27
+ end
28
+
29
+ # Opens the underlying socket
30
+ def open
31
+ self.session_key = nil
32
+ self.disconnecting = false
33
+ self.socket = TCPSocket.open(@server.host, @server.port)
34
+ end
35
+
36
+ # Closes the socket
37
+ def disconnect
38
+ self.session_key = nil
39
+ self.disconnecting = true
40
+ socket.close
41
+ end
42
+
43
+ # Determine if the connection is disconnected. This is determined
44
+ # by the socket being opened or close.
45
+ #
46
+ # @return [Bool]
47
+ def disconnected?
48
+ socket.closed?
49
+ end
50
+
51
+ # Sets the connections session key. Used for crypto
52
+ def session_key=(v)
53
+ @mutex.synchronize { @session_key = v }
54
+ end
55
+
56
+ # Send a Message to the Steam server. If we are in an encrypted
57
+ # session the Packet body is encrypted before it is sent.
58
+ #
59
+ # @see Protocol::Message
60
+ # @see Networking::Packet
61
+ #
62
+ # @param msg [Networking::Message] the message to send
63
+ def send_msg(msg)
64
+ data = msg.encode
65
+ data = Crypto.encrypt(data, session_key) if session_key
66
+
67
+ packet = Packet.new(data)
68
+ write_socket(packet.encode)
69
+ end
70
+
71
+ # Yields each Packet from the socket to the calling block.
72
+ #
73
+ # Starts a thread for reading from the socket into a queue. And another
74
+ # thread to consume that queue. Packets that are consumed from the queue
75
+ # will be yielded to the block
76
+ #
77
+ # @see Networking::Packet
78
+ def each_packet(&block)
79
+ start_read_thread
80
+ start_listen_thread(&block)
81
+ end
82
+
83
+ private
84
+
85
+ # @api private
86
+ def start_listen_thread
87
+ @listen_thread = Thread.new do
88
+ until disconnected?
89
+ packet = @packets.pop
90
+ yield packet if packet
91
+ end
92
+ end
93
+ @listen_thread.abort_on_exception = true
94
+ end
95
+
96
+ # @api private
97
+ def start_read_thread
98
+ @read_thread = Thread.new do
99
+ until disconnected?
100
+ packet = recv_packet_from_socet
101
+ @packets.add(packet) if packet
102
+ end
103
+ end
104
+ @read_thread.abort_on_exception = true
105
+ end
106
+
107
+ # @api private
108
+ def recv_packet_from_socet
109
+ packet_size = read_packet_header
110
+ read_packet(packet_size) if packet_size
111
+ end
112
+
113
+ # @api private
114
+ def read_packet_header
115
+ data = read_socket(8)
116
+ return nil if data.nil?
117
+
118
+ header = ByteReader.new(StringIO.new(data))
119
+
120
+ size = header.signed_int32
121
+ magic = header.string(4)
122
+ raise "invalid packet: size=#{size}, magic=#{magic}" unless
123
+ size.nonzero? && magic == Packet::TCP_MAGIC
124
+ size
125
+ end
126
+
127
+ # @api private
128
+ def read_packet(packet_size)
129
+ data = read_socket(packet_size)
130
+ data = Crypto.decrypt(data, session_key) if session_key
131
+ Packet.new(data)
132
+ end
133
+
134
+ # @api private
135
+ def write_socket(data)
136
+ socket.write(data)
137
+ rescue IOError => e
138
+ # If we are disconnecting, the socket is allowed to be closed
139
+ return 0 if disconnecting
140
+ raise e
141
+ end
142
+
143
+ # @api private
144
+ def read_socket(len)
145
+ socket.read(len)
146
+ rescue IOError => e
147
+ # If we are disconnecting, the socket is allowed to be closed
148
+ return nil if disconnecting
149
+ raise e
150
+ end
151
+
152
+ # @api private
153
+ def socket
154
+ @mutex.synchronize { @socket }
155
+ end
156
+
157
+ # @api private
158
+ def socket=(socket)
159
+ @mutex.synchronize { @socket = socket }
160
+ end
161
+
162
+ # @api private
163
+ def disconnecting
164
+ @mutex.synchronize { @disconnecting }
165
+ end
166
+
167
+ # @api private
168
+ def disconnecting=(v)
169
+ @mutex.synchronize { @disconnecting = v }
170
+ end
171
+
172
+ # @api private
173
+ def session_key
174
+ @mutex.synchronize { @session_key }
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+ module Steam
3
+ module Networking
4
+ # An object representing a packet from the connection.
5
+ #
6
+ # The packets come in the form
7
+ #
8
+ # LENGTH MAGIC BODY
9
+ #
10
+ # To create a packet only the body needs to be supplied. The body should
11
+ # never contain the MAGIC or LENGTH
12
+ class Packet
13
+ # Valve's TCP Packet identifier
14
+ TCP_MAGIC = 'VT01'
15
+
16
+ # The raw bytes of the TCP packet
17
+ attr_reader :body
18
+
19
+ # The EMsg type derived from the Packet body
20
+ attr_reader :msg_type
21
+ alias emsg msg_type
22
+
23
+ # Instantiates a Packet object
24
+ #
25
+ # @param body [String]
26
+ def initialize(body)
27
+ raise 'packet must have raw tcp body' if body.nil? || body.empty?
28
+
29
+ @body = body
30
+ @io = ByteReader.new(StringIO.new(body))
31
+ @iden = ByteReader.new(StringIO.new(body)).unsigned_int32
32
+ @msg_type = @iden & ~Message::PROTO_MASK
33
+ self
34
+ end
35
+
36
+ # Encode a Packet to a byte string
37
+ #
38
+ # @return [String] byte string representation of the Packet
39
+ def encode
40
+ stream = ByteWriter.new
41
+ stream.write_unsigned_int32(body.length)
42
+ stream.write_string(TCP_MAGIC)
43
+ stream.write_string(body)
44
+ stream.string
45
+ end
46
+
47
+ # Returns true if the Packet contains other packets
48
+ #
49
+ # @return [Bool]
50
+ def multi?
51
+ @msg_type == EMsg::MULTI
52
+ end
53
+
54
+ # Returns true if the Packet is Protobuf backed
55
+ #
56
+ # @return [Bool]
57
+ def proto?
58
+ (@iden & Message::PROTO_MASK) == Message::PROTO_MASK
59
+ end
60
+
61
+ # Converts the Packet into a Message object.
62
+ #
63
+ # @param msg [Message] The message to decode from the packet
64
+ # @return [Message] the resulting message
65
+ def as_message(msg)
66
+ cm = if proto?
67
+ ProtobufMessage.new(MsgHdrProtoBuf.new, msg, @msg_type)
68
+ else
69
+ ClientMessage.new(MsgHdr.new, msg, @msg_type)
70
+ end
71
+
72
+ cm.decode(@io)
73
+ cm
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ module Steam
3
+ module Networking
4
+ # Holds a list of Packet objects in a non-blocking thread safe queue
5
+ class PacketList
6
+ def initialize
7
+ @packets = Thread::Queue.new
8
+ end
9
+
10
+ # Adds a Packet to the internal queue
11
+ #
12
+ # @param packet [Packet] the Packet to add
13
+ def add(packet)
14
+ @packets.push(packet)
15
+ end
16
+
17
+ # Removes the next message
18
+ #
19
+ # @return [Packet] The Packet object
20
+ def pop
21
+ @packets.pop(true)
22
+ rescue ThreadError
23
+ nil
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ module Steam
3
+ # Namespace for any Networking concerns.
4
+ #
5
+ # @see Networking::Connection
6
+ # @see Networking::Packet
7
+ # @see Networking::PacketList
8
+ module Networking
9
+ end
10
+ end
11
+
12
+ require 'steam/networking/packet_list'
13
+ require 'steam/networking/connection'
14
+ require 'steam/networking/packet'
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+ module Steam
3
+ # Allows "plugins" via gems. The plugins are just gems that react to client
4
+ # events an maybe expose an additional api. For example a CSGO plugin would
5
+ # be respoinsible for strictly CSGO related things and could have syntatic
6
+ # candy for things such as logging the player on, and handling the connection
7
+ # flow of CSGO related connection events. It could also provide the abililty
8
+ # to send specific GC messages like the requesting of match info.
9
+ #
10
+ # The plugins are refered to by name, ie: csgo
11
+ #
12
+ # The CSGO plugin would be a gem that is named `steam-csgo` and has the
13
+ # following struture.
14
+ #
15
+ # -steam-csgo
16
+ # - lib
17
+ # - steam
18
+ # - csgo
19
+ # - base.rb
20
+ # - csgo.rb
21
+ # - ... other gem things ...
22
+ #
23
+ # The fastpeek/steam-csgo gem is a CSGO plugin for references
24
+ class Plugins
25
+ include Enumerable
26
+
27
+ attr_reader :client, :loaded_plugins
28
+
29
+ # Instantiate a new Plugins engine
30
+ def initialize(client)
31
+ @client = client
32
+ @mutex = Mutex.new
33
+ @plugins = []
34
+ @loaded_plugins = []
35
+ @loaded = false
36
+ end
37
+
38
+ # Determine if the Client has a plugin loaded
39
+ #
40
+ # @param plugin [Symbol]
41
+ # @return [Bool]
42
+ def loaded?(plugin)
43
+ @loaded_plugins.include?(plugin.to_sym)
44
+ end
45
+
46
+ # Add a plugin to the internal list. Plugins are not loaded until
47
+ # #load is called.
48
+ def plugin(plugin)
49
+ @plugins << plugin.to_sym
50
+ @plugins
51
+ end
52
+
53
+ # Load the plugins. Attempt to require each plugin. If the require call
54
+ # fails an error message is printed.
55
+ #
56
+ # If the client has already loaded the plugins, they are not loaded again.
57
+ #
58
+ # @return [Integer] the number of plugins it loaded
59
+ def load
60
+ return true if @loaded
61
+ @loaded_plugins = require_plugins
62
+ Steam.logger.debug("Loaded plugins: #{loaded_plugins}")
63
+ setup_plugin_helpers(loaded_plugins)
64
+ @loaded = true
65
+ @loaded_plugins.count >= 1
66
+ end
67
+
68
+ private
69
+
70
+ # @api private
71
+ def setup_plugin_helpers(loaded_plugins)
72
+ loaded_plugins.each { |plugin| define_plugin_ivar(plugin) }
73
+ end
74
+
75
+ # @api private
76
+ def define_plugin_ivar(plugin)
77
+ client.class.class_eval do
78
+ define_method plugin do
79
+ var = instance_variable_get("@#{plugin}")
80
+
81
+ unless var
82
+ val = "Steam::#{plugin.capitalize}::Base".constantize.new(self)
83
+ var = instance_variable_set("@#{plugin}", val)
84
+ end
85
+ var
86
+ end
87
+ end
88
+ end
89
+
90
+ # @api private
91
+ def require_plugins
92
+ required_plugins = @plugins.map do |plugin|
93
+ begin
94
+ require "steam/#{plugin}"
95
+ plugin
96
+ rescue LoadError
97
+ msg = "Failed to load #{plugin} plugin. Is it in your Gemfile?"
98
+ Steamd.logger.error(msg)
99
+ nil
100
+ end
101
+ end
102
+
103
+ required_plugins.compact
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+ module Steam
3
+ module Protocol
4
+ # Represents a Message received or sent to the Steam
5
+ # network.
6
+ #
7
+ # Client messages typically don't have session or steam meta
8
+ # data associated with them.
9
+ class ClientMessage
10
+ include Message
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+ module Steam
3
+ module Protocol
4
+ # Represents a Game Coordinator Message. Not protobuf backed
5
+ class GcMessage
6
+ include Message
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ module Steam
3
+ module Protocol
4
+ # Represents a GC Message received or sent to the Steam
5
+ # network via the Game Coordinator.
6
+ class GcProtobufMessage < ProtobufMessage
7
+ # Convert the internal gc message into the message we care about
8
+ #
9
+ # @param klass [Message] the type of message
10
+ def as(klass)
11
+ klass = klass.class unless klass.is_a?(Class)
12
+ io = StringIO.new(body.payload)
13
+ @header.decode_from(io)
14
+ klass.decode(io.read)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+ module Steam
3
+ module Protocol
4
+ # The base Steam message. Each message contains a header, body and emsg
5
+ # attribute.
6
+ #
7
+ # The #emsg corresponds to the constants EMsg in the SteamLanguage
8
+ #
9
+ # @see ClientMessage
10
+ # @see ProtobufMessage
11
+ module Message
12
+ # Valve's mask for determining if a packet is Protobuf backed
13
+ PROTO_MASK = 0x80000000
14
+
15
+ # Each packet has a header. The header is defined by the Steam Language.
16
+ # A header can be a MsgHdr, or a MsgHdrProtoBuf
17
+ #
18
+ # @see MsgHdrProtoBuf
19
+ # @see MsgHdr
20
+ attr_reader :header
21
+
22
+ # Each packet has a body, this is the Message being held in the packet.
23
+ # @see Message
24
+ attr_reader :body
25
+
26
+ # Each packet has an optional byte string payload
27
+ attr_reader :payload
28
+
29
+ # The Message type
30
+ #
31
+ # @see EMsg
32
+ attr_reader :emsg
33
+
34
+ # Instantiate a Message object
35
+ #
36
+ # @param header [MsgHdr, MsgHdrProtoBuf]
37
+ # @param body
38
+ # @param emsg
39
+ def initialize(header, body, emsg)
40
+ @header = header
41
+ @body = body
42
+ @header.msg = emsg
43
+ @payload = ByteWriter.new
44
+ @emsg = emsg
45
+ nil
46
+ end
47
+
48
+ # By default a Message is not Protobuf backed
49
+ def proto?
50
+ false
51
+ end
52
+
53
+ # The steam id associated with the message
54
+ #
55
+ # @param _sid [String] the Steam id
56
+ def steam_id=(_sid)
57
+ raise NotImplementedError
58
+ end
59
+
60
+ # The session id associated with the message
61
+ #
62
+ # @param _sid [String] the Session id
63
+ def session_id=(_sid)
64
+ raise NotImplementedError
65
+ end
66
+
67
+ # Decode a Packet from an io object
68
+ #
69
+ # @param io [#read] The IO stream
70
+ def decode(io)
71
+ @header.decode_from(io)
72
+
73
+ if @body.respond_to?(:decode_from)
74
+ # Steam language
75
+ @body.decode_from(io)
76
+ else
77
+ # Proto
78
+ @body = @body.class.decode(io.read)
79
+ end
80
+ end
81
+
82
+ # Encode a Packet from to a string
83
+ #
84
+ # @see Packet
85
+ # @return [String] The byte representation of the Packet
86
+ def encode
87
+ io = StringIO.new
88
+ io.set_encoding('BINARY')
89
+
90
+ @header.encode_to(io)
91
+ body = @body.encode
92
+ io.write(body)
93
+ io.write(@payload.string)
94
+
95
+ io.string
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+ module Steam
3
+ module Protocol
4
+ # Represents a Protobuf message. They have a protobuf header, and body.
5
+ # Their emsg is masked with the PROTO_MASK flag
6
+ class ProtobufMessage
7
+ include Message
8
+
9
+ # A Protobuf Message contains a steam id and a session id. It also uses
10
+ # the PROTO_MASK on the EMsg.
11
+ def initialize(header, body, emsg)
12
+ super(header, body, emsg | PROTO_MASK)
13
+ end
14
+
15
+ # Returns the masked protobuf emsg
16
+ def emsg
17
+ @emsg & ~PROTO_MASK
18
+ end
19
+
20
+ # Get the steam_id associated with this message
21
+ #
22
+ # @return [Integer]
23
+ def steam_id
24
+ @header.proto.steamid
25
+ end
26
+
27
+ #
28
+ # Get the steam_id associated with this message
29
+ #
30
+ # @return [Integer]
31
+ def session_id
32
+ @header.proto.client_sessionid
33
+ end
34
+
35
+ # The steam id associated with the message
36
+ #
37
+ # @param sid [String] the Steam id
38
+ def steam_id=(sid)
39
+ @header.proto.steamid = sid
40
+ end
41
+
42
+ # The session id associated with the message
43
+ #
44
+ # @param sid [String] the Session id
45
+ def session_id=(sid)
46
+ @header.proto.client_sessionid = sid
47
+ end
48
+
49
+ def proto?
50
+ true
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+ module Steam
3
+ # Encapsulates the Steam protocol
4
+ module Protocol
5
+ end
6
+ end
7
+
8
+ require 'steam/protocol/message'
9
+ require 'steam/protocol/client_message'
10
+ require 'steam/protocol/protobuf_message'
11
+ require 'steam/protocol/gc_message'
12
+ require 'steam/protocol/gc_protobuf_message'
13
+
14
+ # @todo remove these
15
+ Steam::ClientMessage = Steam::Protocol::ClientMessage
16
+ Steam::ProtobufMessage = Steam::Protocol::ProtobufMessage
17
+ Steam::GcMessage = Steam::Protocol::GcMessage
18
+ Steam::GcProtobufMessage = Steam::Protocol::GcProtobufMessage
19
+ Steam::Message = Steam::Protocol::Message
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+ module Steam
3
+ # Represents a Sentry file on disk
4
+ #
5
+ # @todo this will overwrite any existing file, namespace
6
+ # to username?
7
+ class SentryFile
8
+ # Writes the digest to the sentry file
9
+ #
10
+ # @example Write the SentryFile
11
+ # file = SentryFile.new
12
+ # file.write('sha1digest')
13
+ #
14
+ # @param digest [String]
15
+ def write(digest)
16
+ File.open(path, 'wb') do |file|
17
+ file.write(digest)
18
+ end
19
+ end
20
+
21
+ # Read the digest from the sentry file. Returns
22
+ # nil if the Sentry file does not exist.
23
+ #
24
+ # @example Read the SentryFile
25
+ # file = SentryFile.new
26
+ # file.read # => 'somedigest'
27
+ #
28
+ # @return [String,nil]
29
+ def read
30
+ return nil unless File.exist?(path)
31
+
32
+ File.open(path, 'rb', &:read)
33
+ end
34
+
35
+ private
36
+
37
+ # @api private
38
+ def path
39
+ File.expand_path('~/.steam-sentry')
40
+ end
41
+ end
42
+ end