devp2p 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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +22 -0
  4. data/lib/devp2p.rb +57 -0
  5. data/lib/devp2p/app_helper.rb +85 -0
  6. data/lib/devp2p/base_app.rb +80 -0
  7. data/lib/devp2p/base_protocol.rb +136 -0
  8. data/lib/devp2p/base_service.rb +55 -0
  9. data/lib/devp2p/command.rb +82 -0
  10. data/lib/devp2p/configurable.rb +32 -0
  11. data/lib/devp2p/connection_monitor.rb +77 -0
  12. data/lib/devp2p/control.rb +32 -0
  13. data/lib/devp2p/crypto.rb +73 -0
  14. data/lib/devp2p/crypto/ecc_x.rb +133 -0
  15. data/lib/devp2p/crypto/ecies.rb +134 -0
  16. data/lib/devp2p/discovery.rb +118 -0
  17. data/lib/devp2p/discovery/address.rb +83 -0
  18. data/lib/devp2p/discovery/kademlia_protocol_adapter.rb +11 -0
  19. data/lib/devp2p/discovery/node.rb +32 -0
  20. data/lib/devp2p/discovery/protocol.rb +342 -0
  21. data/lib/devp2p/discovery/transport.rb +105 -0
  22. data/lib/devp2p/exception.rb +30 -0
  23. data/lib/devp2p/frame.rb +197 -0
  24. data/lib/devp2p/kademlia.rb +48 -0
  25. data/lib/devp2p/kademlia/k_bucket.rb +178 -0
  26. data/lib/devp2p/kademlia/node.rb +40 -0
  27. data/lib/devp2p/kademlia/protocol.rb +284 -0
  28. data/lib/devp2p/kademlia/routing_table.rb +131 -0
  29. data/lib/devp2p/kademlia/wire_interface.rb +30 -0
  30. data/lib/devp2p/multiplexed_session.rb +110 -0
  31. data/lib/devp2p/multiplexer.rb +358 -0
  32. data/lib/devp2p/p2p_protocol.rb +170 -0
  33. data/lib/devp2p/packet.rb +35 -0
  34. data/lib/devp2p/peer.rb +329 -0
  35. data/lib/devp2p/peer_errors.rb +35 -0
  36. data/lib/devp2p/peer_manager.rb +274 -0
  37. data/lib/devp2p/rlpx_session.rb +434 -0
  38. data/lib/devp2p/sync_queue.rb +76 -0
  39. data/lib/devp2p/utils.rb +106 -0
  40. data/lib/devp2p/version.rb +13 -0
  41. data/lib/devp2p/wired_service.rb +30 -0
  42. metadata +227 -0
