steamrb 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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