@@ -0,0 +1,40 @@
1
+ # -*- encoding : ascii-8bit -*-
2
+
3
+ module DEVp2p
4
+ module Kademlia
5
+
6
+ class Node
7
+
8
+ attr :id, :pubkey
9
+ attr_accessor :address
10
+
11
+ def initialize(pubkey)
12
+ raise ArgumentError, "invalid pubkey" unless pubkey.size == 64
13
+
14
+ @id = Crypto.keccak256(pubkey)
15
+ raise "invalid node id" unless @id.size * 8 == ID_SIZE
16
+
17
+ @id = Utils.big_endian_to_int @id
18
+ @pubkey = pubkey
19
+ end
20
+
21
+ def distance(other)
22
+ id ^ other.id
23
+ end
24
+
25
+ def id_distance(_id)
26
+ id ^ _id
27
+ end
28
+
29
+ def ==(other)
30
+ other.instance_of?(self.class) && pubkey == other.pubkey
31
+ end
32
+
33
+ def to_s
34
+ "<Node(#{Utils.encode_hex pubkey[0,8]})>"
35
+ end
36
+ alias inspect to_s
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,284 @@
1
+ # -*- encoding : ascii-8bit -*-
2
+
3
+ module DEVp2p
4
+ module Kademlia
5
+
6
+ class Protocol
7
+
8
+ attr :node, :wire, :routing
9
+
10
+ def initialize(node, wire)
11
+ raise ArgumentError, 'node must be Node' unless node.is_a?(Node)
12
+ raise ArgumentError, 'wire must be WireInterface' unless wire.is_a?(WireInterface)
13
+
14
+ @node = node
15
+ @wire = wire
16
+
17
+ @routing = RoutingTable.new node
18
+
19
+ @expected_pongs = {} # pingid => [timeout, node, replacement_node]
20
+ @find_requests = {} # nodeid => timeout
21
+ @deleted_pingids = {}
22
+ end
23
+
24
+ def bootstrap(nodes)
25
+ nodes.each do |node|
26
+ next if node == @node
27
+
28
+ @routing.add node
29
+ find_node @node.id, node # add self to boot node's routing table
30
+ end
31
+ end
32
+
33
+ ##
34
+ # When a Kademlia node receives any message (request or reply) from
35
+ # another node, it updates the appropriate k-bucket for the sender's node
36
+ # ID.
37
+ #
38
+ # If the sending node already exists in the recipient's k-bucket, the
39
+ # recipient moves it to the tail of the list.
40
+ #
41
+ # If the node is not already in the appropriate k-bucket and the bucket
42
+ # has fewer than k entries, then the recipient just inserts the new
43
+ # sender at the tail of the list.
44
+ #
45
+ # If the appropriate k-bucket is full, however, then the recipient pings
46
+ # the k-bucket's least-recently seen node to decide what to do.
47
+ #
48
+ # If the least-recently seen node fails to respond, it is evicted from
49
+ # the k-bucket and the new sender inserted at the tail.
50
+ #
51
+ # Otherwise, if the least-recently seen node responds, it is moved to the
52
+ # tail of the list, and the new sender's contact is discarded.
53
+ #
54
+ # k-buckets effectively implement a least-recently seen eviction policy,
55
+ # except the live nodes are never removed from the list.
56
+ #
57
+ def update(node, pingid=nil)
58
+ raise ArgumentError, 'node must be Node' unless node.is_a?(Node)
59
+
60
+ if node == @node
61
+ logger.debug 'node is self', remoteid: node
62
+ return
63
+ end
64
+
65
+ if pingid && !@expected_pongs.has_key?(pingid)
66
+ pong_nodes = @expected_pongs.values.map {|v| v[1] }.uniq
67
+ logger.debug "surprising pong", remoteid: node, expected: pong_nodes, pingid: Utils.encode_hex(pingid)[0,8]
68
+
69
+ if @deleted_pingids.has_key?(pingid)
70
+ logger.debug "surprising pong was deleted"
71
+ else
72
+ @expected_pongs.each_key do |key|
73
+ if key.end_with?(node.pubkey)
74
+ logger.debug "waiting for ping from node, but echo mismatch", node: node, expected_echo: Utils.encode_hex(key[0,8]), received_echo: Utils.encode_hex(pingid[0,8])
75
+ end
76
+ end
77
+ end
78
+
79
+ return
80
+ end
81
+
82
+ # check for timed out pings and eventually evict them
83
+ @expected_pongs.each do |_pingid, (timeout, _node, replacement)|
84
+ if Time.now > timeout
85
+ logger.debug "deleting timeout node", remoteid: _node, pingid: Utils.encode_hex(_pingid)[0,8]
86
+
87
+ @deleted_pingids[_pingid] = true
88
+ @expected_pongs.delete _pingid
89
+
90
+ @routing.delete _node
91
+
92
+ if replacement
93
+ logger.debug "adding replacement", remoteid: replacement
94
+ update replacement
95
+ return
96
+ end
97
+
98
+ # prevent node from being added later
99
+ return if _node == node
100
+ end
101
+ end
102
+
103
+ # if we had registered this node for eviction test
104
+ if @expected_pongs.has_key?(pingid)
105
+ timeout, _node, replacement = @expected_pongs[pingid]
106
+ logger.debug "received expected pong", remoteid: node
107
+
108
+ if replacement
109
+ logger.debug "adding replacement to cache", remoteid: replacement
110
+ @routing.bucket_by_node(replacement).add_replacement(replacement)
111
+ end
112
+
113
+ @expected_pongs.delete pingid
114
+ end
115
+
116
+ # add node
117
+ eviction_candidate = @routing.add node
118
+ if eviction_candidate
119
+ logger.debug "could not add", remoteid: node, pinging: eviction_candidate
120
+ ping eviction_candidate, node
121
+ else
122
+ logger.debug "added", remoteid: node
123
+ end
124
+
125
+ # check idle buckets
126
+ # idle bucket refresh:
127
+ # for each bucket which hasn't been touched in 3600 seconds
128
+ # pick a random value in the range of the bucket and perform
129
+ # discovery for that value
130
+ @routing.idle_buckets.each do |bucket|
131
+ rid = SecureRandom.random_number bucket.left, bucket.right+1
132
+ find_node rid
133
+ end
134
+
135
+ # check and removed timeout find requests
136
+ @find_requests.keys.each do |nodeid|
137
+ timeout = @find_requests[nodeid]
138
+ @find_requests.delete(nodeid) if Time.now > timeout
139
+ end
140
+
141
+ logger.debug "updated", num_nodes: @routing.size, num_buckets: @routing.buckets_count
142
+ end
143
+
144
+ # FIXME: amplification attack (need to ping pong ping pong first)
145
+ def find_node(targetid, via_node=nil)
146
+ raise ArgumentError, 'targetid must be Integer' unless targetid.is_a?(Integer)
147
+ raise ArgumentError, 'via_node must be nil or Node' unless via_node.nil? || via_node.is_a?(Node)
148
+
149
+ @find_requests[targetid] = Time.now + REQUEST_TIMEOUT
150
+
151
+ if via_node
152
+ @wire.send_find_node via_node, targetid
153
+ else
154
+ query_neighbours targetid
155
+ end
156
+
157
+ # FIXME: should we return the closest node (allow callbacks on find_request)
158
+ end
159
+
160
+ ##
161
+ # successful pings should lead to an update
162
+ # if bucket is not full
163
+ # elsif least recently seen, does ont respond in time
164
+ #
165
+ def ping(node, replacement=nil)
166
+ raise ArgumentError, 'node must be Node' unless node.is_a?(Node)
167
+ raise ArgumentError, 'cannot ping self' if node == @node
168
+ logger.debug "pinging", remote: node, local: @node
169
+
170
+ echoed = @wire.send_ping node
171
+ pingid = mkpingid echoed, node
172
+ timeout = Time.now + REQUEST_TIMEOUT
173
+ logger.debug "set wait for pong from", remote: node, local: @node, pingid: Utils.encode_hex(pingid)[0,8]
174
+
175
+ @expected_pongs[pingid] = [timeout, node, replacement]
176
+ end
177
+
178
+ ##
179
+ # udp addresses determined by socket address of received Ping packets # ok
180
+ # tcp addresses determined by contents of Ping packet # not yet
181
+ def recv_ping(remote, echo)
182
+ raise ArgumentError, 'remote must be Node' unless remote.is_a?(Node)
183
+ logger.debug "recv ping", remote: remote, local: @node
184
+
185
+ if remote == @node
186
+ logger.warn "recv ping from self?!"
187
+ return
188
+ end
189
+
190
+ update remote
191
+ @wire.send_pong remote, echo
192
+ end
193
+
194
+ ##
195
+ # tcp addresses are only updated upon receipt of Pong packet
196
+ #
197
+ def recv_pong(remote, echoed)
198
+ raise ArgumentError, 'remote must be Node' unless remote.is_a?(Node)
199
+ raise ArgumentError, 'cannot pong self' if remote == @node
200
+
201
+ pingid = mkpingid echoed, remote
202
+ logger.debug 'recv pong', remote: remote, pingid: Utils.encode_hex(pingid)[0,8], local: @node
203
+
204
+ # FIXME: but neighbours will NEVER include remote
205
+ #neighbours = @routing.neighbours remote
206
+ #if !neighbours.empty? && neighbours[0] == remote
207
+ # neighbours[0].address = remote.address # update tcp address
208
+ #end
209
+
210
+ update remote, pingid
211
+ end
212
+
213
+ ##
214
+ # if one of the neighbours is closer than the closest known neighbours
215
+ # if not timed out
216
+ # query closest node for neighbours
217
+ # add all nodes to the list
218
+ #
219
+ def recv_neighbours(remote, neighbours)
220
+ logger.debug "recv neighbours", remoteid: remote, num: neighbours.size, local: @node, neighbours: neighbours
221
+
222
+ neighbours = neighbours.select {|n| n != @node && !@routing.include?(n) }
223
+
224
+ # FIXME: we don't map requests to responses, thus forwarding to all
225
+ @find_requests.each do |nodeid, timeout|
226
+ closest = neighbours.sort_by {|n| n.id_distance(nodeid) }
227
+
228
+ if Time.now < timeout
229
+ closest_known = @routing.neighbours(nodeid)[0]
230
+ raise KademliaRoutingError if closest_known == @node
231
+
232
+ # send find_node requests to A closests
233
+ closest[0, A].each do |close_node|
234
+ if !closest_known || close_node.id_distance(nodeid) < closest_known.id_distance(nodeid)
235
+ logger.debug "forwarding find request", closest: close_node, closest_known: closest_known
236
+ @wire.send_find_node close_node, nodeid
237
+ end
238
+ end
239
+ end
240
+ end
241
+
242
+ # add all nodes to the list
243
+ neighbours.each do |node|
244
+ ping node if node != @node
245
+ end
246
+ end
247
+
248
+ # FIXME: amplification attack (need to ping pong ping pong first)
249
+ def recv_find_node(remote, targetid)
250
+ raise ArgumentError, 'remote must be Node' unless remote.is_a?(Node)
251
+
252
+ update remote
253
+
254
+ found = @routing.neighbours(targetid)
255
+ logger.debug "recv find_node", remoteid: remote, found: found.size
256
+
257
+ @wire.send_neighbours remote, found
258
+ end
259
+
260
+ private
261
+
262
+ def logger
263
+ @logger ||= Logger.new('p2p.discovery.kademlia').tap {|l| l.level = :info }
264
+ end
265
+
266
+ def query_neighbours(targetid)
267
+ @routing.neighbours(targetid)[0, A].each do |n|
268
+ @wire.send_find_node n, targetid
269
+ end
270
+ end
271
+
272
+ def mkpingid(echoed, node)
273
+ raise ArgumentError, 'node has no pubkey' if node.pubkey.nil? || node.pubkey.empty?
274
+
275
+ pid = echoed + node.pubkey
276
+ logger.debug "mkpingid", echoed: Utils.encode_hex(echoed), node: Utils.encode_hex(node.pubkey)
277
+
278
+ pid
279
+ end
280
+
281
+ end
282
+
283
+ end
284
+ end
@@ -0,0 +1,131 @@
1
+ # -*- encoding : ascii-8bit -*-
2
+
3
+ module DEVp2p
4
+ module Kademlia
5
+
6
+ class RoutingTable
7
+
8
+ attr :node
9
+
10
+ def initialize(node)
11
+ @node = node
12
+ @buckets = [KBucket.new(0, MAX_NODE_ID)]
13
+ end
14
+
15
+ include Enumerable
16
+ def each(&block)
17
+ @buckets.each do |b|
18
+ b.each(&block)
19
+ end
20
+ end
21
+
22
+ def split_bucket(bucket)
23
+ index = @buckets.index bucket
24
+ @buckets[index..index] = bucket.split
25
+ end
26
+
27
+ def idle_buckets
28
+ t_idle = Time.now - IDLE_BUCKET_REFRESH_INTERVAL
29
+ @buckets.select {|b| b.last_updated < t_idle }
30
+ end
31
+
32
+ def not_full_buckets
33
+ @buckets.select {|b| b.size < K }
34
+ end
35
+
36
+ def add(node)
37
+ raise ArgumentError, 'cannot add self' if node == @node
38
+
39
+ bucket = bucket_by_node node
40
+ eviction_candidate = bucket.add node
41
+
42
+ if eviction_candidate # bucket is full
43
+ # split if the bucket has the local node in its range or if the depth
44
+ # is not congruent to 0 mod B
45
+ if bucket.in_range?(@node) || bucket.splitable?
46
+ split_bucket bucket
47
+ return add(node) # retry
48
+ end
49
+
50
+ # nothing added, ping eviction_candidate
51
+ return eviction_candidate
52
+ end
53
+
54
+ nil # successfully added to not full bucket
55
+ end
56
+
57
+ def delete(node)
58
+ bucket_by_node(node).delete node
59
+ end
60
+
61
+ def bucket_by_node(node)
62
+ @buckets.each do |bucket|
63
+ if node.id < bucket.right
64
+ raise KademliaRoutingError, "mal-formed routing table" unless node.id >= bucket.left
65
+ return bucket
66
+ end
67
+ end
68
+
69
+ raise KademliaNodeNotFound
70
+ end
71
+
72
+ def buckets_by_id_distance(id)
73
+ raise ArgumentError, 'id must be integer' unless id.is_a?(Integer)
74
+ @buckets.sort_by {|b| b.id_distance(id) }
75
+ end
76
+
77
+ def buckets_by_distance(node)
78
+ raise ArgumentError, 'node must be Node' unless node.is_a?(Node)
79
+ buckets_by_id_distance(node.id)
80
+ end
81
+
82
+ def include?(node)
83
+ bucket_by_node(node).include?(node)
84
+ end
85
+
86
+ def size
87
+ @buckets.map(&:size).reduce(0, &:+)
88
+ end
89
+
90
+ def buckets_count
91
+ @buckets.size
92
+ end
93
+
94
+ ##
95
+ # sorting by bucket.midpoint does not work in edge cases, buld a short
96
+ # list of `k * 2` nodes and sort and shorten it.
97
+ #
98
+ # TODO: can we do better?
99
+ #
100
+ def neighbours(node, k=K)
101
+ raise ArgumentError, 'node must be Node or node id' unless node.instance_of?(Node) || node.is_a?(Integer)
102
+
103
+ node = node.id if node.instance_of?(Node)
104
+
105
+ nodes = []
106
+ buckets_by_id_distance(node).each do |bucket|
107
+ bucket.nodes_by_id_distance(node).each do |n|
108
+ if n != node
109
+ nodes.push n
110
+ break if nodes.size == k * 2
111
+ end
112
+ end
113
+ end
114
+
115
+ nodes.sort_by {|n| n.id_distance(node) }[0,k]
116
+ end
117
+
118
+ ##
119
+ # naive correct version simply compares all nodes
120
+ #
121
+ def neighbours_within_distance(id, distance)
122
+ raise ArgumentError, 'invalid id' unless id.is_a?(Integer)
123
+
124
+ select {|n| n.id_distance(id) <= distance }
125
+ .sort_by {|n| n.id_distance(id) }
126
+ end
127
+
128
+ end
129
+
130
+ end
131
+ end