├── .gitignore ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── devp2p.gemspec ├── lib ├── devp2p.rb └── devp2p │ ├── app.rb │ ├── command.rb │ ├── configurable.rb │ ├── connection_monitor.rb │ ├── crypto.rb │ ├── crypto │ ├── ecc_x.rb │ └── ecies.rb │ ├── discovery.rb │ ├── discovery │ ├── address.rb │ ├── kademlia_protocol_adapter.rb │ ├── node.rb │ ├── protocol.rb │ └── service.rb │ ├── exception.rb │ ├── frame.rb │ ├── kademlia.rb │ ├── kademlia │ ├── k_bucket.rb │ ├── node.rb │ ├── protocol.rb │ ├── routing_table.rb │ └── wire_interface.rb │ ├── multiplexed_session.rb │ ├── multiplexer.rb │ ├── p2p_protocol.rb │ ├── packet.rb │ ├── peer.rb │ ├── peer_errors.rb │ ├── peer_manager.rb │ ├── protocol.rb │ ├── rlpx_session.rb │ ├── service.rb │ ├── sync_queue.rb │ ├── utils.rb │ ├── version.rb │ └── wired_service.rb └── test ├── app.rb ├── app_test.rb ├── configurable_test.rb ├── crypto ├── ecc_x_test.rb └── ecies_test.rb ├── crypto_test.rb ├── discovery ├── address_test.rb └── service_test.rb ├── example.rb ├── geth_test.rb ├── kademlia_test.rb ├── multiplexed_session_test.rb ├── multiplexer_test.rb ├── p2p_protocol_test.rb ├── peer_manager_test.rb ├── peer_test.rb ├── rlpx_session_test.rb ├── service_test.rb ├── sync_queue_test.rb ├── test_helper.rb └── utils_test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | tags 2 | .bundle 3 | .yardoc 4 | doc 5 | test.log 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | 4 | gem 'pry' 5 | #gem 'celluloid', path: '../celluloid' 6 | #gem 'bitcoin-secp256k1', path: '../ruby-bitcoin-secp256k1', require: 'secp256k1' 7 | #gem 'block_logger', path: '../block_logger' 8 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | devp2p (0.3.0) 5 | bitcoin-secp256k1 (~> 0.4) 6 | block_logger (= 0.1.2) 7 | concurrent-ruby (~> 1.0) 8 | digest-sha3 (~> 1.1) 9 | hashie (~> 3.4) 10 | rlp (>= 0.7.1) 11 | 12 | GEM 13 | remote: https://rubygems.org/ 14 | specs: 15 | bitcoin-secp256k1 (0.4.0) 16 | ffi (>= 1.9.10) 17 | block_logger (0.1.2) 18 | logging (~> 2.0) 19 | coderay (1.1.0) 20 | concurrent-ruby (1.0.2) 21 | digest-sha3 (1.1.0) 22 | ffi (1.9.10) 23 | hashie (3.4.4) 24 | little-plugger (1.1.4) 25 | logging (2.1.0) 26 | little-plugger (~> 1.1) 27 | multi_json (~> 1.10) 28 | method_source (0.8.2) 29 | minitest (5.8.3) 30 | multi_json (1.12.1) 31 | pry (0.10.3) 32 | coderay (~> 1.1.0) 33 | method_source (~> 0.8.1) 34 | slop (~> 3.4) 35 | rake (10.5.0) 36 | rlp (0.7.2) 37 | slop (3.6.0) 38 | yard (0.8.7.6) 39 | 40 | PLATFORMS 41 | ruby 42 | 43 | DEPENDENCIES 44 | devp2p! 45 | minitest (= 5.8.3) 46 | pry 47 | rake (~> 10.5) 48 | yard (= 0.8.7.6) 49 | 50 | BUNDLED WITH 51 | 1.11.2 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jan Xie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ruby-devp2p 2 | 3 | [![MIT License](https://img.shields.io/packagist/l/doctrine/orm.svg)](LICENSE) 4 | [![travis build status](https://travis-ci.org/janx/ruby-devp2p.svg?branch=master)](https://travis-ci.org/janx/ruby-devp2p) 5 | 6 | A ruby implementation of Ethereum's DEVp2p framework. 7 | 8 | ## Fiber Stack Size 9 | 10 | DEVp2p is build on [Celluloid](https://github.com/celluloid/celluloid/), which 11 | uses fibers to schedule tasks. Ruby's default limit on fiber stack size is quite 12 | small, which need to be increased by setting environment variables: 13 | 14 | ``` 15 | export RUBY_FIBER_VM_STACK_SIZE=104857600 # 100MB 16 | export RUBY_FIBER_MACHINE_STACK_SIZE=1048576000 17 | ``` 18 | 19 | ## Resources 20 | 21 | * [DEVp2p Whitepaper](https://github.com/ethereum/wiki/wiki/libp2p-Whitepaper) 22 | * [RLPx](https://github.com/ethereum/devp2p/blob/master/rlpx.md) 23 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | require 'yard' 3 | 4 | Rake::TestTask.new do |t| 5 | t.libs += %w(lib test) 6 | t.test_files = FileList['test/**/*_test.rb'] 7 | t.verbose = true 8 | end 9 | 10 | YARD::Rake::YardocTask.new do |t| 11 | t.files = ['lib/**/*.rb'] 12 | t.options = ['--markup=markdown'] 13 | end 14 | 15 | task default: [:test, :yard] 16 | -------------------------------------------------------------------------------- /devp2p.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("../lib", __FILE__) 2 | 3 | # Maintain your gem's version: 4 | require "devp2p/version" 5 | 6 | # Describe your gem and declare its dependencies: 7 | Gem::Specification.new do |s| 8 | s.name = "devp2p" 9 | s.version = DEVp2p::VERSION 10 | s.authors = ["Jan Xie"] 11 | s.email = ["jan.h.xie@gmail.com"] 12 | s.homepage = "https://github.com/janx/ruby-devp2p" 13 | s.summary = "A ruby implementation of Ethereum's DEVp2p framework." 14 | s.description = "DEVp2p aims to provide a lightweight abstraction layer that provides these low-level algorithms, protocols and services in a transparent framework without predetermining the eventual transmission-use-cases of the protocols." 15 | s.license = 'MIT' 16 | 17 | s.files = Dir["{lib}/**/*"] + ["LICENSE", "README.md"] 18 | 19 | s.add_dependency('hashie', ['~> 3.4']) 20 | s.add_dependency('block_logger', ['0.1.2']) 21 | s.add_dependency('digest-sha3', ['~> 1.1']) 22 | s.add_dependency('bitcoin-secp256k1', ['~>0.4']) 23 | s.add_dependency('rlp', ['>= 0.7.1']) 24 | s.add_dependency('concurrent-ruby', ['~>1.0']) 25 | 26 | s.add_development_dependency('rake', ['~> 10.5']) 27 | s.add_development_dependency('minitest', '5.8.3') 28 | s.add_development_dependency('yard', '0.8.7.6') 29 | end 30 | -------------------------------------------------------------------------------- /lib/devp2p.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | require 'concurrent' 4 | require 'block_logger' 5 | require 'rlp' 6 | 7 | module DEVp2p 8 | Logger = BlockLogger 9 | 10 | TT16 = 2**16 11 | TT256 = 2**256 12 | 13 | NODE_URI_SCHEME = 'enode://'.freeze 14 | end 15 | 16 | require 'devp2p/version' 17 | 18 | require 'devp2p/exception' 19 | require 'devp2p/crypto' 20 | require 'devp2p/utils' 21 | require 'devp2p/configurable' 22 | 23 | require 'devp2p/app' 24 | require 'devp2p/service' 25 | require 'devp2p/wired_service' 26 | 27 | require 'devp2p/sync_queue' 28 | require 'devp2p/frame' 29 | require 'devp2p/packet' 30 | require 'devp2p/multiplexer' 31 | require 'devp2p/multiplexed_session' 32 | require 'devp2p/rlpx_session' 33 | 34 | require 'devp2p/kademlia' 35 | require 'devp2p/discovery' 36 | require 'devp2p/connection_monitor' 37 | require 'devp2p/command' 38 | require 'devp2p/protocol' 39 | require 'devp2p/p2p_protocol' 40 | require 'devp2p/peer_manager' 41 | require 'devp2p/peer_errors' 42 | require 'devp2p/peer' 43 | -------------------------------------------------------------------------------- /lib/devp2p/app.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'hashie' 3 | 4 | module DEVp2p 5 | class App 6 | include Concurrent::Async 7 | 8 | extend Configurable 9 | add_config( 10 | default_config: { 11 | client_version_string: "ruby-devp2p #{VersionString}", 12 | deactivated_services: [] 13 | } 14 | ) 15 | 16 | attr :config, :services 17 | 18 | def initialize(config=default_config) 19 | super() 20 | 21 | @config = Hashie::Mash.new(default_config).merge(config) 22 | @registry = {} 23 | @services = Hashie::Mash.new 24 | end 25 | 26 | def register_service(klass, *args) 27 | raise ArgumentError, "service #{klass.name} already registered" if services.has_key?(klass.name) 28 | 29 | logger.info "registering service", service: klass.name 30 | @registry[klass.name] = [klass, args] 31 | services[klass.name] = klass.new(*args) 32 | end 33 | 34 | def deregister_service(klass) 35 | raise ArgumentError, "service #{klass.name} not registered" unless services.has_key?(klass.name) 36 | 37 | logger.info "deregistering service", service: klass.name 38 | services[klass.name].async.stop 39 | services.delete klass.name 40 | @registry.delete klass.name 41 | end 42 | 43 | def start 44 | @registry.each do |name, (klass, args)| 45 | services[name] ||= klass.new(*args) 46 | services[name].async.start 47 | end 48 | rescue 49 | puts $! 50 | puts $!.backtrace[0,10].join("\n") 51 | end 52 | 53 | def stop 54 | services.keys.each do |name| 55 | services[name].async.stop 56 | services.delete name 57 | end 58 | rescue 59 | puts $! 60 | puts $!.backtrace[0,10].join("\n") 61 | end 62 | 63 | private 64 | 65 | def logger 66 | @logger ||= Logger.new 'app' 67 | end 68 | 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/devp2p/command.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | 5 | class Command 6 | 7 | extend Configurable 8 | add_config( 9 | cmd_id: 0, 10 | structure: {}, # {arg_name: RLP::Sedes.type} 11 | decode_strict: true 12 | ) 13 | 14 | class <(proto, **data) { 25 | @last_response = Time.now 26 | @samples.unshift(@last_response - @last_request) 27 | @samples.pop if @samples.size > @max_samples 28 | } 29 | @proto.receive_pong_callbacks.push(track_response) 30 | 31 | monitor = self 32 | # FIXME: sleep 1 to make sure ConnectionMonitor start after connection of 33 | # other protocols like ETHProtocol 34 | @proto.receive_hello_callbacks.push(->(p, **kwargs) { sleep 1; monitor.start }) 35 | end 36 | 37 | def latency(num_samples=@max_samples) 38 | num_samples = [num_samples, @samples.size].min 39 | return 1 unless num_samples > 0 40 | (0...num_samples).map {|i| @samples[i] }.reduce(0, &:+) 41 | end 42 | 43 | def start 44 | logger.debug 'started', monitor: self 45 | 46 | logger.debug 'pinging', monitor: self 47 | @proto.async.send_ping 48 | now = @last_request = Time.now 49 | 50 | @task = Concurrent::TimerTask.new(execution_interval: @ping_interval) do 51 | logger.debug('latency', peer: @proto, latency: ("%.3f" % latency)) 52 | 53 | if now - @last_response > @response_delay_threshold 54 | logger.debug "unresponsive_peer", monitor: self 55 | @proto.peer.async.report_error 'not responding to ping' 56 | @proto.async.stop 57 | end 58 | 59 | logger.debug 'pinging', monitor: self 60 | @proto.async.send_ping 61 | now = @last_request = Time.now 62 | end 63 | @task.execute 64 | rescue 65 | puts $! 66 | puts $!.backtrace[0,10].join("\n") 67 | end 68 | 69 | def stop 70 | logger.debug 'stopped', monitor: self 71 | @task.shutdown 72 | @task = nil 73 | end 74 | 75 | private 76 | 77 | def logger 78 | @logger ||= Logger.new("p2p.ctxmonitor") 79 | end 80 | 81 | end 82 | 83 | end 84 | -------------------------------------------------------------------------------- /lib/devp2p/crypto.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | require 'secp256k1' # bitcoin-secp256k1 4 | require 'digest/sha3' 5 | 6 | require 'devp2p/crypto/ecies' 7 | require 'devp2p/crypto/ecc_x' 8 | 9 | module DEVp2p 10 | module Crypto 11 | 12 | extend self 13 | 14 | def mk_privkey(seed) 15 | Crypto.keccak256 seed 16 | end 17 | 18 | def privtopub(privkey) 19 | priv = Secp256k1::PrivateKey.new privkey: privkey, raw: true 20 | 21 | pub = priv.pubkey.serialize(compressed: false) 22 | raise InvalidKeyError, 'invalid pubkey' unless pub.size == 65 && pub[0] == "\x04" 23 | 24 | pub[1,64] 25 | end 26 | 27 | def keccak256(x) 28 | Digest::SHA3.new(256).digest(x) 29 | end 30 | 31 | def hmac_sha256(key, msg) 32 | OpenSSL::HMAC.digest 'sha256', key, msg 33 | end 34 | 35 | def ecdsa_sign(msghash, privkey) 36 | raise ArgumentError, 'msghash length must be 32' unless msghash.size == 32 37 | 38 | priv = Secp256k1::PrivateKey.new privkey: privkey, raw: true 39 | sig = priv.ecdsa_recoverable_serialize priv.ecdsa_sign_recoverable(msghash, raw: true) 40 | "#{sig[0]}#{sig[1].chr}" 41 | end 42 | 43 | def ecdsa_recover(msghash, sig) 44 | raise ArgumentError, 'msghash length must be 32' unless msghash.size == 32 45 | raise ArgumentError, 'signature length must be 65' unless sig.size == 65 46 | 47 | pub = Secp256k1::PublicKey.new flags: Secp256k1::ALL_FLAGS 48 | recsig = pub.ecdsa_recoverable_deserialize sig[0,64], sig[64].ord 49 | pub.public_key = pub.ecdsa_recover msghash, recsig, raw: true 50 | pub.serialize(compressed: false)[1..-1] 51 | end 52 | 53 | def ecdsa_verify(pubkey, sig, msg) 54 | raise ArgumentError, 'invalid signature length' unless sig.size == 65 55 | raise ArgumentError, 'invalid pubkey length' unless pubkey.size == 64 56 | 57 | pub = Secp256k1::PublicKey.new pubkey: "\x04#{pubkey}", raw: true 58 | raw_sig = pub.ecdsa_recoverable_convert pub.ecdsa_recoverable_deserialize(sig[0,64], sig[64].ord) 59 | 60 | pub.ecdsa_verify msg, raw_sig, raw: true 61 | end 62 | alias verify ecdsa_verify 63 | 64 | ## 65 | # Encrypt data with ECIES method using the public key of the recipient. 66 | # 67 | def encrypt(data, raw_pubkey) 68 | raise ArgumentError, "invalid pubkey of length #{raw_pubkey.size}" unless raw_pubkey.size == 64 69 | Crypto::ECIES.encrypt data, raw_pubkey 70 | end 71 | 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/devp2p/crypto/ecc_x.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | require 'securerandom' 4 | 5 | module DEVp2p 6 | module Crypto 7 | class ECCx 8 | 9 | CURVE = 'secp256k1'.freeze 10 | 11 | class <') 124 | ctx.update key_material 125 | ctx.update s1 126 | key += ctx.digest 127 | end 128 | key[0,key_len] 129 | end 130 | 131 | end 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/devp2p/discovery.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | ## 3 | # # Node Discovery Protocol 4 | # 5 | # * [Node] - an entity on the network 6 | # * [Node] ID - 512 bit public key of node 7 | # 8 | # The Node Discovery protocol provides a way to find RLPx nodes that can be 9 | # connected to. It uses a Kademlia-like protocol to maintain a distributed 10 | # database of the IDs and endpoints of all listening nodes. 11 | # 12 | # Each node keeps a node table as described in the Kademlia paper (Maymounkov, 13 | # Mazières 2002). The node table is configured with a bucket size of 16 14 | # (denoted `k` in Kademlia), concurrency of 3 (denoted `α` in Kademlia), and 8 15 | # bits per hop (denoted `b` in Kademlia) for routing. The eviction check 16 | # interval is 75 milliseconds, and the idle bucket-refresh interval is 3600 17 | # seconds. 18 | # 19 | # In order to maintain a well-formed network, RLPx nodes should try to connect 20 | # to an unspecified number of close nodes. To increase resilience against Sybil 21 | # attacks, nodes should also connect to randomly chosen, non-close nodes. 22 | # 23 | # Each node runs the UDP-based RPC protocol defined below. The `FIND_DATA` and 24 | # `STORE` requests from the Kademlia paper are not part of the protocol since 25 | # the Node Discovery Protocol does not provide DHT functionality. 26 | # 27 | # ## Joining the network 28 | # 29 | # When joining the network, fills its node table by performing a recursive Find 30 | # Node operation with its own ID as the 'Target'. The initial Find Node request 31 | # is sent to one or more bootstrap nodes. 32 | # 33 | # ## RPC Protocol 34 | # 35 | # RLPx nodes that want to accept incoming connections should listen on the same 36 | # port number for UDP packets (Node Discovery Protocol) and TCP connections 37 | # (RLPx protocol). 38 | # 39 | # All requests time out after 300ms. Requests are not re-sent. 40 | # 41 | # ## Packet Data 42 | # 43 | # All packets contain an `Expiration` date to guard against replay attacks. The 44 | # date should be interpreted as a UNIX timestamp. The receiver should discard 45 | # any packet whose `Expiration` value is in the past. 46 | # 47 | # ### Ping (type 0x01) 48 | # 49 | # Ping packets can be sent and received at any time. The receiver should reply 50 | # with a Pong packet and update the IP/Port of the sender in its node table. 51 | # 52 | # PingNode packet-type: 0x01 53 | # 54 | # struct PingNode <= 59 bytes 55 | # { 56 | # h256 version = 0x3; <= 1 57 | # Endpoint from; <= 23 58 | # Endpoint to; <= 23 59 | # unsigned expiration; <= 9 60 | # } 61 | # 62 | # struct Endpoint <= 24 = [17,3,3] 63 | # { 64 | # unsigned address; // BE encoded 32-bit or 128-bit unsigned (layer3 address; size determins ipv4 vs ipv6) 65 | # unsigned udpPort; // BE encoded 16-bit unsigned 66 | # unsigned tcpPort; // BE encoded 16-bit unsigned 67 | # } 68 | # 69 | # ### Pong (type 0x02) 70 | # 71 | # Pong is the reply to a Ping packet. 72 | # 73 | # Pong packet-type: 0x02 74 | # 75 | # struct Pong <= 66 bytes 76 | # { 77 | # Endpoint to; 78 | # h256 echo; 79 | # unsigned expiration; 80 | # } 81 | # 82 | # ### Find Node (type 0x03) 83 | # 84 | # Find Node packets are sent to locate nodes close to a given target ID. The 85 | # receiver should reply with a Neighbours packet containing the `k` nodes 86 | # closest to target that it knows about. 87 | # 88 | # FindNode packet-type: 0x03 89 | # 90 | # struct FindNode <= 76 bytes 91 | # { 92 | # NodeId target; // Id of a node. The responding node will send back nodes closest to the target. 93 | # unsigned expiration; 94 | # } 95 | # 96 | # ### Neighbours (type 0x04) 97 | # 98 | # Neighbours is the reply to Find Node. It contains up to `k` nodes that the 99 | # sender knows which are closest to the requested 'Target`. 100 | # 101 | # Neighbours packet-type: 0x04 102 | # 103 | # struct Neighbours <= 1423 104 | # { 105 | # list nodes: struct Neighbours <= 88...1411; 76...1219 106 | # { 107 | # inline Endpoint endpoint; 108 | # NodeId node; 109 | # }; 110 | # unsigned expiration; 111 | # } 112 | # 113 | 114 | require 'devp2p/discovery/address' 115 | require 'devp2p/discovery/node' 116 | require 'devp2p/discovery/kademlia_protocol_adapter' 117 | require 'devp2p/discovery/protocol' 118 | require 'devp2p/discovery/service' 119 | -------------------------------------------------------------------------------- /lib/devp2p/discovery/address.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | require 'ipaddr' 4 | require 'resolv' 5 | 6 | module DEVp2p 7 | module Discovery 8 | 9 | class Address 10 | 11 | attr :udp_port, :tcp_port 12 | 13 | def self.from_endpoint(ip, udp_port, tcp_port="\x00\x00") 14 | new(ip, udp_port, tcp_port, true) 15 | end 16 | 17 | def initialize(ip, udp_port, tcp_port=nil, from_binary=false) 18 | tcp_port ||= udp_port 19 | 20 | if from_binary 21 | raise ArgumentError, "invalid ip" unless [4,16].include?(ip.size) 22 | 23 | @udp_port = dec_port udp_port 24 | @tcp_port = dec_port tcp_port 25 | else 26 | raise ArgumentError, "udp_port must be Integer" unless udp_port.is_a?(Integer) 27 | raise ArgumentError, "tcp_port must be Integer" unless tcp_port.is_a?(Integer) 28 | 29 | @udp_port = udp_port 30 | @tcp_port = tcp_port 31 | end 32 | 33 | begin 34 | @ip = from_binary ? IPAddr.new_ntoh(ip) : IPAddr.new(ip) 35 | rescue IPAddr::InvalidAddressError => e 36 | ips = Resolv.getaddresses(ip).sort {|addr| addr =~ /:/ ? 1 : 0 } # use ipv4 first 37 | raise e if ips.empty? 38 | @ip = ips[0] 39 | end 40 | end 41 | 42 | def ip 43 | @ip.to_s 44 | end 45 | 46 | def update(addr) 47 | @tcp_port = addr.tcp_port if @tcp_port.nil? || @tcp_port == 0 48 | end 49 | 50 | ## 51 | # addresses equal if they share ip and udp_port 52 | # 53 | def ==(other) 54 | [ip, udp_port] == [other.ip, other.udp_port] 55 | end 56 | 57 | def to_s 58 | "Address(#{ip}:#{udp_port})" 59 | end 60 | 61 | def to_h 62 | {ip: ip, udp_port: udp_port, tcp_port: tcp_port} 63 | end 64 | 65 | def to_b 66 | [@ip.hton, enc_port(udp_port), enc_port(tcp_port)] 67 | end 68 | alias to_endpoint to_b 69 | 70 | private 71 | 72 | def enc_port(p) 73 | Utils.int_to_big_endian4(p)[-2..-1] 74 | end 75 | 76 | def dec_port(b) 77 | Utils.big_endian_to_int(b) 78 | end 79 | 80 | end 81 | 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/devp2p/discovery/kademlia_protocol_adapter.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | module Discovery 5 | 6 | class KademliaProtocolAdapter < Kademlia::Protocol 7 | # do nothing 8 | end 9 | 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/devp2p/discovery/node.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | module Discovery 5 | 6 | class Node < Kademlia::Node 7 | 8 | def self.from_uri(uri) 9 | ip, port, pubkey = Utils.host_port_pubkey_from_uri(uri) 10 | new(pubkey, Address.new(ip, port.to_i)) 11 | end 12 | 13 | attr_accessor :address 14 | 15 | def initialize(pubkey, address=nil) 16 | raise ArgumentError, 'invalid address' unless address.nil? || address.is_a?(Address) 17 | 18 | super(pubkey) 19 | 20 | self.address = address 21 | @reputation = 0 22 | @rlpx_version = 0 23 | end 24 | 25 | def to_uri 26 | Utils.host_port_pubkey_to_uri(address.ip, address.udp_port, pubkey) 27 | end 28 | 29 | end 30 | 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/devp2p/discovery/protocol.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | module Discovery 5 | 6 | class Protocol < Kademlia::WireInterface 7 | 8 | VERSION = 4 9 | 10 | EXPIRATION = 60 # let messages expire after N seconds 11 | 12 | CMD_ID_MAP = { 13 | ping: 1, 14 | pong: 2, 15 | find_node: 3, 16 | neighbours: 4 17 | }.freeze 18 | REV_CMD_ID_MAP = CMD_ID_MAP.map {|k,v| [v,k] }.to_h.freeze 19 | 20 | # number of required top-level list elements for each cmd_id. 21 | # elements beyond this length are trimmed. 22 | CMD_ELEM_COUNT_MAP = { 23 | ping: 4, 24 | poing: 3, 25 | find_node: 2, 26 | neighbours: 2 27 | } 28 | 29 | attr :pubkey, :kademlia 30 | 31 | def initialize(app, service) 32 | @app = app 33 | @service = service 34 | 35 | @privkey = Utils.decode_hex app.config[:node][:privkey_hex] 36 | @pubkey = Crypto.privtopub @privkey 37 | 38 | @nodes = {} # nodeid => Node 39 | @node = Node.new(pubkey, @service.address) 40 | 41 | @kademlia = KademliaProtocolAdapter.new @node, self 42 | 43 | uri = Utils.host_port_pubkey_to_uri(ip, udp_port, pubkey) 44 | logger.info "starting discovery proto", enode: uri 45 | end 46 | 47 | def bootstrap(nodes) 48 | @kademlia.bootstrap(nodes) unless nodes.empty? 49 | end 50 | 51 | ## 52 | # return node or create new, update address if supplied 53 | # 54 | def get_node(nodeid, address=nil) 55 | raise ArgumentError, 'invalid nodeid' unless nodeid.size == Kademlia::PUBKEY_SIZE / 8 56 | raise ArgumentError, 'must give either address or existing nodeid' unless address || @nodes.has_key?(nodeid) 57 | 58 | @nodes[nodeid] = Node.new nodeid, address if !@nodes.has_key?(nodeid) 59 | node = @nodes[nodeid] 60 | 61 | if address 62 | raise ArgumentError, 'address must be Address' unless address.instance_of?(Address) 63 | node.address = address 64 | end 65 | 66 | node 67 | end 68 | 69 | def sign(msg) 70 | msg = Crypto.keccak256 msg 71 | Crypto.ecdsa_sign msg, @privkey 72 | end 73 | 74 | ## 75 | # UDP packets are structured as follows: 76 | # 77 | # hash || signature || packet-type || packet-data 78 | # 79 | # * packet-type: single byte < 2**7 // valid values are [1,4] 80 | # * packet-data: RLP encoded list. Packet properties are serialized in 81 | # the order in which they're defined. See packet-data below. 82 | # 83 | # Offset | 84 | # 0 | MDC | Ensures integrity of packet. 85 | # 65 | signature | Ensures authenticity of sender, `SIGN(sender-privkey, MDC)` 86 | # 97 | type | Single byte in range [1, 4] that determines the structure of Data 87 | # 98 | data | RLP encoded, see section Packet Data 88 | # 89 | # The packets are signed and authenticated. The sender's Node ID is 90 | # determined by recovering the public key from the signature. 91 | # 92 | # sender-pubkey = ECRECOVER(Signature) 93 | # 94 | # The integrity of the packet can then be verified by computing the 95 | # expected MDC of the packet as: 96 | # 97 | # MDC = keccak256(sender-pubkey || type || data) 98 | # 99 | # As an optimization, implementations may look up the public key by the 100 | # UDP sending address and compute MDC before recovering the sender ID. If 101 | # the MDC values do not match, the packet can be dropped. 102 | # 103 | def pack(cmd_id, payload) 104 | raise ArgumentError, 'invalid cmd_id' unless REV_CMD_ID_MAP.has_key?(cmd_id) 105 | raise ArgumentError, 'payload must be Array' unless payload.is_a?(Array) 106 | 107 | cmd_id = encode_cmd_id cmd_id 108 | expiration = encode_expiration Time.now.to_i + EXPIRATION 109 | 110 | encoded_data = RLP.encode(payload + [expiration]) 111 | signed_data = Crypto.keccak256 "#{cmd_id}#{encoded_data}" 112 | signature = Crypto.ecdsa_sign signed_data, @privkey 113 | 114 | raise InvalidSignatureError unless signature.size == 65 115 | 116 | mdc = Crypto.keccak256 "#{signature}#{cmd_id}#{encoded_data}" 117 | raise InvalidMACError unless mdc.size == 32 118 | 119 | "#{mdc}#{signature}#{cmd_id}#{encoded_data}" 120 | end 121 | 122 | ## 123 | # macSize = 256 / 8 = 32 124 | # sigSize = 520 / 8 = 65 125 | # headSize = macSize + sigSize = 97 126 | # 127 | def unpack(message) 128 | mdc = message[0,32] 129 | if mdc != Crypto.keccak256(message[32..-1]) 130 | logger.warn 'packet with wrong mcd' 131 | raise InvalidMessageMAC 132 | end 133 | 134 | signature = message[32,65] 135 | raise InvalidSignatureError unless signature.size == 65 136 | 137 | signed_data = Crypto.keccak256(message[97..-1]) 138 | remote_pubkey = Crypto.ecdsa_recover(signed_data, signature) 139 | raise InvalidKeyError unless remote_pubkey.size == Kademlia::PUBKEY_SIZE / 8 140 | 141 | cmd_id = decode_cmd_id message[97] 142 | cmd = REV_CMD_ID_MAP[cmd_id] 143 | 144 | payload = RLP.decode message[98..-1], strict: false 145 | raise InvalidPayloadError unless payload.instance_of?(Array) 146 | 147 | # ignore excessive list elements as required by EIP-8 148 | payload = payload[0, CMD_ELEM_COUNT_MAP[cmd]||payload.size] 149 | 150 | return remote_pubkey, cmd_id, payload, mdc 151 | end 152 | 153 | def receive_message(address, message) 154 | logger.debug "<<< message", address: address 155 | raise ArgumentError, 'address must be Address' unless address.instance_of?(Address) 156 | 157 | begin 158 | remote_pubkey, cmd_id, payload, mdc = unpack message 159 | 160 | # Note: as of discovery version 4, expiration is the last element for 161 | # all packets. This might not be the case for a later version, but 162 | # just popping the last element is good enough for now. 163 | expiration = decode_expiration payload.pop 164 | raise PacketExpired if Time.now.to_i > expiration 165 | rescue DefectiveMessage 166 | logger.debug $! 167 | return 168 | end 169 | 170 | cmd = "recv_#{REV_CMD_ID_MAP[cmd_id]}" 171 | nodeid = remote_pubkey 172 | 173 | get_node(nodeid, address) unless @nodes.has_key?(nodeid) 174 | send cmd, nodeid, payload, mdc 175 | rescue 176 | logger.error 'invalid message', error: $!, from: address 177 | end 178 | 179 | def send_message(node, message) 180 | raise ArgumentError, 'node must have address' unless node.address 181 | logger.debug ">>> message", address: node.address 182 | @service.async.send_message node.address, message 183 | end 184 | 185 | def send_ping(node) 186 | raise ArgumentError, "node must be Node" unless node.is_a?(Node) 187 | raise ArgumentError, "cannot ping self" if node == @node 188 | 189 | logger.debug ">>> ping", remoteid: node 190 | 191 | version = RLP::Sedes.big_endian_int.serialize VERSION 192 | payload = [ 193 | version, 194 | Address.new(ip, udp_port, tcp_port).to_endpoint, 195 | node.address.to_endpoint 196 | ] 197 | 198 | message = pack CMD_ID_MAP[:ping], payload 199 | send_message node, message 200 | 201 | message[0,32] # return the MDC to identify pongs 202 | end 203 | 204 | ## 205 | # Update ip, port in node table. Addresses can only be learned by ping 206 | # messages. 207 | # 208 | def recv_ping(nodeid, payload, mdc) 209 | if payload.size != 3 210 | logger.error "invalid ping payload", payload: payload 211 | return 212 | end 213 | 214 | node = get_node nodeid 215 | logger.debug "<<< ping", node: node 216 | 217 | remote_address = Address.from_endpoint(*payload[1]) # from 218 | my_address = Address.from_endpoint(*payload[2]) # my address 219 | 220 | get_node(nodeid).address.update remote_address 221 | @kademlia.recv_ping node, mdc 222 | end 223 | 224 | def send_pong(node, token) 225 | logger.debug ">>> pong", remoteid: node 226 | 227 | payload = [node.address.to_endpoint, token] 228 | raise InvalidPayloadError unless [4,16].include?(payload[0][0].size) 229 | 230 | message = pack CMD_ID_MAP[:pong], payload 231 | send_message node, message 232 | end 233 | 234 | def recv_pong(nodeid, payload, mdc) 235 | if payload.size != 2 236 | logger.error 'invalid pong payload', payload: payload 237 | return 238 | end 239 | 240 | raise InvalidPayloadError unless payload[0].size == 3 241 | raise InvalidPayloadError unless [4,16].include?(payload[0][0].size) 242 | 243 | my_address = Address.from_endpoint *payload[0] 244 | echoed = payload[1] 245 | 246 | if @nodes.include?(nodeid) 247 | node = get_node nodeid 248 | @kademlia.recv_pong node, echoed 249 | else 250 | logger.debug "<<< unexpected pong from unknown node" 251 | end 252 | end 253 | 254 | def send_find_node(node, target_node_id) 255 | target_node_id = Utils.zpad_int target_node_id, Kademlia::PUBKEY_SIZE/8 256 | logger.debug ">>> find_node", remoteid: node 257 | 258 | message = pack CMD_ID_MAP[:find_node], [target_node_id] 259 | send_message node, message 260 | end 261 | 262 | def recv_find_node(nodeid, payload, mdc) 263 | node = get_node nodeid 264 | 265 | logger.debug "<<< find_node", remoteid: node 266 | raise InvalidPayloadError unless payload[0].size == Kademlia::PUBKEY_SIZE/8 267 | 268 | target = Utils.big_endian_to_int payload[0] 269 | @kademlia.recv_find_node node, target 270 | end 271 | 272 | def send_neighbours(node, neighbours) 273 | raise ArgumentError, 'neighbours must be Array' unless neighbours.instance_of?(Array) 274 | raise ArgumentError, 'neighbours must be Node' unless neighbours.all? {|n| n.is_a?(Node) } 275 | 276 | nodes = neighbours.map {|n| n.address.to_endpoint + [n.pubkey] } 277 | logger.debug ">>> neighbours", remoteid: node, count: nodes.size 278 | 279 | message = pack CMD_ID_MAP[:neighbours], [nodes] 280 | send_message node, message 281 | end 282 | 283 | def recv_neighbours(nodeid, payload, mdc) 284 | node = get_node nodeid 285 | raise InvalidPayloadError unless payload.size == 1 286 | raise InvalidPayloadError unless payload[0].instance_of?(Array) 287 | logger.debug "<<< neighbours", remoteid: node, count: payload[0].size 288 | 289 | neighbours_set = payload[0].uniq 290 | logger.warn "received duplicates" if neighbours_set.size < payload[0].size 291 | 292 | neighbours = neighbours_set.map do |n| 293 | if n.size != 4 || ![4,16].include?(n[0].size) 294 | logger.error "invalid neighbours format", neighbours: n 295 | return 296 | end 297 | 298 | n = n.dup 299 | nodeid = n.pop 300 | address = Address.from_endpoint *n 301 | get_node nodeid, address 302 | end 303 | 304 | @kademlia.recv_neighbours node, neighbours 305 | end 306 | 307 | def ip 308 | @app.config[:discovery][:listen_host] 309 | end 310 | 311 | def udp_port 312 | @app.config[:discovery][:listen_port] 313 | end 314 | 315 | def tcp_port 316 | @app.config[:p2p][:listen_port] 317 | end 318 | 319 | private 320 | 321 | def logger 322 | @logger ||= Logger.new("p2p.discovery").tap {|l| l.level = :info } 323 | end 324 | 325 | def encode_cmd_id(cmd_id) 326 | cmd_id.chr 327 | end 328 | 329 | def decode_cmd_id(byte) 330 | byte.ord 331 | end 332 | 333 | def encode_expiration(i) 334 | RLP::Sedes.big_endian_int.serialize(i) 335 | end 336 | 337 | def decode_expiration(b) 338 | RLP::Sedes.big_endian_int.deserialize(b) 339 | end 340 | 341 | end 342 | 343 | end 344 | end 345 | -------------------------------------------------------------------------------- /lib/devp2p/discovery/service.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | module Discovery 5 | 6 | class Receiver 7 | include Concurrent::Async 8 | 9 | def initialize(service, socket) 10 | super() 11 | 12 | @service = service 13 | @socket = socket 14 | 15 | @stopped = false 16 | end 17 | 18 | def start 19 | maxlen = Multiplexer.max_window_size * 2 20 | 21 | loop do 22 | break if @stopped || @socket.closed? 23 | 24 | message, info = @socket.recvfrom maxlen 25 | handle_packet message, info[3], info[1] 26 | end 27 | rescue 28 | puts $! 29 | puts $!.backtrace[0,10].join("\n") 30 | end 31 | 32 | def stop 33 | @stopped = true 34 | end 35 | 36 | def handle_packet(message, ip, port) 37 | logger.debug "handling packet", ip: ip, port: port, size: message.size 38 | @service.async.receive_message Address.new(ip, port), message 39 | end 40 | 41 | private 42 | 43 | def logger 44 | @logger ||= Logger.new "p2p.discovery" 45 | end 46 | end 47 | 48 | class Sender 49 | include Concurrent::Async 50 | 51 | def initialize(service, socket) 52 | super() 53 | 54 | @service = service 55 | @socket = socket 56 | 57 | @stopped = false 58 | end 59 | 60 | def start 61 | # do nothing 62 | end 63 | 64 | def send_message(address, message) 65 | raise ArgumentError, 'address must be Address' unless address.instance_of?(Address) 66 | logger.debug "sending", size: message.size, to: address 67 | 68 | @socket.send message, 0, address.ip, address.udp_port 69 | rescue 70 | puts $! 71 | puts $!.backtrace[0,10].join("\n") 72 | end 73 | 74 | private 75 | 76 | def logger 77 | @logger ||= Logger.new "p2p.discovery" 78 | end 79 | end 80 | 81 | class Service < ::DEVp2p::Service 82 | name 'discovery' 83 | 84 | default_config( 85 | discovery: { 86 | listen_port: 30303, 87 | listen_host: '0.0.0.0' 88 | }, 89 | node: { 90 | privkey_hex: '' 91 | } 92 | ) 93 | 94 | attr :protocol 95 | 96 | def initialize(app) 97 | super(app) 98 | logger.info "Discovery service init" 99 | 100 | @socket = nil 101 | @protocol = Protocol.new app, self 102 | end 103 | 104 | def start 105 | logger.info 'starting discovery' 106 | 107 | ip = @app.config[:discovery][:listen_host] 108 | port = @app.config[:discovery][:listen_port] 109 | 110 | logger.info "starting udp listener", port: port, host: ip 111 | 112 | @socket = UDPSocket.new 113 | @socket.bind ip, port 114 | 115 | @receiver = Receiver.new self, @socket 116 | @sender = Sender.new self, @socket 117 | @receiver.async.start 118 | @sender.async.start 119 | 120 | nodes = @app.config[:discovery][:bootstrap_nodes] || [] 121 | @protocol.bootstrap( nodes.map {|x| Node.from_uri(x) } ) 122 | rescue 123 | puts $! 124 | puts $!.backtrace[0,10].join("\n") 125 | end 126 | 127 | def stop 128 | logger.info "stopping discovery" 129 | 130 | @socket.close if @socket 131 | @sender.async.stop if @sender 132 | @receiver.async.stop if @receiver 133 | 134 | @socket = nil 135 | @sender = nil 136 | @receiver = nil 137 | end 138 | 139 | def address 140 | ip = @app.config[:discovery][:listen_host] 141 | port = @app.config[:discovery][:listen_port] 142 | Address.new ip, port 143 | end 144 | 145 | def receive_message(address, message) 146 | raise ArgumentError, 'address must be Address' unless address.instance_of?(Address) 147 | @protocol.receive_message address, message 148 | end 149 | 150 | def send_message(address, message) 151 | @sender.async.send_message address, message 152 | end 153 | 154 | private 155 | 156 | def logger 157 | @logger ||= Logger.new "p2p.discovery" 158 | end 159 | 160 | end 161 | 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /lib/devp2p/exception.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | 5 | class MissingRequiredServiceError < StandardError; end 6 | class InvalidCommandStructure < StandardError; end 7 | class DuplicatedCommand < StandardError; end 8 | class UnknownCommandError < StandardError; end 9 | class FrameError < StandardError; end 10 | class MultiplexerError < StandardError; end 11 | class RLPxSessionError < StandardError; end 12 | class MultiplexedSessionError < StandardError; end 13 | class AuthenticationError < StandardError; end 14 | class FormatError < StandardError; end 15 | class InvalidKeyError < StandardError; end 16 | class InvalidSignatureError < StandardError; end 17 | class InvalidMACError < StandardError; end 18 | class InvalidPayloadError < StandardError; end 19 | class EncryptionError < StandardError; end 20 | class DecryptionError < StandardError; end 21 | class KademliaRoutingError < StandardError; end 22 | class KademliaNodeNotFound < StandardError; end 23 | class PeerError < StandardError; end 24 | class ProtocolError < StandardError; end 25 | 26 | class DefectiveMessage < StandardError; end 27 | class PacketExpired < DefectiveMessage; end 28 | class InvalidMessageMAC < DefectiveMessage; end 29 | 30 | end 31 | -------------------------------------------------------------------------------- /lib/devp2p/frame.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | 5 | ## 6 | # When sending a packet over RLPx, the packet will be framed. The frame 7 | # provides information about the size of the packet and the packet's source 8 | # protocol. There are three slightly different frames, depending on whether 9 | # or not the frame is delivering a multi-frame packet. A multi-frame packet 10 | # is a packet which is split (aka chunked) into multiple frames because it's 11 | # size is larger than the protocol window size (pws, see Multiplexing). When 12 | # a packet is chunked into multiple frames, there is an implicit difference 13 | # between the first frame and all subsequent frames. 14 | # 15 | # Thus, the three frame types are normal, chunked-0 (first frame of a 16 | # multi-frame packet), and chunked-n (subsequent frames of a multi-frame 17 | # packet). 18 | # 19 | # * Single-frame packet: 20 | # 21 | # header || header-mac || frame || mac 22 | # 23 | # * Multi-frame packet: 24 | # 25 | # header || header-mac || frame-0 || 26 | # [ header || header-mac || frame-n || ... || ] 27 | # header || header-mac || frame-last || mac 28 | # 29 | class Frame 30 | 31 | extend Configurable 32 | add_config( 33 | header_size: 16, 34 | mac_size: 16, 35 | padding: 16, 36 | header_sedes: RLP::Sedes::List.new(elements: [RLP::Sedes.big_endian_int]*3, strict: false) 37 | ) 38 | 39 | class <')[1..-1] 42 | end 43 | 44 | def decode_body_size(header) 45 | "\x00#{header[0,3]}".unpack('I>').first 46 | end 47 | end 48 | 49 | attr :protocol_id, :cmd_id, :sequence_id, :payload, :is_chunked_n, :total_payload_size, :frames 50 | 51 | def initialize(protocol_id, cmd_id, payload, sequence_id, window_size, is_chunked_n=false, frames=nil, frame_cipher=nil) 52 | raise ArgumentError, 'invalid protocol_id' unless protocol_id < TT16 53 | raise ArgumentError, 'invalid sequence_id' unless sequence_id.nil? || sequence_id < TT16 54 | raise ArgumentError, 'invalid window_size' unless window_size % padding == 0 55 | raise ArgumentError, 'invalid cmd_id' unless cmd_id < 256 56 | 57 | @protocol_id = protocol_id 58 | @cmd_id = cmd_id 59 | @payload = payload 60 | @sequence_id = sequence_id 61 | @is_chunked_n = is_chunked_n 62 | @frame_cipher = frame_cipher 63 | 64 | @frames = frames || [] 65 | @frames.push self 66 | 67 | # chunk payloads resulting in frames exceeing window_size 68 | fs = frame_size 69 | if fs > window_size 70 | unless is_chunked_n 71 | @is_chunked_0 = true 72 | @total_payload_size = body_size 73 | end 74 | 75 | # chunk payload 76 | @payload = payload[0...(window_size-fs)] 77 | raise FrameError, "invalid frame size" unless frame_size <= window_size 78 | 79 | remain = payload[@payload.size..-1] 80 | raise FrameError, "invalid remain size" unless (remain.size + @payload.size) == payload.size 81 | 82 | Frame.new(protocol_id, cmd_id, remain, sequence_id, window_size, true, @frames, frame_cipher) 83 | end 84 | 85 | raise FrameError, "invalid frame size" unless frame_size <= window_size 86 | end 87 | 88 | def frame_type 89 | return :normal if normal? 90 | @is_chunked_n ? :chunked_n : :chunked_0 91 | end 92 | 93 | def frame_size 94 | # header16 || mac16 || dataN + [padding] || mac16 95 | header_size + mac_size + body_size(true) + mac_size 96 | end 97 | 98 | ## 99 | # frame-size: 3-byte integer, size of frame, big endian encoded (excludes 100 | # padding) 101 | # 102 | def body_size(padded=false) 103 | l = enc_cmd_id.size + payload.size 104 | padded ? Utils.ceil16(l) : l 105 | end 106 | 107 | def normal? 108 | !@is_chunked_n && !@is_chunked_0 109 | end 110 | 111 | ## 112 | # header: frame-size || header-data || padding 113 | # 114 | # frame-size: 3-byte integer, size of frame, big endian encoded 115 | # header-data: 116 | # normal: RLP::Sedes::List.new(protocol_type[, sequence_id]) 117 | # chunked_0: RLP::Sedes::List.new(protocol_type, sequence_id, total_packet_size) 118 | # chunked_n: RLP::Sedes::List.new(protocol_type, sequence_id) 119 | # normal, chunked_n: RLP::Sedes::List.new(protocol_type[, sequence_id]) 120 | # values: 121 | # protocol_type: < 2**16 122 | # sequence_id: < 2**16 (this value is optional for normal frames) 123 | # total_packet_size: < 2**32 124 | # padding: zero-fill to 16-byte boundary 125 | # 126 | def header 127 | raise FrameError, "invalid protocol id" unless protocol_id < 2**16 128 | raise FrameError, "invalid sequence id" unless sequence_id.nil? || sequence_id < TT16 129 | 130 | l = [protocol_id] 131 | if @is_chunked_0 132 | raise FrameError, 'chunked_0 must have sequence_id' if sequence_id.nil? 133 | l.push sequence_id 134 | l.push total_payload_size 135 | elsif sequence_id 136 | l.push sequence_id 137 | end 138 | 139 | header_data = RLP.encode l, sedes: header_sedes 140 | raise FrameError, 'invalid rlp' unless l == RLP.decode(header_data, sedes: header_sedes, strict: false) 141 | 142 | bs = body_size 143 | raise FrameError, 'invalid body size' unless bs < 256**3 144 | 145 | header = Frame.encode_body_size(body_size) + header_data 146 | header = Utils.rzpad16 header 147 | raise FrameError, 'invalid header' unless header.size == header_size 148 | 149 | header 150 | end 151 | 152 | def enc_cmd_id 153 | @is_chunked_n ? '' : RLP.encode(cmd_id, sedes: RLP::Sedes.big_endian_int) 154 | end 155 | 156 | ## 157 | # frame: 158 | # normal: rlp(packet_type) [|| rlp(packet_data)] || padding 159 | # chunked_0: rlp(packet_type) || rlp(packet_data ...) 160 | # chunked_n: rlp(...packet_data) || padding 161 | # padding: zero-fill to 16-byte boundary (only necessary for last frame) 162 | # 163 | def body 164 | Utils.rzpad16 "#{enc_cmd_id}#{payload}" 165 | end 166 | 167 | def as_bytes 168 | raise FrameError, 'can only be called once' if @cipher_called 169 | 170 | if @frame_cipher 171 | @cipher_called = true 172 | e = @frame_cipher.encrypt(header, body) 173 | raise FrameError, 'invalid frame size of encrypted frame' unless e.size == frame_size 174 | e 175 | else 176 | h = header 177 | raise FrameError, 'invalid header size' unless h.size == header_size 178 | 179 | b = body 180 | raise FrameError, 'invalid body size' unless b.size == body_size(true) 181 | 182 | dummy_mac = "\x00" * mac_size 183 | r = h + dummy_mac + b + dummy_mac 184 | raise FrameError, 'invalid frame' unless r.size == frame_size 185 | 186 | r 187 | end 188 | end 189 | 190 | def to_s 191 | "" 35 | end 36 | alias inspect to_s 37 | end 38 | 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/devp2p/kademlia/protocol.rb: -------------------------------------------------------------------------------- 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 285 | -------------------------------------------------------------------------------- /lib/devp2p/kademlia/routing_table.rb: -------------------------------------------------------------------------------- 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 132 | -------------------------------------------------------------------------------- /lib/devp2p/kademlia/wire_interface.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | module Kademlia 5 | 6 | ## 7 | # defines the methods used by KademliaProtocol 8 | # 9 | class WireInterface 10 | 11 | def send_ping(node) 12 | raise NotImplementedError 13 | end 14 | 15 | def send_pong(node, id) 16 | raise NotImplementedError 17 | end 18 | 19 | def send_find_node(nodeid) 20 | raise NotImplementedError 21 | end 22 | 23 | def send_neighbours(node, neighbours) 24 | raise NotImplementedError 25 | end 26 | 27 | end 28 | 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/devp2p/multiplexed_session.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | 5 | class MultiplexedSession < Multiplexer 6 | 7 | def initialize(privkey, hello_packet, remote_pubkey=nil) 8 | @hello_packet = hello_packet 9 | @remote_pubkey = remote_pubkey 10 | 11 | @message_queue = SyncQueue.new # wire msg egress queue 12 | @packet_queue = SyncQueue.new # packet ingress queue 13 | 14 | @is_initiator = !!remote_pubkey 15 | @handshake_finished = false 16 | 17 | ecc = Crypto::ECCx.new privkey 18 | @rlpx_session = RLPxSession.new ecc, @is_initiator 19 | 20 | super(@rlpx_session) 21 | 22 | send_init_msg if @is_initiator 23 | end 24 | 25 | def get_message 26 | @message_queue.deq 27 | end 28 | 29 | def get_packet 30 | @packet_queue.deq 31 | end 32 | 33 | def ready? 34 | @rlpx_session.ready? 35 | end 36 | 37 | def initiator? 38 | @is_initiator 39 | end 40 | 41 | def remote_pubkey 42 | @remote_pubkey || @rlpx_session.remote_pubkey 43 | end 44 | 45 | def remote_pubkey=(key) 46 | @remote_pubkey = key 47 | end 48 | 49 | def add_message(msg) 50 | @handshake_finished ? add_message_post_handshake(msg) : add_message_during_handshake(msg) 51 | end 52 | 53 | ## 54 | # encodes a packet and adds the message(s) to the message queue. 55 | # 56 | def add_packet(packet) 57 | raise MultiplexedSessionError, 'session is not ready' unless ready? 58 | raise ArgumentError, 'packet must be instance of Packet' unless packet.is_a?(Packet) 59 | 60 | super(packet) 61 | 62 | pop_all_frames.each {|f| @message_queue.enq f.as_bytes } 63 | end 64 | 65 | private 66 | 67 | def send_init_msg 68 | auth_msg = @rlpx_session.create_auth_message @remote_pubkey 69 | auth_msg_ct = @rlpx_session.encrypt_auth_message auth_msg 70 | 71 | @message_queue.enq auth_msg_ct 72 | end 73 | 74 | def add_message_during_handshake(msg) 75 | raise MultiplexedSessionError, 'handshake after ready is not allowed' if ready? 76 | 77 | if initiator? 78 | # expecting auth ack message 79 | rest = @rlpx_session.decode_auth_ack_message msg 80 | @rlpx_session.setup_cipher 81 | 82 | # add remains (hello) to queue 83 | add_message_post_handshake(rest) unless rest.empty? 84 | else 85 | # expecting auth_init 86 | rest = @rlpx_session.decode_authentication msg 87 | auth_ack_msg = @rlpx_session.create_auth_ack_message 88 | auth_ack_msg_ct = @rlpx_session.encrypt_auth_ack_message auth_ack_msg 89 | 90 | @message_queue.enq auth_ack_msg_ct 91 | 92 | @rlpx_session.setup_cipher 93 | add_message_post_handshake(rest) unless rest.empty? 94 | end 95 | 96 | @handshake_finished = true 97 | raise MultiplexedSessionError, 'session is not ready after handshake' unless @rlpx_session.ready? 98 | 99 | add_packet @hello_packet 100 | end 101 | 102 | def add_message_post_handshake(msg) 103 | decode(msg).each do |packet| 104 | @packet_queue.enq packet 105 | end 106 | end 107 | 108 | end 109 | 110 | end 111 | -------------------------------------------------------------------------------- /lib/devp2p/multiplexer.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | 5 | ## 6 | # Multiplexing of protocols is performed via dynamic framing and fair 7 | # queueing. Dequeuing packets is performed in a cycle which dequeues one or 8 | # more packets from the queue(s) of each active protocol. The multiplexor 9 | # determines the amount of bytes to send for each protocol prior to each 10 | # round of dequeuing packets. 11 | # 12 | # If the size of an RLP-encoded packet is less than 1KB then the protocol may 13 | # request that the network layer prioritize the delivery of the packet. This 14 | # should be used if and only if the packet must be delivered before all other 15 | # packets. 16 | # 17 | # The network layer maintains two queues and three buffers per protocol: 18 | # 19 | # * a queue for normal packets, a queue for priority packets 20 | # * a chunked-frame buffer, a normal-frame buffer, and a priority-frame buffer 21 | # 22 | # Implemented Variant: 23 | # 24 | # each sub protocol has three queues: prio, normal, chunked 25 | # 26 | # protocols are queried round robin 27 | # 28 | class Multiplexer 29 | 30 | extend Configurable 31 | add_config( 32 | max_window_size: 8 * 1024, 33 | max_priority_frame_size: 1024, 34 | max_payload_size: 10 * 1024**2, 35 | frame_cipher: nil, 36 | ) 37 | 38 | attr :decode_buffer 39 | 40 | def initialize(frame_cipher=nil) 41 | @frame_cipher = frame_cipher || self.class.frame_cipher 42 | @last_protocol = nil 43 | @decode_buffer = "" # byte array 44 | 45 | # protocol_id: {normal: queue, chunked: queue, prio: queue} 46 | @queues = {} 47 | 48 | # protocol_id: counter 49 | @sequence_id = {} 50 | 51 | # decode: {protocol_id: {sequence_id: buffer} 52 | @chunked_buffers = {} 53 | end 54 | 55 | ## 56 | # A protocol is considered active if it's queue contains one or more 57 | # packets. 58 | # 59 | def num_active_protocols 60 | @queues.keys.select {|id| active_protocol?(id) }.size 61 | end 62 | 63 | def active_protocol?(id) 64 | !@queues[id].values.all?(&:empty?) 65 | end 66 | 67 | # pws = protocol_window_size = window_size / active_protocol_count 68 | def protocol_window_size(id=nil) 69 | if id && !active_protocol?(id) 70 | s = max_window_size / (1 + num_active_protocols) 71 | else 72 | s = max_window_size / [1, num_active_protocols].max 73 | end 74 | 75 | s - s % 16 # should be a multiple of padding size # FIXME: 16 should be constant 76 | end 77 | 78 | def add_protocol(id) 79 | raise ArgumentError, 'protocol already added' if @queues.include?(id) 80 | 81 | @queues[id] = { 82 | normal: SyncQueue.new, 83 | chunked: SyncQueue.new, 84 | priority: SyncQueue.new 85 | } 86 | @sequence_id[id] = 0 87 | @chunked_buffers[id] = {} 88 | @last_protocol = id 89 | end 90 | 91 | def next_protocol 92 | protocols = @queues.keys 93 | if @last_protocol == protocols.last 94 | proto = protocols.first 95 | else 96 | proto = protocols[protocols.index(@last_protocol) + 1] 97 | end 98 | 99 | @last_protocol = proto 100 | proto 101 | end 102 | 103 | def add_packet(packet) 104 | sid = @sequence_id[packet.protocol_id] 105 | @sequence_id[packet.protocol_id] = (sid + 1) % TT16 106 | 107 | frames = Frame.new( 108 | packet.protocol_id, packet.cmd_id, packet.payload, sid, 109 | protocol_window_size(packet.protocol_id), 110 | false, nil, @frame_cipher 111 | ).frames 112 | 113 | queues = @queues[packet.protocol_id] 114 | 115 | if packet.prioritize 116 | raise FrameError, "invalid priority packet frames" unless frames.size == 1 117 | raise FrameError, "frame too large for priority packet" unless frames[0].frame_size <= max_priority_frame_size 118 | 119 | queues[:priority].enq frames[0] 120 | elsif frames.size == 1 121 | queues[:normal].enq frames[0] 122 | else 123 | frames.each {|f| queues[:chunked].enq f } 124 | end 125 | end 126 | 127 | ## 128 | # If priority packet and normal packet exist: 129 | # send up to pws/2 bytes from each (priority first) 130 | # else if priority packet and chunked-frame exist: 131 | # send up to pws/2 bytes from each 132 | # else if normal packet and chunked-frame exist: 133 | # send up to pws/2 bytes from each 134 | # else 135 | # read pws bytes from active buffer 136 | # 137 | # If there are bytes leftover -- for example, if the bytes sent is < pws, 138 | # then repeat the cycle. 139 | # 140 | def pop_frames_for_protocol(id) 141 | pws = protocol_window_size 142 | queues = @queues[id] 143 | 144 | frames = [] 145 | size = 0 146 | 147 | while size < pws 148 | frames_added = 0 149 | 150 | %i(priority normal chunked).each do |qn| 151 | q = queues[qn] 152 | 153 | if !q.empty? 154 | fs = q.peek.frame_size 155 | if size + fs <= pws 156 | frames.push q.deq 157 | size += fs 158 | frames_added += 1 159 | end 160 | end 161 | 162 | # add no more than two in order to send normal and priority first 163 | # i.e. next is 'priority' again 164 | # 165 | # FIXME: too weird 166 | # 167 | break if frames_added == 2 168 | end 169 | 170 | break if frames_added == 0 # empty queues 171 | end 172 | 173 | # the following can not be guaranteed, as pws might have been different 174 | # at the time where packets were framed and added to the queues 175 | # 176 | # frames.map(&:frame_size).sum <= pws 177 | return frames 178 | end 179 | 180 | ## 181 | # Returns the frames for the next protocol up to protocol window size bytes. 182 | # 183 | def pop_frames 184 | protocols = @queues.keys 185 | idx = protocols.index next_protocol 186 | protocols = protocols[idx..-1] + protocols[0,idx] 187 | 188 | protocols.each do |id| 189 | frames = pop_frames_for_protocol id 190 | return frames unless frames.empty? 191 | end 192 | 193 | [] 194 | end 195 | 196 | def pop_all_frames 197 | frames = [] 198 | loop do 199 | r = pop_frames 200 | frames.concat r 201 | break if r.empty? 202 | end 203 | frames 204 | end 205 | 206 | def pop_all_frames_as_bytes 207 | pop_all_frames.map(&:as_bytes).join 208 | end 209 | 210 | def decode_header(buffer) 211 | raise ArgumentError, "buffer too small" unless buffer.size >= 32 212 | 213 | if @frame_cipher 214 | header = @frame_cipher.decrypt_header(buffer[0, Frame.header_size + Frame.mac_size]) 215 | else 216 | # header: frame-size || header-data || padding 217 | header = buffer[0, Frame.header_size] 218 | end 219 | 220 | header 221 | end 222 | 223 | ## 224 | # w/o encryption 225 | # peak info buffer for body_size 226 | # 227 | # return nil if buffer is not long enough to decode frame 228 | # 229 | def decode_body(buffer, header=nil) 230 | return [nil, buffer] if buffer.size < Frame.header_size 231 | 232 | header ||= decode_header buffer[0, Frame.header_size + Frame.mac_size] 233 | body_size = Frame.decode_body_size header 234 | 235 | if @frame_cipher 236 | body = @frame_cipher.decrypt_body(buffer[(Frame.header_size+Frame.mac_size)..-1], body_size) 237 | raise MultiplexerError, 'body length mismatch' unless body.size == body_size 238 | 239 | bytes_read = Frame.header_size + Frame.mac_size + Utils.ceil16(body.size) + Frame.mac_size 240 | else 241 | header = buffer[0, Frame.header_size] 242 | body_offset = Frame.header_size + Frame.mac_size 243 | body = buffer[body_offset, body_size] 244 | raise MultiplexerError, 'body length mismatch' unless body.size == body_size 245 | 246 | bytes_read = Utils.ceil16(body_offset + body_size + Frame.mac_size) 247 | end 248 | raise MultiplexerError, "bytes not padded" unless bytes_read % Frame.padding == 0 249 | 250 | # normal, chunked-n: RLP::List.new(protocol_type[, sequence_id]) 251 | # chunked-0: RLP::List.new(protocol_type, sequence_id, total_packet_size) 252 | header_data = nil 253 | begin 254 | header_data = RLP.decode(header[3..-1], sedes: Frame.header_sedes, strict: false) 255 | rescue RLP::Error::RLPException => e 256 | logger.error(e) 257 | raise MultiplexerError, 'invalid rlp data' 258 | end 259 | 260 | if header_data.size == 3 261 | chunked_0 = true 262 | total_payload_size = header_data[2] 263 | raise MultiplexerError, "invalid total payload size" unless total_payload_size < 2**32 264 | else 265 | chunked_0 = false 266 | total_payload_size = nil 267 | end 268 | 269 | protocol_id = header_data[0] 270 | raise MultiplexerError, "invalid protocol id" unless protocol_id < TT16 271 | 272 | if header_data.size > 1 273 | sequence_id = header_data[1] 274 | raise MultiplexerError, "invalid sequence id" unless sequence_id < TT16 275 | else 276 | sequence_id = nil 277 | end 278 | 279 | raise MultiplexerError, "unknown protocol id #{protocol_id}" unless @chunked_buffers.has_key?(protocol_id) 280 | 281 | chunkbuf = @chunked_buffers[protocol_id] 282 | if chunkbuf.has_key?(sequence_id) 283 | packet = chunkbuf[sequence_id] 284 | 285 | raise MultiplexerError, "received chunked_0 frame for existing buffer #{sequence_id} of protocol #{protocol_id}" if chunked_0 286 | raise MultiplexerError, "too much data for chunked buffer #{sequence_id} of protocol #{protocol_id}" if body.size > (packet.total_payload_size - packet.payload.size) 287 | 288 | packet.payload += body 289 | if packet.total_payload_size == packet.payload.size 290 | packet.total_payload_size = nil 291 | chunkbuf.delete sequence_id 292 | return packet 293 | end 294 | else 295 | # body of normal, chunked_0: rlp(packet-type) [|| rlp(packet-data)] || padding 296 | item, item_end = RLP.consume_item(body, 0) 297 | cmd_id = RLP::Sedes.big_endian_int.deserialize item 298 | 299 | if chunked_0 300 | payload = body[item_end..-1] 301 | total_payload_size -= item_end 302 | else 303 | payload = body[item_end..-1] 304 | end 305 | 306 | packet = Packet.new protocol_id, cmd_id, payload 307 | if chunked_0 308 | raise MultiplexerError, "total payload size smaller than initial chunk" if total_payload_size < payload.size 309 | 310 | # shouldn't have been chunked, whatever 311 | return packet if total_payload_size == payload.size 312 | 313 | raise MultiplexerError, 'chunked_0 must have sequence id' if sequence_id.nil? 314 | 315 | packet.total_payload_size = total_payload_size 316 | chunkbuf[sequence_id] = packet 317 | 318 | return nil 319 | else 320 | return packet # normal (non-chunked) 321 | end 322 | end 323 | end 324 | 325 | def decode(data='') 326 | @decode_buffer.concat(data) unless data.empty? 327 | 328 | unless @cached_decode_header 329 | if @decode_buffer.size < Frame.header_size + Frame.mac_size 330 | return [] 331 | else 332 | @cached_decode_header = decode_header @decode_buffer 333 | end 334 | end 335 | 336 | body_size = Frame.decode_body_size @cached_decode_header 337 | required_len = Frame.header_size + Frame.mac_size + Utils.ceil16(body_size) + Frame.mac_size 338 | 339 | if @decode_buffer.size >= required_len 340 | packet = decode_body @decode_buffer, @cached_decode_header 341 | @cached_decode_header = nil 342 | @decode_buffer = @decode_buffer[required_len..-1] 343 | 344 | return packet ? ([packet] + decode) : decode 345 | end 346 | 347 | [] 348 | end 349 | 350 | private 351 | 352 | def logger 353 | @logger ||= Logger.new('multiplexer') 354 | end 355 | 356 | end 357 | 358 | end 359 | -------------------------------------------------------------------------------- /lib/devp2p/p2p_protocol.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | 5 | ## 6 | # DEV P2P Wire Protocol 7 | # 8 | # @see https://github.com/ethereum/wiki/wiki/%C3%90%CE%9EVp2p-Wire-Protocol 9 | # 10 | class P2PProtocol < Protocol 11 | 12 | class Ping < Command 13 | cmd_id 2 14 | 15 | def receive(proto, data) 16 | proto.send_pong 17 | end 18 | end 19 | 20 | class Pong < Command 21 | cmd_id 3 22 | end 23 | 24 | class Hello < Command 25 | cmd_id 0 26 | decode_strict false # don't throw for additional list elements as mandated by EIP-8 27 | 28 | structure( 29 | version: RLP::Sedes.big_endian_int, 30 | client_version_string: RLP::Sedes.binary, 31 | capabilities: RLP::Sedes::CountableList.new( 32 | RLP::Sedes::List.new(elements: [RLP::Sedes.binary, RLP::Sedes.big_endian_int]) 33 | ), 34 | listen_port: RLP::Sedes.big_endian_int, 35 | remote_pubkey: RLP::Sedes.binary 36 | ) 37 | 38 | def create(proto) 39 | { version: proto.class.version, 40 | client_version_string: proto.config[:client_version_string], 41 | capabilities: proto.peer.capabilities, 42 | listen_port: proto.config[:p2p][:listen_port], 43 | remote_pubkey: proto.config[:node][:id] } 44 | end 45 | 46 | def receive(proto, data) 47 | logger.debug 'receive_hello', peer: proto.peer, version: data[:version] 48 | 49 | reasons = proto.class::Disconnect::Reason 50 | if data[:remote_pubkey] == proto.config[:node][:id] 51 | logger.debug 'connected myself' 52 | return proto.send_disconnect(reason: reasons[:connected_to_self]) 53 | end 54 | 55 | proto.peer.async.receive_hello proto, data 56 | super(proto, data) 57 | end 58 | 59 | private 60 | 61 | def logger 62 | @logger = Logger.new "p2p.protocol" 63 | end 64 | 65 | end 66 | 67 | class Disconnect < Command 68 | cmd_id 1 69 | 70 | structure reason: RLP::Sedes.big_endian_int 71 | 72 | Reason = { 73 | disconnect_requested: 0, 74 | tcp_sub_system_error: 1, 75 | bad_protocol: 2, # e.g. a malformed message, bad RLP, incorrect magic number 76 | useless_peer: 3, 77 | too_many_peers: 4, 78 | already_connected: 5, 79 | incompatible_p2p_version: 6, 80 | null_node_identity_received: 7, 81 | client_quitting: 8, 82 | unexpected_identity: 9, 83 | connected_to_self: 10, 84 | timeout: 11, 85 | subprotocol_error: 12, 86 | other: 16 87 | }.freeze 88 | 89 | def reason_key(id) 90 | Reason.invert[id] 91 | end 92 | 93 | def reason_name(id) 94 | key = reason_key id 95 | key ? key.to_s : "unknown (id:#{id})" 96 | end 97 | 98 | def create(proto, reason=Reason[:client_quitting]) 99 | raise ArgumentError, "unknown reason" unless reason_key(reason) 100 | logger.debug "send_disconnect", peer: proto.peer, reason: reason_name(reason) 101 | 102 | proto.peer.async.report_error "sending disconnect #{reason_name(reason)}" 103 | 104 | Concurrent::ScheduledTask.execute(0.5) { proto.peer.async.stop } 105 | 106 | {reason: reason} 107 | end 108 | 109 | def receive(proto, data) 110 | logger.debug "receive_disconnect", peer: proto.peer, reason: reason_name(data[:reason]) 111 | proto.peer.async.report_error "disconnected #{reason_name(data[:reason])}" 112 | proto.peer.async.stop 113 | end 114 | 115 | private 116 | 117 | def logger 118 | @logger = Logger.new "p2p.protocol" 119 | end 120 | 121 | end 122 | 123 | class <" 86 | end 87 | alias inspect to_s 88 | 89 | def report_error(reason) 90 | pn = "#@ip:#@port" 91 | @peermanager.add_error pn, reason, @remote_client_version 92 | end 93 | 94 | def connect_service(service) 95 | raise ArgumentError, "service must be WiredService" unless service.is_a?(WiredService) 96 | 97 | # create protocol instance which connects peer with service 98 | protocol_class = service.wire_protocol 99 | protocol = protocol_class.new self, service 100 | 101 | # register protocol 102 | raise PeerError, 'protocol already connected' if @protocols.has_key?(protocol_class) 103 | logger.debug "registering protocol", protocol: protocol.name, peer: self 104 | 105 | @protocols[protocol_class] = protocol 106 | @mux.add_protocol protocol.protocol_id 107 | 108 | protocol.start 109 | end 110 | 111 | def has_protocol?(protocol) 112 | @protocols.has_key?(protocol) 113 | end 114 | 115 | def receive_hello(proto, data) 116 | version = data[:version] 117 | listen_port = data[:listen_port] 118 | capabilities = data[:capabilities] 119 | remote_pubkey = data[:remote_pubkey] 120 | client_version_string = data[:client_version_string] 121 | 122 | logger.info 'received hello', version: version, client_version: client_version_string, capabilities: capabilities 123 | 124 | raise ArgumentError, "invalid remote pubkey" unless remote_pubkey.size == 64 125 | raise ArgumentError, "remote pubkey mismatch" if @remote_pubkey_available && @remote_pubkey != remote_pubkey 126 | 127 | @hello_received = true 128 | 129 | # enable backwards compatibility for legacy peers 130 | if version < 5 131 | @offset_based_dispatch = true 132 | max_window_size = 2**32 # disable chunked transfers 133 | end 134 | 135 | # call peermanager 136 | agree = @peermanager.on_hello_received(proto, version, client_version_string, capabilities, listen_port, remote_pubkey) 137 | return unless agree 138 | 139 | @remote_client_version = client_version_string 140 | @remote_pubkey = remote_pubkey 141 | 142 | # register in common protocols 143 | logger.debug 'connecting services', services: @peermanager.wired_services 144 | remote_services = capabilities.map {|name, version| [name, version] }.to_h 145 | 146 | @peermanager.wired_services.sort_by(&:name).each do |service| 147 | raise PeerError, 'invalid service' unless service.is_a?(WiredService) 148 | 149 | proto = service.wire_protocol 150 | if remote_services.has_key?(proto.name) 151 | if remote_services[proto.name] == proto.version 152 | if service != @peermanager # p2p protocol already registered 153 | connect_service service 154 | end 155 | else 156 | logger.debug 'wrong version', service: proto.name, local_version: proto.version, remote_version: remote_services[proto.name] 157 | report_error 'wrong version' 158 | end 159 | end 160 | end 161 | end 162 | 163 | def capabilities 164 | @peermanager.wired_services.map {|s| [s.wire_protocol.name, s.wire_protocol.version] } 165 | end 166 | 167 | def send_packet(packet) 168 | protocol = @protocols.values.find {|pro| pro.protocol_id == packet.protocol_id } 169 | raise PeerError, "no protocol found" unless protocol 170 | logger.debug "send packet", cmd: protocol.cmd_by_id[packet.cmd_id], protocol: protocol.name, peer: self 171 | 172 | # rewrite cmd_id (backwards compatibility) 173 | if @offset_based_dispatch 174 | @protocols.values.each_with_index do |proto, i| 175 | if packet.protocol_id > i 176 | packet.cmd_id += (protocol.max_cmd_id == 0 ? 0 : protocol.max_cmd_id + 1) 177 | end 178 | if packet.protocol_id == protocol.protocol_id 179 | protocol = proto 180 | break 181 | end 182 | packet.protocol_id = 0 183 | end 184 | end 185 | 186 | @mux.add_packet packet 187 | end 188 | 189 | def send_data(data) 190 | return if data.nil? || data.empty? 191 | 192 | @safe_to_read.reset 193 | 194 | @socket.write data 195 | logger.debug "wrote data", size: data.size 196 | 197 | @safe_to_read.set 198 | rescue Errno::ETIMEDOUT 199 | logger.debug "write timeout" 200 | report_error "write timeout" 201 | stop 202 | rescue SystemCallError => e 203 | logger.debug "write error #{e}" 204 | report_error "write error #{e}" 205 | stop 206 | end 207 | 208 | def run 209 | logger.debug "peer starting main loop" 210 | raise PeerError, 'connection is closed' if @socket.closed? 211 | 212 | @run_decoded_packets = Thread.new { run_decoded_packets } 213 | @run_egress_message = Thread.new { run_egress_message } 214 | 215 | while !stopped? 216 | @safe_to_read.wait 217 | 218 | begin 219 | imsg = @socket.recv(4096) 220 | if imsg.empty? 221 | logger.info "socket closed" 222 | stop 223 | end 224 | rescue EOFError # imsg is empty 225 | if @socket.closed? 226 | logger.info "socket closed" 227 | stop 228 | else 229 | imsg = '' 230 | end 231 | rescue SystemCallError => e 232 | logger.debug "read error", error: e, peer: self 233 | report_error "network error #{e}" 234 | if [Errno::ECONNRESET, Errno::ETIMEDOUT, Errno::ENETDOWN, Errno::EHOSTUNREACH].any? {|syserr| e.instance_of?(syserr) } 235 | stop 236 | else 237 | raise e 238 | break 239 | end 240 | end 241 | 242 | if !imsg.empty? 243 | logger.debug "read data", size: imsg.size 244 | @mux.add_message imsg 245 | end 246 | end 247 | rescue RLPxSessionError, DecryptionError => e 248 | logger.debug "rlpx session error", peer: self, error: e 249 | report_error "rlpx session error" 250 | stop 251 | rescue MultiplexerError => e 252 | logger.debug "multiplexer error", peer: self, error: e 253 | report_error "multiplexer error" 254 | stop 255 | rescue 256 | logger.debug "ingress message error", peer: self, error: $! 257 | report_error "ingress message error" 258 | stop 259 | end 260 | 261 | private 262 | 263 | def logger 264 | @logger ||= Logger.new "p2p.peer" 265 | end 266 | 267 | def hello_data 268 | { client_version_string: config[:client_version_string], 269 | capabilities: capabilities, 270 | listen_port: config[:p2p][:listen_port], 271 | remote_pubkey: config[:node][:id] 272 | } 273 | end 274 | 275 | def handle_packet(packet) 276 | raise ArgumentError, 'packet must be Packet' unless packet.is_a?(Packet) 277 | 278 | protocol, cmd_id = protocol_cmd_id_from_packet packet 279 | logger.debug "recv packet", cmd: protocol.cmd_by_id[cmd_id], protocol: protocol.name, orig_cmd_id: packet.cmd_id 280 | 281 | packet.cmd_id = cmd_id # rewrite 282 | protocol.receive_packet packet 283 | rescue UnknownCommandError => e 284 | logger.error 'received unknown cmd', error: e, packet: packet 285 | rescue 286 | logger.error $! 287 | logger.error $!.backtrace[0,10].join("\n") 288 | end 289 | 290 | def protocol_cmd_id_from_packet(packet) 291 | # offset-based dispatch (backwards compatibility) 292 | if @offset_based_dispatch 293 | max_id = 0 294 | 295 | @protocols.each_value do |protocol| 296 | if packet.cmd_id < max_id + protocol.max_cmd_id + 1 297 | return protocol, packet.cmd_id - (max_id == 0 ? 0 : max_id + 1) 298 | end 299 | max_id += protocol.max_cmd_id 300 | end 301 | raise UnknownCommandError, "no protocol for id #{packet.cmd_id}" 302 | end 303 | 304 | # new-style dispatch based on protocol_id 305 | @protocols.values.each_with_index do |protocol, i| 306 | if packet.protocol_id == protocol.protocol_id 307 | return protocol, packet.cmd_id 308 | end 309 | end 310 | raise UnknownCommandError, "no protocol for protocol id #{packet.protocol_id}" 311 | end 312 | 313 | ## 314 | # Stop peer if hello not received 315 | # 316 | def check_if_dumb_remote 317 | if !@hello_received 318 | report_error "No hello in #{DUMB_REMOTE_TIMEOUT} seconds" 319 | stop 320 | end 321 | end 322 | 323 | def run_egress_message 324 | while !stopped? 325 | # TODO: async.send_data? 326 | send_data @mux.get_message 327 | end 328 | end 329 | 330 | def run_decoded_packets 331 | while !stopped? 332 | # TODO: async.handle_packet? 333 | handle_packet @mux.get_packet # get_packet blocks 334 | end 335 | end 336 | 337 | end 338 | 339 | end 340 | -------------------------------------------------------------------------------- /lib/devp2p/peer_errors.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | 5 | class PeerErrorsBase 6 | 7 | def add(address, error, client_version='') 8 | # do nothing 9 | end 10 | 11 | end 12 | 13 | class PeerErrors < PeerErrorsBase 14 | 15 | def initialize 16 | @errors = Hash.new {|h, k| h[k] = [] } # node:['error'] 17 | @client_versions = {} # address: client_version 18 | 19 | at_exit do 20 | @errors.each do |k, v| 21 | puts "#{k} #{@client_versions.fetch(k, '')}" 22 | puts v.join("\t") 23 | end 24 | end 25 | end 26 | 27 | def add(address, error, client_version='') 28 | @errors[address].push error 29 | @client_versions[address] = client_version unless client_version.nil? || client_version.empty? 30 | end 31 | 32 | end 33 | 34 | end 35 | 36 | -------------------------------------------------------------------------------- /lib/devp2p/peer_manager.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | 5 | ## 6 | # connection strategy 7 | # for service which requires peers 8 | # while peers.size > min_num_peers 9 | # gen random id 10 | # resolve closest node address 11 | # [ideally know their services] 12 | # connect closest node 13 | # 14 | class PeerManager < WiredService 15 | 16 | class ServiceListener 17 | include Concurrent::Async 18 | 19 | def initialize(service, server) 20 | super() 21 | 22 | @service = service 23 | @server = server 24 | 25 | @stopped = false 26 | end 27 | 28 | def start 29 | loop do 30 | break if @stopped 31 | @service.async.handle_connection @server.accept 32 | end 33 | rescue IOError 34 | logger.error "listening error: #{$!}" 35 | puts $! 36 | @stopped = true 37 | rescue 38 | logger.error $! 39 | logger.error $!.backtrace[0,10].join("\n") 40 | end 41 | 42 | def stop 43 | @stopped = true 44 | end 45 | 46 | private 47 | 48 | def logger 49 | @logger ||= Logger.new "p2p.peermgr" 50 | end 51 | end 52 | 53 | name 'peermanager' 54 | required_services [] 55 | 56 | default_config( 57 | p2p: { 58 | bootstrap_nodes: [], 59 | min_peers: 5, 60 | max_peers: 10, 61 | listen_port: 30303, 62 | listen_host: '0.0.0.0' 63 | }, 64 | log_disconnects: false, 65 | node: {privkey_hex: ''} 66 | ) 67 | 68 | def initialize(app) 69 | super(app) 70 | 71 | logger.info "PeerManager init" 72 | 73 | @peers = [] 74 | @excluded = [] 75 | @errors = @config[:log_disconnects] ? PeerErrors.new : PeerErrorsBase.new 76 | 77 | @wire_protocol = P2PProtocol 78 | 79 | # setup nodeid based on privkey 80 | unless @config[:p2p].has_key?(:id) 81 | @config[:node][:id] = Crypto.privtopub Utils.decode_hex(@config[:node][:privkey_hex]) 82 | end 83 | 84 | @connect_timeout = 2.0 85 | @connect_loop_delay = 0.5 86 | @discovery_delay = 0.5 87 | 88 | @host = @config[:p2p][:listen_host] 89 | @port = @config[:p2p][:listen_port] 90 | 91 | @stopped = false 92 | end 93 | 94 | def start 95 | logger.info "starting peermanager" 96 | 97 | logger.info "starting tcp listener", host: @host, port: @port 98 | @server = TCPServer.new @host, @port 99 | 100 | @service_listener = ServiceListener.new self, @server 101 | @service_listener.async.start 102 | 103 | @discovery_loop = Thread.new do 104 | sleep 0.1 105 | discovery_loop 106 | end 107 | end 108 | 109 | def stop 110 | logger.info "stopping peermanager" 111 | 112 | @server.close if @server 113 | @peers.each(&:stop) 114 | @discovery_loop.kill 115 | 116 | @stopped = true 117 | end 118 | 119 | def stopped? 120 | @stopped 121 | end 122 | 123 | def add(peer) 124 | @peers.push peer 125 | end 126 | 127 | def delete(peer) 128 | @peers.delete peer 129 | end 130 | 131 | def exclude(peer) 132 | @excluded.push peer.remote_pubkey 133 | peer.async.stop 134 | end 135 | 136 | def on_hello_received(proto, version, client_version_string, capabilities, listen_port, remote_pubkey) 137 | logger.debug 'hello_received', listen_port: listen_port, peer: proto.peer, num_peers: @peers.size 138 | 139 | if @peers.size > @config[:p2p][:max_peers] 140 | logger.debug "too many peers", max: @config[:p2p][:max_peers] 141 | proto.send_disconnect proto.class::Disconnect::Reason[:too_many_peers] 142 | return false 143 | end 144 | if @peers.select {|p| p != proto.peer }.include?(remote_pubkey) 145 | logger.debug "connected to that node already" 146 | proto.send_disconnect proto.class::Disconnect::Reason[:useless_peer] 147 | return false 148 | end 149 | 150 | return true 151 | end 152 | 153 | def wired_services 154 | app.services.values.select {|s| s.is_a?(WiredService) } 155 | end 156 | 157 | def broadcast(protocol, command_name, args=[], kwargs={}, num_peers=nil, exclude_peers=[]) 158 | logger.debug "broadcasting", protocol: protocol, command: command_name, num_peers: num_peers, exclude_peers: exclude_peers.map(&:to_s) 159 | raise ArgumentError, 'invalid num_peers' unless num_peers.nil? || num_peers > 0 160 | 161 | peers_with_proto = @peers.select {|p| p.protocols.include?(protocol) && !exclude_peers.include?(p) } 162 | if peers_with_proto.empty? 163 | logger.debug "no peers with protocol found", protos: @peers.select {|p| p.protocols } 164 | end 165 | 166 | num_peers ||= peers_with_proto.size 167 | peers_with_proto.sample([num_peers, peers_with_proto.size].min).each do |peer| 168 | logger.debug "broadcasting to", proto: peer.protocols[protocol] 169 | 170 | args.push kwargs 171 | peer.protocols[protocol].send "send_#{command_name}", *args 172 | 173 | peer.safe_to_read.wait 174 | logger.debug "broadcasting done", ts: Time.now 175 | end 176 | end 177 | 178 | ## 179 | # Connect to address (a 2-tuple [host, port]) and return the socket object. 180 | # 181 | # Passing the optional timeout parameter will set the timeout. 182 | # 183 | def connect(host, port, remote_pubkey) 184 | socket = create_connection host, port, @connect_timeout 185 | logger.debug "connecting to", peer: socket.peeraddr 186 | 187 | start_peer socket, remote_pubkey 188 | true 189 | rescue Errno::ETIMEDOUT 190 | address = "#{host}:#{port}" 191 | logger.debug "connection timeout", address: address, timeout: @connect_timeout 192 | @errors.add address, 'connection timeout' 193 | false 194 | rescue Errno::ECONNRESET, Errno::ECONNABORTED, Errno::ECONNREFUSED 195 | address = "#{host}:#{port}" 196 | logger.debug "connection error #{$!}" 197 | @errors.add address, "connection error #{$!}" 198 | false 199 | rescue 200 | address = "#{host}:#{port}" 201 | logger.debug $! 202 | @errors.add address, "connection error #{$!}" 203 | false 204 | end 205 | 206 | def num_peers 207 | active = @peers.select {|p| !p.stopped? } 208 | 209 | if @peers.size != active.size 210 | logger.error "stopped peers in peers list", inlist: @peers.size, active: active.size 211 | end 212 | 213 | active.size 214 | end 215 | 216 | def add_error(*args) 217 | @errors.add *args 218 | end 219 | 220 | def handle_connection(socket) 221 | _, port, host = socket.peeraddr 222 | logger.debug "incoming connection", host: host, port: port 223 | 224 | start_peer socket 225 | rescue EOFError 226 | logger.debug "connection disconnected", host: host, port: port 227 | socket.close 228 | end 229 | 230 | private 231 | 232 | def logger 233 | @logger ||= Logger.new "p2p.peermgr" 234 | end 235 | 236 | def bootstrap(bootstrap_nodes=[]) 237 | bootstrap_nodes.each do |uri| 238 | ip, port, pubkey = Utils.host_port_pubkey_from_uri uri 239 | logger.info 'connecting bootstrap server', uri: uri 240 | 241 | begin 242 | connect ip, port, pubkey 243 | rescue Errno::ECONNRESET, Errno::ECONNABORTED, Errno::ECONNREFUSED, Errno::ETIMEDOUT 244 | logger.warn "connecting bootstrap server failed: #{$!}" 245 | end 246 | end 247 | end 248 | 249 | # FIXME: TODO: timeout is ignored! 250 | def create_connection(host, port, timeout) 251 | ::TCPSocket.new(host, port) 252 | end 253 | 254 | def start_peer(socket, remote_pubkey=nil) 255 | peer = Peer.new self, socket, remote_pubkey 256 | logger.debug "created new peer", peer: peer, fileno: socket.to_io.fileno 257 | 258 | add peer 259 | peer.async.start 260 | 261 | logger.debug "peer started", peer: peer, fileno: socket.to_io.fileno 262 | raise PeerError, 'connection closed' if socket.closed? 263 | 264 | peer 265 | rescue 266 | puts $! 267 | puts $!.backtrace[0,10].join("\n") 268 | end 269 | 270 | def discovery_loop 271 | logger.info "waiting for bootstrap" 272 | sleep @discovery_delay 273 | 274 | while !stopped? 275 | num, min = num_peers, @config[:p2p][:min_peers] 276 | 277 | begin 278 | kademlia_proto = app.services.discovery.protocol.kademlia 279 | rescue NoMethodError # some point hit nil 280 | logger.error "Discovery service not available." 281 | break 282 | end 283 | 284 | if num < min 285 | logger.debug "missing peers", num_peers: num, min_peers: min, known: kademlia_proto.routing.size 286 | 287 | nodeid = Kademlia.random_nodeid 288 | 289 | kademlia_proto.find_node nodeid 290 | sleep @discovery_delay 291 | 292 | neighbours = kademlia_proto.routing.neighbours(nodeid, 2) 293 | if neighbours.empty? 294 | sleep @connect_loop_delay 295 | next 296 | end 297 | 298 | node = neighbours.sample 299 | 300 | local_pubkey = Crypto.privtopub Utils.decode_hex(@config[:node][:privkey_hex]) 301 | if node.pubkey == local_pubkey 302 | logger.debug 'connecting random neighbour', node: node, skipped: true, reason: 'myself' 303 | next 304 | end 305 | if @peers.any? {|p| node.pubkey == p.remote_pubkey } 306 | logger.debug 'connecting random neighbour', node: node, skipped: true, reason: 'already connected' 307 | next 308 | end 309 | if @excluded.any? {|pubkey| node.pubkey == pubkey } 310 | logger.debug 'connecting random neighbour', node: node, skipped: true, reason: 'excluded peer' 311 | next 312 | end 313 | 314 | logger.debug 'connecting random neighbour', node: node, skipped: false 315 | connect node.address.ip, node.address.tcp_port, node.pubkey 316 | end 317 | 318 | sleep @connect_loop_delay 319 | end 320 | rescue 321 | puts $! 322 | puts $!.backtrace[0,10].join("\n") 323 | end 324 | 325 | end 326 | 327 | end 328 | -------------------------------------------------------------------------------- /lib/devp2p/protocol.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | 5 | ## 6 | # A protocol mediates between the network and the service. It implements a 7 | # collection of commands. 8 | # 9 | # For each command X the following methods are created at initialization: 10 | # 11 | # * `packet = protocol.create_X(*args, **kwargs)` 12 | # * `protocol.send_X(*args, **kwargs)`, which is a shortcut for `send_packet` 13 | # plus `create_X`. 14 | # * `protocol.receive_X(data)` 15 | # 16 | # On `protocol.receive_packet`, the packet is deserialized according to the 17 | # `command.structure` and the `command.receive` method called with a hash 18 | # containing the received data. 19 | # 20 | # The default implementation of `command.receive` calls callbacks which can 21 | # be registered in a list which is available as: `protocol.receive_X_callbacks`. 22 | # 23 | class Protocol 24 | include Concurrent::Async 25 | 26 | extend Configurable 27 | add_config( 28 | name: '', 29 | protocol_id: 0, 30 | version: 0, 31 | max_cmd_id: 0 # reserved cmd space 32 | ) 33 | 34 | attr :peer, :service, :cmd_by_id 35 | 36 | def initialize(peer, service) 37 | raise ArgumentError, 'service must be WiredService' unless service.is_a?(WiredService) 38 | raise ArgumentError, 'peer.send_packet must be callable' unless peer.respond_to?(:send_packet) 39 | 40 | @peer = peer 41 | @service = service 42 | 43 | @stopped = false 44 | 45 | setup 46 | end 47 | 48 | def start 49 | logger.debug 'starting', proto: self 50 | service.async.on_wire_protocol_start self 51 | rescue 52 | puts $! 53 | puts $!.backtrace[0,10].join("\n") 54 | end 55 | 56 | def stop 57 | logger.debug 'stopping', proto: self 58 | service.async.on_wire_protocol_stop self 59 | 60 | @stopped = true 61 | rescue 62 | puts $! 63 | puts $!.backtrace[0,10].join("\n") 64 | end 65 | 66 | def stopped? 67 | @stopped 68 | end 69 | 70 | def receive_packet(packet) 71 | cmd_name = @cmd_by_id[packet.cmd_id] 72 | cmd = "receive_#{cmd_name}" 73 | send cmd, packet 74 | rescue ProtocolError => e 75 | logger.warn "protocol exception, stopping", error: e 76 | stop 77 | end 78 | 79 | def send_packet(packet) 80 | peer.async.send_packet packet 81 | end 82 | 83 | def to_s 84 | "<#{name} #{peer}>" 85 | end 86 | alias :inspect :to_s 87 | 88 | private 89 | 90 | def logger 91 | @logger ||= Logger.new('protocol') 92 | end 93 | 94 | def setup 95 | klasses = [] 96 | self.class.constants.each do |name| 97 | c = self.class.const_get name 98 | klasses.push(c) if c.instance_of?(Class) && c < Command 99 | end 100 | 101 | raise DuplicatedCommand unless klasses.map(&:cmd_id).uniq.size == klasses.size 102 | 103 | proto = self 104 | 105 | klasses.each do |klass| 106 | instance = klass.new 107 | 108 | # decode rlp, create hash, call receive 109 | receive = lambda do |packet| 110 | raise ArgumentError, "packet is not a Packet: #{packet.inspect}" unless packet.is_a?(Packet) 111 | instance.receive proto, klass.decode_payload(packet.payload) 112 | end 113 | 114 | # get data, rlp encode, return packet 115 | create = lambda do |*args| 116 | res = instance.create(proto, *args) 117 | payload = klass.encode_payload res 118 | Packet.new protocol_id, klass.cmd_id, payload 119 | end 120 | 121 | # create and send packet 122 | send_packet = lambda do |*args| 123 | packet = create.call *args 124 | send_packet packet 125 | end 126 | 127 | name = Utils.class_to_cmd_name(klass) 128 | singleton_class.send(:define_method, "receive_#{name}", &receive) 129 | singleton_class.send(:define_method, "create_#{name}", &create) 130 | singleton_class.send(:define_method, "send_#{name}", &send_packet) 131 | singleton_class.send(:define_method, "receive_#{name}_callbacks") do 132 | instance.receive_callbacks 133 | end 134 | end 135 | 136 | @cmd_by_id = klasses.map {|k| [k.cmd_id, Utils.class_to_cmd_name(k)] }.to_h 137 | end 138 | 139 | end 140 | 141 | end 142 | -------------------------------------------------------------------------------- /lib/devp2p/service.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | 5 | class Service 6 | include Concurrent::Async 7 | 8 | extend Configurable 9 | add_config( 10 | name: '', 11 | default_config: {}, 12 | required_services: [] 13 | ) 14 | 15 | class <" 45 | end 46 | alias inspect to_s 47 | 48 | end 49 | 50 | end 51 | -------------------------------------------------------------------------------- /lib/devp2p/sync_queue.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | require 'thread' 4 | 5 | class SyncQueue 6 | 7 | attr :queue, :max_size 8 | 9 | def initialize(max_size=nil) 10 | @queue = [] 11 | @num_waiting = 0 12 | 13 | @max_size = max_size 14 | 15 | @mutex = Mutex.new 16 | @cond_full = ConditionVariable.new 17 | @cond_empty = ConditionVariable.new 18 | end 19 | 20 | def enq(obj, non_block=false) 21 | Thread.handle_interrupt(StandardError => :on_blocking) do 22 | loop do 23 | @mutex.synchronize do 24 | if full? 25 | if non_block 26 | raise ThreadError, 'queue full' 27 | else 28 | begin 29 | @num_waiting += 1 30 | @cond_full.wait @mutex 31 | ensure 32 | @num_waiting -= 1 33 | end 34 | end 35 | else 36 | @queue.push obj 37 | @cond_empty.signal 38 | return obj 39 | end 40 | end 41 | end 42 | end 43 | end 44 | alias << enq 45 | 46 | def deq(non_block=false) 47 | Thread.handle_interrupt(StandardError => :on_blocking) do 48 | loop do 49 | @mutex.synchronize do 50 | if empty? 51 | if non_block 52 | raise ThreadError, 'queue empty' 53 | else 54 | begin 55 | @num_waiting += 1 56 | @cond_empty.wait @mutex 57 | ensure 58 | @num_waiting -= 1 59 | end 60 | end 61 | else 62 | obj = @queue.shift 63 | @cond_full.signal 64 | return obj 65 | end 66 | end 67 | end 68 | end 69 | end 70 | 71 | # Same as pop except it will not remove the element from queue, just peek. 72 | def peek(non_block=false) 73 | Thread.handle_interrupt(StandardError => :on_blocking) do 74 | loop do 75 | @mutex.synchronize do 76 | if empty? 77 | if non_block 78 | raise ThreadError, 'queue empty' 79 | else 80 | begin 81 | @num_waiting += 1 82 | @cond_empty.wait @mutex 83 | ensure 84 | @num_waiting -= 1 85 | end 86 | end 87 | else 88 | return @queue[0] 89 | end 90 | end 91 | end 92 | end 93 | end 94 | 95 | def full? 96 | @max_size && @queue.size >= @max_size 97 | end 98 | 99 | def empty? 100 | @queue.empty? 101 | end 102 | 103 | def clear 104 | @queue.clear 105 | end 106 | 107 | def length 108 | @queue.length 109 | end 110 | alias size length 111 | 112 | # Returns the number of threads waiting on the queue. 113 | def num_waiting 114 | @num_waiting 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/devp2p/utils.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | module Utils 5 | 6 | BYTE_ZERO = "\x00".freeze 7 | 8 | extend self 9 | 10 | def encode_hex(b) 11 | RLP::Utils.encode_hex b 12 | end 13 | 14 | def decode_hex(s) 15 | RLP::Utils.decode_hex s 16 | end 17 | 18 | def int_to_big_endian(i) 19 | RLP::Sedes.big_endian_int.serialize(i) 20 | end 21 | 22 | def big_endian_to_int(s) 23 | RLP::Sedes.big_endian_int.deserialize s.sub(/\A(\x00)+/, '') 24 | end 25 | 26 | # 4 bytes big endian integer 27 | def int_to_big_endian4(i) 28 | [i].pack('I>') 29 | end 30 | 31 | def ceil16(x) 32 | x % 16 == 0 ? x : (x + 16 - (x%16)) 33 | end 34 | 35 | def lpad(x, symbol, l) 36 | return x if x.size >= l 37 | symbol * (l - x.size) + x 38 | end 39 | 40 | def zpad(x, l) 41 | lpad x, BYTE_ZERO, l 42 | end 43 | 44 | def bpad(x, l) 45 | lpad x.to_s(2), '0', l 46 | end 47 | 48 | def rzpad16(data) 49 | extra = data.size % 16 50 | data += "\x00" * (16 - extra) if extra != 0 51 | data 52 | end 53 | 54 | def zpad_int(i, l=32) 55 | Utils.zpad Utils.int_to_big_endian(i), l 56 | end 57 | 58 | ## 59 | # String xor. 60 | # 61 | def sxor(s1, s2) 62 | raise ArgumentError, "strings must have equal size" unless s1.size == s2.size 63 | 64 | s1.bytes.zip(s2.bytes).map {|a, b| (a ^ b).chr }.join 65 | end 66 | 67 | def update_config_with_defaults(config, default_config) 68 | default_config.each do |k, v| 69 | if v.is_a?(Hash) 70 | config[k] = update_config_with_defaults(config.fetch(k, {}), v) 71 | elsif !config.has_key?(k) 72 | config[k] = default_config[k] 73 | end 74 | end 75 | 76 | config 77 | end 78 | 79 | def host_port_pubkey_from_uri(uri) 80 | raise ArgumentError, 'invalid uri' unless uri =~ /\A#{NODE_URI_SCHEME}.+@.+:.+$/ 81 | 82 | pubkey_hex, ip_port = uri[NODE_URI_SCHEME.size..-1].split('@') 83 | raise ArgumentError, 'invalid pubkey length' unless pubkey_hex.size == 2 * Kademlia::PUBKEY_SIZE / 8 84 | 85 | ip, port = ip_port.split(':') 86 | return ip, port, Utils.decode_hex(pubkey_hex) 87 | end 88 | 89 | def host_port_pubkey_to_uri(host, port, pubkey) 90 | raise ArgumentError, 'invalid pubkey length' unless pubkey.size == Kademlia::PUBKEY_SIZE / 8 91 | 92 | "#{NODE_URI_SCHEME}#{encode_hex pubkey}@#{host}:#{port}" 93 | end 94 | 95 | DOUBLE_COLON = '::'.freeze 96 | def underscore(s) 97 | word = s.split(DOUBLE_COLON).last 98 | word.gsub!(/([A-Z\d]+)([A-Z][a-z])/,'\1_\2') 99 | word.gsub!(/([a-z\d])([A-Z])/,'\1_\2') 100 | word.tr!("-", "_") 101 | word.downcase! 102 | word 103 | end 104 | 105 | def class_to_cmd_name(klass) 106 | klass.name.split(DOUBLE_COLON).last.downcase 107 | end 108 | 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/devp2p/version.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | 3 | module DEVp2p 4 | VERSION = '0.3.0' 5 | 6 | VersionString = begin 7 | git_describe_re = /^(?v\d+\.\d+\.\d+)-(?\d+-g[a-fA-F0-9]+(?:-dirty)?)$/ 8 | 9 | rev = `git describe --tags --dirty` 10 | m = rev.match git_describe_re 11 | 12 | ver = m ? "#{m[:version]}+git-#{m[:git]}" : VERSION 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/devp2p/wired_service.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | module DEVp2p 3 | 4 | ## 5 | # A service which has an associated WireProtocol. 6 | # 7 | # peermanager checks all services registered with app.services 8 | # if service is instance of WiredService 9 | # add WiredService.wire_protocol to announced capabilities 10 | # if a peer with the same protocol is connected 11 | # a WiredService.wire_protocol instance is created 12 | # with instances of Peer and WiredService 13 | # WiredService.wire_protocol(Peer.new, WiredService.new) 14 | # 15 | class WiredService < Service 16 | name 'wired' 17 | 18 | attr_accessor :wire_protocol 19 | 20 | def on_wire_protocol_start(proto) 21 | raise ArgumentError, "argument is not a protocol" unless proto.is_a?(::DEVp2p::Protocol) 22 | end 23 | 24 | def on_wire_protocol_stop(proto) 25 | raise ArgumentError, "argument is not a protocol" unless proto.is_a?(::DEVp2p::Protocol) 26 | end 27 | 28 | end 29 | 30 | end 31 | -------------------------------------------------------------------------------- /test/app.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path('../../lib', __FILE__) 2 | 3 | require 'yaml' 4 | require 'hashie' 5 | require 'devp2p' 6 | 7 | default_config = <<-EOF 8 | discovery: 9 | listen_host: 0.0.0.0 10 | listen_port: 13333 11 | bootstrap_nodes: 12 | # local bootstrap 13 | # - enode://6ed2fecb28ff17dec8647f08aa4368b57790000e0e9b33a7b91f32c41b6ca9ba21600e9a8c44248ce63a71544388c6745fa291f88f8b81e109ba3da11f7b41b9@127.0.0.1:30303 14 | # go_bootstrap 15 | #- enode://6cdd090303f394a1cac34ecc9f7cda18127eafa2a3a06de39f6d920b0e583e062a7362097c7c65ee490a758b442acd5c80c6fce4b148c6a391e946b45131365b@54.169.166.226:30303 16 | # cpp_bootstrap 17 | #- enode://4a44599974518ea5b0f14c31c4463692ac0329cb84851f3435e6d1b18ee4eae4aa495f846a0fa1219bd58035671881d44423876e57db2abd57254d0197da0ebe@5.1.83.226:30303 18 | # go1_bootstrap <- use this 19 | - enode://a979fb575495b8d6db44f750317d0f4622bf4c2aa3365d6af7c284339968eef29b69ad0dce72a4d8db5ebb4968de0e3bec910127f134779fbcb0cb6d3331163c@52.16.188.185:30303 20 | # go2_bootstrap 21 | #- enode://de471bccee3d042261d52e9bff31458daecc406142b401d4cd848f677479f73104b9fdeb090af9583d3391b7f10cb2ba9e26865dd5fca4fcdc0fb1e3b723c786@54.94.239.50:30303 22 | # python_bootstrap 23 | #- enode://2676755dd8477ad3beea32b4e5a144fa10444b70dfa3e05effb0fdfa75683ebd4f75709e1f8126cb5317c5a35cae823d503744e790a3a038ae5dd60f51ee9101@144.76.62.101:30303 24 | 25 | p2p: 26 | num_peers: 10 27 | listen_host: 0.0.0.0 28 | listen_port: 13333 29 | 30 | node: 31 | privkey_hex: 65462b0520ef7d3df61b9992ed3bea0c56ead753be7c8b3614e0ce01e4cac41b 32 | EOF 33 | 34 | include DEVp2p 35 | 36 | if ARGV.size > 0 37 | puts "loading config from #{ARGV[0]}" 38 | config = Hashie::Mash.new YAML.load_file(ARGV[0]) 39 | else 40 | config = Hashie::Mash.new YAML.load(default_config) 41 | pubkey = Crypto.privtopub Utils.decode_hex(config['node']['privkey_hex']) 42 | config.node.id = Crypto.keccak256 pubkey 43 | end 44 | 45 | Logging.logger.root.level = :debug 46 | 47 | app = App.new config 48 | Discovery::Service.register_with_app app 49 | PeerManager.register_with_app app 50 | 51 | puts "application config:" 52 | p app.config.to_h 53 | 54 | evt_exit = Concurrent::Event.new 55 | do_exit = proc do 56 | puts "exit." 57 | exit 0 58 | end 59 | 60 | Signal.trap("INT", &do_exit) 61 | Signal.trap("TERM", &do_exit) 62 | Signal.trap("QUIT", &do_exit) 63 | 64 | app.async.start 65 | 66 | Thread.new { evt_exit.wait }.join 67 | -------------------------------------------------------------------------------- /test/app_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'test_helper' 3 | 4 | require_relative 'example' 5 | 6 | class AppTest < Minitest::Test 7 | include DEVp2p 8 | 9 | class ExampleServiceAppRestart < ExampleService 10 | 11 | class <\xD5\xAA\xBA\x05e\xD7\x1E\x184`H\x19\xFF\x9C\x17\xF5\xE9\xD5\xDD\a\x8Fp\xBE\xAF\x8FX\x8BT\x15\a\xFE\xD6\xA6B\xC5\xABB\xDF\xDF\x81 \xA7\xF69\xDEQ\"\xD4zi\xA8\xE8\xD1", Crypto.privtopub("\x01"*32) 9 | end 10 | 11 | def test_privtopub2 12 | priv = Crypto.mk_privkey 'test' 13 | pub = Crypto.privtopub priv 14 | pub2 = Crypto::ECCx.new(priv).raw_pubkey 15 | assert_equal pub, pub2 16 | end 17 | 18 | def test_keccak256 19 | assert_equal 'c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470', Utils.encode_hex(Crypto.keccak256('')) 20 | end 21 | 22 | def test_hmac_sha256 23 | assert_equal "rQ\xB8\xD0\xA5@\x88Q$\xD9\x7F'\xC5\xFC[\x84}\x87E6!\xF4#\xE7+\x9D\xE2\xA2\xE6\xE0\x00^", Crypto.hmac_sha256("\x01"*32, 'ether') 24 | 25 | k_mac = Utils.decode_hex("07a4b6dfa06369a570f2dcba2f11a18f") 26 | indata = Utils.decode_hex("4dcb92ed4fc67fe86832") 27 | expected = Utils.decode_hex("c90b62b1a673b47df8e395e671a68bfa68070d6e2ef039598bb829398b89b9a9") 28 | hmac = Crypto.hmac_sha256(k_mac, indata) 29 | assert_equal expected, hmac 30 | end 31 | 32 | def test_ecdsa_sign 33 | assert_equal "R\x90\xB9\xF6r/M\x1A\xAB\x99\xF0\"\xF8\xD6\xF1\xFA\xE6\x83\x00C9\x153\xA8L;\x127\xD3\xBD\x8DWP\xDD%\x06\xCD\x04o\xEBD_\xDD8\xAF\xEF\x9D\x7F\xB6\xEE\x18/R\xDCE*\t1\xCEHcz\xCC\xC6\x00", Crypto.ecdsa_sign("1"*32, "\x01"*32) 34 | end 35 | 36 | def test_recover 37 | alice = Crypto::ECCx.new Crypto.mk_privkey('secret1') 38 | message = (0...1024).map { SecureRandom.random_number(256).chr }.join 39 | message = Crypto.keccak256 message 40 | signature = alice.sign message 41 | 42 | recovered_pubkey = Crypto.ecdsa_recover message, signature 43 | assert_equal alice.raw_pubkey, recovered_pubkey 44 | end 45 | 46 | end 47 | -------------------------------------------------------------------------------- /test/discovery/address_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'test_helper' 3 | 4 | class DiscoveryAddressTest < Minitest::Test 5 | include DEVp2p::Discovery 6 | 7 | def test_address 8 | ipv4 = "127.98.19.21" 9 | ipv6 = '5aef:2b::8' 10 | hostname = 'localhost' 11 | port = 1 12 | 13 | a4 = Address.new ipv4, port 14 | aa4 = Address.new ipv4, port 15 | assert_equal aa4, a4 16 | 17 | a6 = Address.new ipv6, port 18 | aa6 = Address.new ipv6, port 19 | assert_equal aa6, a6 20 | 21 | b_a4 = a4.to_endpoint 22 | assert_equal a4, Address.from_endpoint(*b_a4) 23 | 24 | b_a6 = a6.to_endpoint 25 | assert_equal 3, b_a6.size 26 | assert_equal a6, Address.from_endpoint(*b_a6) 27 | 28 | assert_equal 16, b_a6[0].size 29 | assert_equal 4, b_a4[0].size 30 | assert b_a6[1].instance_of?(String) 31 | 32 | host_a = Address.new hostname, port 33 | assert_equal "127.0.0.1", host_a.ip 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /test/discovery/service_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'test_helper' 3 | 4 | class DiscoveryServiceTest < Minitest::Test 5 | include DEVp2p::Discovery 6 | 7 | class AppMock < Struct.new(:config) 8 | end 9 | 10 | class NodeDiscoveryMock 11 | include Concurrent::Async 12 | 13 | # [[to_address, from_address, message], ...] shared between all instances 14 | @@messages = [] 15 | 16 | def self.messages 17 | @@messages 18 | end 19 | 20 | attr :address, :protocol 21 | 22 | def initialize(host, port, seed) 23 | @address = DEVp2p::Discovery::Address.new host, port 24 | 25 | config = { 26 | discovery: { 27 | listen_host: host, 28 | listen_port: port 29 | }, 30 | node: { 31 | privkey_hex: DEVp2p::Utils.encode_hex(DEVp2p::Crypto.keccak256(seed)) 32 | }, 33 | p2p: { 34 | listen_port: port 35 | } 36 | } 37 | 38 | app = AppMock.new 39 | app.config = config 40 | 41 | @protocol = DEVp2p::Discovery::Protocol.new app, self 42 | end 43 | 44 | def send_message(address, message) 45 | raise ArgumentError, 'address must be Address' unless address.instance_of?(DEVp2p::Discovery::Address) 46 | raise ArgumentError, 'address cannot be self' if address == @address 47 | @@messages.push [address, @address, message] 48 | end 49 | 50 | def receive_message(address, message) 51 | raise ArgumentError, 'address must be Address' unless address.instance_of?(DEVp2p::Discovery::Address) 52 | @protocol.receive_message address, message 53 | end 54 | 55 | def poll 56 | @@messages.each_with_index do |(to, from, message), i| 57 | if to == @address 58 | @@messages.delete_at i 59 | receive_message from, message 60 | end 61 | end 62 | end 63 | end 64 | 65 | def test_packing 66 | alice = NodeDiscoveryMock.new('127.0.0.1', 1, 'alice').protocol 67 | bob = NodeDiscoveryMock.new('127.0.0.1', 1, 'bob').protocol 68 | 69 | cmd_id = 3 # find node 70 | payload = ['a', ['b', 'c']] 71 | message = alice.pack(cmd_id, payload) 72 | 73 | r_pubkey, r_cmd_id, r_payload, mdc = bob.unpack message 74 | assert_equal cmd_id, r_cmd_id 75 | assert_equal payload, r_payload 76 | assert_equal alice.pubkey, r_pubkey 77 | end 78 | 79 | def test_ping_pong 80 | alice = NodeDiscoveryMock.new('127.0.0.1', 1, 'alice') 81 | bob = NodeDiscoveryMock.new('127.0.0.1', 2, 'bob') 82 | 83 | bob_node = alice.protocol.get_node bob.protocol.pubkey, bob.address 84 | ivget(alice.protocol, :@kademlia).ping bob_node 85 | sleep 0.1 86 | assert_equal 1, NodeDiscoveryMock.messages.size 87 | 88 | msg = NodeDiscoveryMock.messages[0][2] 89 | remote_pubkey, cmd_id, payload, mdc = bob.protocol.unpack(msg) 90 | assert_equal alice.protocol.class::CMD_ID_MAP[:ping], cmd_id 91 | 92 | bob.poll 93 | sleep 0.1 94 | assert_equal 1, NodeDiscoveryMock.messages.size 95 | 96 | alice.poll 97 | assert_equal 0, NodeDiscoveryMock.messages.size 98 | end 99 | 100 | EIP8_PACKETS = { 101 | # ping packet with version 4, additional list elements 102 | ping1: DEVp2p::Utils.decode_hex( 103 | "e9614ccfd9fc3e74360018522d30e1419a143407ffcce748de3e22116b7e8dc92ff74788c0b6663a"+ 104 | "aa3d67d641936511c8f8d6ad8698b820a7cf9e1be7155e9a241f556658c55428ec0563514365799a"+ 105 | "4be2be5a685a80971ddcfa80cb422cdd0101ec04cb847f000001820cfa8215a8d790000000000000"+ 106 | "000000000000000000018208ae820d058443b9a3550102" 107 | ), 108 | 109 | # ping packet with version 555, additional list elements and additional random data 110 | ping2: DEVp2p::Utils.decode_hex( 111 | "577be4349c4dd26768081f58de4c6f375a7a22f3f7adda654d1428637412c3d7fe917cadc56d4e5e"+ 112 | "7ffae1dbe3efffb9849feb71b262de37977e7c7a44e677295680e9e38ab26bee2fcbae207fba3ff3"+ 113 | "d74069a50b902a82c9903ed37cc993c50001f83e82022bd79020010db83c4d001500000000abcdef"+ 114 | "12820cfa8215a8d79020010db885a308d313198a2e037073488208ae82823a8443b9a355c5010203"+ 115 | "040531b9019afde696e582a78fa8d95ea13ce3297d4afb8ba6433e4154caa5ac6431af1b80ba7602"+ 116 | "3fa4090c408f6b4bc3701562c031041d4702971d102c9ab7fa5eed4cd6bab8f7af956f7d565ee191"+ 117 | "7084a95398b6a21eac920fe3dd1345ec0a7ef39367ee69ddf092cbfe5b93e5e568ebc491983c09c7"+ 118 | "6d922dc3" 119 | ), 120 | 121 | # pong packet with additional list elements and additional random data 122 | pong: DEVp2p::Utils.decode_hex( 123 | "09b2428d83348d27cdf7064ad9024f526cebc19e4958f0fdad87c15eb598dd61d08423e0bf66b206"+ 124 | "9869e1724125f820d851c136684082774f870e614d95a2855d000f05d1648b2d5945470bc187c2d2"+ 125 | "216fbe870f43ed0909009882e176a46b0102f846d79020010db885a308d313198a2e037073488208"+ 126 | "ae82823aa0fbc914b16819237dcd8801d7e53f69e9719adecb3cc0e790c57e91ca4461c9548443b9"+ 127 | "a355c6010203c2040506a0c969a58f6f9095004c0177a6b47f451530cab38966a25cca5cb58f0555"+ 128 | "42124e" 129 | ), 130 | 131 | # findnode packet with additional list elements and additional random data 132 | findnode: DEVp2p::Utils.decode_hex( 133 | "c7c44041b9f7c7e41934417ebac9a8e1a4c6298f74553f2fcfdcae6ed6fe53163eb3d2b52e39fe91"+ 134 | "831b8a927bf4fc222c3902202027e5e9eb812195f95d20061ef5cd31d502e47ecb61183f74a504fe"+ 135 | "04c51e73df81f25c4d506b26db4517490103f84eb840ca634cae0d49acb401d8a4c6b6fe8c55b70d"+ 136 | "115bf400769cc1400f3258cd31387574077f301b421bc84df7266c44e9e6d569fc56be0081290476"+ 137 | "7bf5ccd1fc7f8443b9a35582999983999999280dc62cc8255c73471e0a61da0c89acdc0e035e260a"+ 138 | "dd7fc0c04ad9ebf3919644c91cb247affc82b69bd2ca235c71eab8e49737c937a2c396" 139 | ), 140 | 141 | # neighbours packet with additional list elements and additional random data 142 | neighbours: DEVp2p::Utils.decode_hex( 143 | "c679fc8fe0b8b12f06577f2e802d34f6fa257e6137a995f6f4cbfc9ee50ed3710faf6e66f932c4c8"+ 144 | "d81d64343f429651328758b47d3dbc02c4042f0fff6946a50f4a49037a72bb550f3a7872363a83e1"+ 145 | "b9ee6469856c24eb4ef80b7535bcf99c0004f9015bf90150f84d846321163782115c82115db84031"+ 146 | "55e1427f85f10a5c9a7755877748041af1bcd8d474ec065eb33df57a97babf54bfd2103575fa8291"+ 147 | "15d224c523596b401065a97f74010610fce76382c0bf32f84984010203040101b840312c55512422"+ 148 | "cf9b8a4097e9a6ad79402e87a15ae909a4bfefa22398f03d20951933beea1e4dfa6f968212385e82"+ 149 | "9f04c2d314fc2d4e255e0d3bc08792b069dbf8599020010db83c4d001500000000abcdef12820d05"+ 150 | "820d05b84038643200b172dcfef857492156971f0e6aa2c538d8b74010f8e140811d53b98c765dd2"+ 151 | "d96126051913f44582e8c199ad7c6d6819e9a56483f637feaac9448aacf8599020010db885a308d3"+ 152 | "13198a2e037073488203e78203e8b8408dcab8618c3253b558d459da53bd8fa68935a719aff8b811"+ 153 | "197101a4b2b47dd2d47295286fc00cc081bb542d760717d1bdd6bec2c37cd72eca367d6dd3b9df73"+ 154 | "8443b9a355010203b525a138aa34383fec3d2719a0" 155 | ) 156 | }.freeze 157 | 158 | def test_eip8_packets 159 | disc = NodeDiscoveryMock.new('127.0.0.1', 1, 'bob').protocol 160 | from = Address.new '127.0.0.1', 9999 161 | EIP8_PACKETS.each_value do |packet| 162 | disc.unpack packet 163 | end 164 | end 165 | 166 | ################### test with real UDP ################### 167 | 168 | def test_ping_pong_udp 169 | alice_app = get_app 30000, 'alice' 170 | alice_app.start 171 | alice_discovery = alice_app.services.discovery 172 | 173 | bob_app = get_app 30001, 'bob' 174 | bob_app.start 175 | bob_discovery = bob_app.services.discovery 176 | 177 | sleep 0.2 178 | 179 | bob_node = alice_discovery.protocol.get_node(bob_discovery.protocol.pubkey, bob_discovery.address) 180 | assert !ivget(alice_discovery.protocol, :@kademlia).routing.include?(bob_node) 181 | 182 | ivget(alice_discovery.protocol, :@kademlia).ping bob_node 183 | assert !ivget(alice_discovery.protocol, :@kademlia).routing.include?(bob_node) 184 | 185 | sleep 0.2 186 | assert ivget(alice_discovery.protocol, :@kademlia).routing.include?(bob_node) 187 | 188 | bob_app.stop 189 | alice_app.stop 190 | end 191 | 192 | def test_bootstrap_udp 193 | # set timeout to something more tolerant 194 | DEVp2p::Kademlia.const_set :REQUEST_TIMEOUT, 10000.0 195 | 196 | # startup num_apps udp server and node applications 197 | num_apps = 6 198 | apps = num_apps.times.map do |i| 199 | app = get_app 30002 + i, "app#{i}" 200 | app.start 201 | app 202 | end 203 | 204 | sleep 0.2 205 | 206 | boot_node = get_app_node apps[0] 207 | assert boot_node.address 208 | 209 | sleep_delay = 0.2 # we need to wait for the packets to be delivered 210 | apps[1..-1].each do |app| 211 | #puts "test bootstrap from=#{get_app_node(app)} to=#{boot_node}" 212 | get_app_kademlia(app).bootstrap [boot_node] 213 | sleep sleep_delay 214 | end 215 | 216 | apps[1..-1].each do |app| 217 | #puts "test find_node from=#{get_app_node(app)}" 218 | get_app_kademlia(app).find_node get_app_node(app).id 219 | sleep sleep_delay 220 | end 221 | 222 | # now all nodes should know each other 223 | apps.each_with_index do |app, i| 224 | num = get_app_kademlia(app).routing.size 225 | assert num >= num_apps - 1 226 | end 227 | 228 | sleep 0.5 229 | apps.each(&:stop) 230 | end 231 | 232 | private 233 | 234 | def get_app(port, seed) 235 | config = { 236 | discovery: { 237 | listen_host: '127.0.0.1', 238 | listen_port: port, 239 | bootstrap_nodes: [] 240 | }, 241 | node: { 242 | privkey_hex: DEVp2p::Utils.encode_hex(DEVp2p::Crypto.keccak256(seed)), 243 | }, 244 | p2p: { 245 | listen_port: port 246 | } 247 | } 248 | 249 | DEVp2p::App.new(config).tap do |app| 250 | Service.register_with_app app 251 | end 252 | end 253 | 254 | def get_app_kademlia(app) 255 | ivget(app.services.discovery.protocol, :@kademlia) 256 | end 257 | 258 | def get_app_node(app) 259 | get_app_kademlia(app).node 260 | end 261 | 262 | end 263 | -------------------------------------------------------------------------------- /test/example.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # Object with the information to update a decentralized counter. 3 | # 4 | class Token 5 | include RLP::Sedes::Serializable 6 | set_serializable_fields( 7 | counter: RLP::Sedes.big_endian_int, 8 | sender: RLP::Sedes.binary 9 | ) 10 | 11 | def full_hash 12 | DEVp2p::Crypto.keccak256 RLP.encode(self) 13 | end 14 | 15 | def to_s 16 | "<#{self.class.name}(counter=#{counter} full_hash=#{Utils.encode_hex(full_hash)[0,8]})>" 17 | rescue 18 | "<#{self.class.name}>" 19 | end 20 | end 21 | 22 | class ExampleProtocol < DEVp2p::BaseProtocol 23 | protocol_id 1 24 | #network_id 0 25 | max_cmd_id 1 26 | 27 | name 'example' 28 | version 1 29 | 30 | ## 31 | # message sending a token and a nonce 32 | # 33 | class Token < DEVp2p::Command 34 | cmd_id 0 35 | structure( 36 | token: ::Token 37 | ) 38 | end 39 | 40 | def initialize(peer, service) 41 | @config = peer.config 42 | super(peer, service) 43 | end 44 | 45 | end 46 | 47 | class DuplicateFilter 48 | def initialize(max_items=1024) 49 | @max_items = max_items 50 | @filter = [] 51 | end 52 | 53 | def update(data) 54 | if @filter.include?(data) 55 | @filter.push @filter.shift 56 | return false 57 | else 58 | @filter.push data 59 | if @filter.size > @max_items 60 | @filter.shift 61 | end 62 | return true 63 | end 64 | end 65 | 66 | def include?(v) 67 | @filter.include?(v) 68 | end 69 | end 70 | 71 | class ExampleService < DEVp2p::WiredService 72 | name 'exampleservice' 73 | default_config( 74 | example: { 75 | num_participants: 1 76 | } 77 | ) 78 | 79 | def initialize(app) 80 | @config = app.config 81 | @address = DEVp2p::Crypto.privtopub DEVp2p::Utils.decode_hex(@config[:node][:privkey_hex]) 82 | 83 | self.wire_protocol = ExampleProtocol 84 | 85 | super(app) 86 | end 87 | 88 | def start 89 | super 90 | end 91 | 92 | def _run 93 | loop do 94 | break if stopped? 95 | sleep 1 96 | end 97 | end 98 | 99 | def broadcast(obj, origin=nil) 100 | fmap = {Token: 'token'} 101 | logger.debug "broadcasting", obj: obj 102 | 103 | exclude_peers = origin ? [origin.peer] : [] 104 | app.services.peermanager.broadcast ExampleProtocol, fmap[obj.class], [obj], {}, nil, exclude_peers 105 | end 106 | 107 | def on_wire_protocol_stop(proto) 108 | logger.debug "=======================================" 109 | logger.debug "on_wire_protocol_stop", proto: proto 110 | end 111 | 112 | def on_wire_protocol_start(proto) 113 | logger.debug "=======================================" 114 | logger.debug "on_wire_protocol_start", proto: proto, peers: app.services.peermanager.peers 115 | 116 | on_receive_token = ->(proto, token) { 117 | logger.debug "=======================================" 118 | logger.debug "on_receive token", token: token, proto: proto 119 | send_token 120 | } 121 | 122 | proto.receive_token_callbacks.push on_receive_token 123 | send_token 124 | end 125 | 126 | def send_token 127 | sleep rand 128 | token = Token.new SecureRandom.random_number(1025), @address 129 | 130 | logger.debug "=======================================" 131 | logger.debug "sending token", token: token 132 | broadcast token 133 | end 134 | 135 | private 136 | 137 | def logger 138 | @logger ||= DEVp2p::Logger.new 'Example' 139 | end 140 | 141 | end 142 | 143 | class ExampleApp < DEVp2p::BaseApp 144 | default_config( 145 | client_version_string: "exampleapp/v0.1/#{RUBY_PLATFORM}/ruby#{RUBY_VERSION}", 146 | deactivated_services: [], 147 | post_app_start_callback: nil 148 | ) 149 | end 150 | -------------------------------------------------------------------------------- /test/geth_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'test_helper' 3 | 4 | class GethTest < Minitest::Test 5 | include DEVp2p 6 | 7 | ## 8 | # go client started with: 9 | # 10 | # ethereum -port="40404" -loglevel=5 -nodekeyhex="9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658" -bootnodes="enode://2da47499d52d9161a778e4c711e22e8651cb90350ec066452f9516d1d11eb465d1ec42bb27ec6cd4488b8b6a1a411cb5ef83c16cbb8bee194624bb65fef0f7fd@127.0.0.1:30303" 11 | def test_go_sig 12 | r_pubkey = Utils.decode_hex "ab16b8c7fc1febb74ceedf1349944ffd4a04d11802451d02e808f08cb3b0c1c1a9c4e1efb7d309a762baa4c9c8da08890b3b712d1666b5b630d6c6a09cbba171" 13 | d = { 14 | signed_data: 'a061e5b799b5bb3a3a68a7eab6ee11207d90672e796510ac455e985bd206e240', 15 | cmd: 'find_node', 16 | body: '03f847b840ab16b8c7fc1febb74ceedf1349944ffd4a04d11802451d02e808f08cb3b0c1c1a9c4e1efb7d309a762baa4c9c8da08890b3b712d1666b5b630d6c6a09cbba1718454e869b1', 17 | signature: '0de032c62e30f4a9f9f07f25ac5377c5a531116147617a6c08f946c97991f351577e53ae138210bdb7447bab53f3398d746d42c64a9ce67a6248e59353f1bc6e01' 18 | } 19 | 20 | priv_seed = 'test' 21 | priv_key = Crypto.mk_privkey priv_seed 22 | assert_equal Utils.decode_hex("9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658"), priv_key 23 | 24 | pub_key = Crypto.privtopub priv_key 25 | assert_equal r_pubkey, pub_key 26 | 27 | go_body = Utils.decode_hex d[:body] 28 | target_node_id, expiry = RLP.decode(go_body[1..-1]) 29 | assert_equal r_pubkey, target_node_id # lookup for itself 30 | 31 | go_signed_data = Utils.decode_hex d[:signed_data] 32 | go_signature = Utils.decode_hex d[:signature] 33 | 34 | my_signature = Crypto.ecdsa_sign go_signed_data, priv_key 35 | assert_equal my_signature, Crypto.ecdsa_sign(go_signed_data, priv_key) # deterministic k 36 | 37 | assert_equal 32, go_signed_data.size 38 | assert_equal 65, go_signature.size 39 | assert_equal 65, my_signature.size 40 | assert go_signature != my_signature # because go_signature is not generated with deterministic k 41 | 42 | assert_equal pub_key, Crypto.ecdsa_recover(go_signed_data, my_signature) 43 | 44 | # TODO: can be removed? 45 | # 46 | # problem we can not decode the pubkey from the go signature and go can 47 | # not decode ours 48 | Crypto.ecdsa_recover go_signed_data, go_signature 49 | end 50 | 51 | TEST_VALUES = { 52 | initiator_private_key: "5e173f6ac3c669587538e7727cf19b782a4f2fda07c1eaa662c593e5e85e3051", 53 | receiver_private_key: "c45f950382d542169ea207959ee0220ec1491755abe405cd7498d6b16adb6df8", 54 | initiator_ephemeral_private_key: "19c2185f4f40634926ebed3af09070ca9e029f2edd5fae6253074896205f5f6c", 55 | receiver_ephemeral_private_key: "d25688cf0ab10afa1a0e2dba7853ed5f1e5bf1c631757ed4e103b593ff3f5620", 56 | auth_plaintext: "884c36f7ae6b406637c1f61b2f57e1d2cab813d24c6559aaf843c3f48962f32f46662c066d39669b7b2e3ba14781477417600e7728399278b1b5d801a519aa570034fdb5419558137e0d44cd13d319afe5629eeccb47fd9dfe55cc6089426e46cc762dd8a0636e07a54b31169eba0c7a20a1ac1ef68596f1f283b5c676bae4064abfcce24799d09f67e392632d3ffdc12e3d6430dcb0ea19c318343ffa7aae74d4cd26fecb93657d1cd9e9eaf4f8be720b56dd1d39f190c4e1c6b7ec66f077bb1100", 57 | authresp_plaintext: "802b052f8b066640bba94a4fc39d63815c377fced6fcb84d27f791c9921ddf3e9bf0108e298f490812847109cbd778fae393e80323fd643209841a3b7f110397f37ec61d84cea03dcc5e8385db93248584e8af4b4d1c832d8c7453c0089687a700", 58 | auth_ciphertext: "04a0274c5951e32132e7f088c9bdfdc76c9d91f0dc6078e848f8e3361193dbdc43b94351ea3d89e4ff33ddcefbc80070498824857f499656c4f79bbd97b6c51a514251d69fd1785ef8764bd1d262a883f780964cce6a14ff206daf1206aa073a2d35ce2697ebf3514225bef186631b2fd2316a4b7bcdefec8d75a1025ba2c5404a34e7795e1dd4bc01c6113ece07b0df13b69d3ba654a36e35e69ff9d482d88d2f0228e7d96fe11dccbb465a1831c7d4ad3a026924b182fc2bdfe016a6944312021da5cc459713b13b86a686cf34d6fe6615020e4acf26bf0d5b7579ba813e7723eb95b3cef9942f01a58bd61baee7c9bdd438956b426a4ffe238e61746a8c93d5e10680617c82e48d706ac4953f5e1c4c4f7d013c87d34a06626f498f34576dc017fdd3d581e83cfd26cf125b6d2bda1f1d56", 59 | authresp_ciphertext: "049934a7b2d7f9af8fd9db941d9da281ac9381b5740e1f64f7092f3588d4f87f5ce55191a6653e5e80c1c5dd538169aa123e70dc6ffc5af1827e546c0e958e42dad355bcc1fcb9cdf2cf47ff524d2ad98cbf275e661bf4cf00960e74b5956b799771334f426df007350b46049adb21a6e78ab1408d5e6ccde6fb5e69f0f4c92bb9c725c02f99fa72b9cdc8dd53cff089e0e73317f61cc5abf6152513cb7d833f09d2851603919bf0fbe44d79a09245c6e8338eb502083dc84b846f2fee1cc310d2cc8b1b9334728f97220bb799376233e113", 60 | ecdhe_shared_secret: "e3f407f83fc012470c26a93fdff534100f2c6f736439ce0ca90e9914f7d1c381", 61 | initiator_nonce: "cd26fecb93657d1cd9e9eaf4f8be720b56dd1d39f190c4e1c6b7ec66f077bb11", 62 | receiver_nonce: "f37ec61d84cea03dcc5e8385db93248584e8af4b4d1c832d8c7453c0089687a7", 63 | aes_secret: "c0458fa97a5230830e05f4f20b7c755c1d4e54b1ce5cf43260bb191eef4e418d", 64 | mac_secret: "48c938884d5067a1598272fcddaa4b833cd5e7d92e8228c0ecdfabbe68aef7f1", 65 | token: "3f9ec2592d1554852b1f54d228f042ed0a9310ea86d038dc2b401ba8cd7fdac4", 66 | initial_egress_MAC: "09771e93b1a6109e97074cbe2d2b0cf3d3878efafe68f53c41bb60c0ec49097e", 67 | initial_ingress_MAC: "75823d96e23136c89666ee025fb21a432be906512b3dd4a3049e898adb433847", 68 | initiator_hello_packet: "6ef23fcf1cec7312df623f9ae701e63b550cdb8517fefd8dd398fc2acd1d935e6e0434a2b96769078477637347b7b01924fff9ff1c06df2f804df3b0402bbb9f87365b3c6856b45e1e2b6470986813c3816a71bff9d69dd297a5dbd935ab578f6e5d7e93e4506a44f307c332d95e8a4b102585fd8ef9fc9e3e055537a5cec2e9", 69 | receiver_hello_packet: "6ef23fcf1cec7312df623f9ae701e63be36a1cdd1b19179146019984f3625d4a6e0434a2b96769050577657247b7b02bc6c314470eca7e3ef650b98c83e9d7dd4830b3f718ff562349aead2530a8d28a8484604f92e5fced2c6183f304344ab0e7c301a0c05559f4c25db65e36820b4b909a226171a60ac6cb7beea09376d6d8" 70 | } 71 | 72 | TEST_VALUES.each do |k, v| 73 | TEST_VALUES[k] = Utils.decode_hex v 74 | end 75 | 76 | KEYS = [ 77 | :initiator_private_key, 78 | :receiver_private_key, 79 | :initiator_ephemeral_private_key, 80 | :receiver_ephemeral_private_key, 81 | :initiator_nonce, 82 | :receiver_nonce, 83 | # auth 84 | :auth_plaintext, 85 | :auth_ciphertext, 86 | # auth response 87 | :authresp_plaintext, 88 | :authresp_ciphertext, 89 | # on ack receive 90 | :ecdhe_shared_secret, 91 | :aes_secret, 92 | :mac_secret, 93 | :token, 94 | :initial_egress_MAC, 95 | :initial_ingress_MAC, 96 | # messages 97 | :initiator_hello_packet, 98 | :receiver_hello_packet 99 | ] 100 | 101 | def test_keys 102 | assert_equal TEST_VALUES.keys.sort, KEYS.sort 103 | end 104 | 105 | def test_ecies_decrypt 106 | e = Crypto::ECCx.new TEST_VALUES[:receiver_private_key] 107 | pt = e.ecies_decrypt TEST_VALUES[:auth_ciphertext] 108 | assert_equal TEST_VALUES[:auth_plaintext], pt 109 | end 110 | 111 | def test_handshake 112 | tv = TEST_VALUES 113 | 114 | initiator = RLPxSession.new Crypto::ECCx.new(tv[:initiator_private_key]), true, tv[:initiator_ephemeral_private_key] 115 | initiator_pubkey = initiator.ecc.raw_pubkey 116 | responder = RLPxSession.new Crypto::ECCx.new(tv[:receiver_private_key]), false, tv[:receiver_ephemeral_private_key] 117 | responder_pubkey = responder.ecc.raw_pubkey 118 | 119 | # test encryption 120 | ct = initiator.encrypt_auth_message tv[:auth_plaintext], responder_pubkey 121 | assert_equal tv[:auth_ciphertext].size, ct.size 122 | assert_equal 113 + tv[:auth_plaintext].size, ct.size 123 | 124 | # test auth_msg plain 125 | auth_msg = initiator.create_auth_message responder_pubkey, tv[:initiator_ephemeral_private_key], tv[:initiator_nonce] 126 | assert_equal tv[:auth_plaintext].size, auth_msg.size 127 | assert_equal tv[:auth_plaintext][65..-1], auth_msg[65..-1] 128 | 129 | auth_msg_cipher = initiator.encrypt_auth_message auth_msg, responder_pubkey 130 | 131 | # test shared 132 | assert_equal responder.ecc.get_ecdh_key(initiator_pubkey), initiator.ecc.get_ecdh_key(responder_pubkey) 133 | 134 | # test decrypt 135 | assert_equal auth_msg, responder.ecc.ecies_decrypt(auth_msg_cipher) 136 | 137 | # check receive 138 | responder_ephemeral_pubkey = Crypto.privtopub tv[:receiver_ephemeral_private_key] 139 | auth_msg_cipher = tv[:auth_ciphertext] 140 | auth_msg = responder.ecc.ecies_decrypt auth_msg_cipher 141 | assert_equal tv[:auth_plaintext][65..-1], auth_msg[65..-1] 142 | 143 | responder.decode_authentication auth_msg_cipher 144 | auth_ack_msg = responder.create_auth_ack_message(responder_ephemeral_pubkey, tv[:receiver_nonce]) 145 | assert_equal tv[:authresp_plaintext], auth_ack_msg 146 | auth_ack_msg_cipher = responder.encrypt_auth_ack_message(auth_ack_msg, responder.remote_pubkey) 147 | 148 | # set auth ack msg cipher (needed later for mac calculation) 149 | responder.instance_variable_set :@auth_ack, tv[:authresp_ciphertext] 150 | 151 | responder.setup_cipher 152 | assert_equal tv[:ecdhe_shared_secret], ivget(responder, :@ecdhe_shared_secret) 153 | assert_equal tv[:token], ivget(responder, :@token) 154 | assert_equal tv[:aes_secret], ivget(responder, :@aes_secret) 155 | assert_equal tv[:mac_secret], ivget(responder, :@mac_secret) 156 | assert_equal tv[:initiator_nonce], ivget(responder, :@initiator_nonce) 157 | assert_equal tv[:receiver_nonce], ivget(responder, :@responder_nonce) 158 | assert_equal tv[:auth_ciphertext], ivget(responder, :@auth_init) 159 | assert_equal tv[:authresp_ciphertext], ivget(responder, :@auth_ack) 160 | assert_equal tv[:initial_egress_MAC], responder.ingress_mac 161 | assert_equal tv[:initial_ingress_MAC], responder.egress_mac 162 | 163 | r = responder.decrypt tv[:initiator_hello_packet] 164 | header = r[:header] 165 | frame_length = "\x00#{header[0,3]}".unpack('I>')[0] 166 | 167 | header_sedes = RLP::Sedes::List.new( 168 | elements: [ 169 | RLP::Sedes.big_endian_int, 170 | RLP::Sedes.big_endian_int 171 | ] 172 | ) 173 | header_data = RLP.decode header[3..-1], strict: false, sedes: header_sedes 174 | 175 | frame = r[:frame] 176 | packet_type, pos_end = RLP.consume_item frame, 0 177 | packet_type = RLP.decode frame, sedes: RLP::Sedes.big_endian_int, strict: false 178 | 179 | capabilities = RLP::Sedes::List.new( 180 | elements: [ 181 | RLP::Sedes.binary, 182 | RLP::Sedes.big_endian_int 183 | ] 184 | ) 185 | 186 | structure = { 187 | version: RLP::Sedes.big_endian_int, 188 | client_version_string: RLP::Sedes.big_endian_int, 189 | capabilities: RLP::Sedes::CountableList.new(capabilities), 190 | listen_port: RLP::Sedes.big_endian_int, 191 | remote_pubkey: RLP::Sedes.binary 192 | } 193 | 194 | hello_sedes = RLP::Sedes::List.new elements: structure.values 195 | frame_data = RLP.decode frame[pos_end..-1], sedes: hello_sedes 196 | frame_data = frame_data.each_with_index.map {|x, i| [structure.keys[i], x] }.to_h 197 | end 198 | 199 | end 200 | -------------------------------------------------------------------------------- /test/kademlia_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'test_helper' 3 | 4 | require 'set' 5 | 6 | class KademliaTest < Minitest::Test 7 | include DEVp2p 8 | 9 | def test_routing_table 10 | routing_table 1000 11 | end 12 | 13 | def test_split 14 | node = random_node 15 | routing = Kademlia::RoutingTable.new node 16 | assert_equal 1, routing.buckets_count 17 | 18 | # create very close nodes 19 | Kademlia::K.times do |i| 20 | node = fake_node_from_id node.id + 1 21 | assert ivget(routing, :@buckets)[0].in_range?(node) 22 | 23 | routing.add node 24 | assert_equal 1, routing.buckets_count 25 | end 26 | 27 | assert_equal Kademlia::K, ivget(routing, :@buckets)[0].size 28 | 29 | node = fake_node_from_id node.id + 1 30 | assert ivget(routing, :@buckets)[0].in_range?(node) 31 | 32 | routing.add node 33 | assert ivget(routing, :@buckets)[0].size <= Kademlia::K 34 | assert routing.buckets_count <= 512 35 | end 36 | 37 | def test_split2 38 | routing = routing_table(10000) 39 | 40 | full_buckets = ivget(routing, :@buckets).select {|b| b.full? } 41 | split_buckets = ivget(routing, :@buckets).select {|b| b.should_split? } 42 | assert split_buckets.size < full_buckets.size 43 | assert Set.new(split_buckets).subset?(Set.new(full_buckets)) 44 | 45 | bucket = full_buckets[0] 46 | assert !bucket.should_split? 47 | assert Kademlia::K, bucket.size 48 | 49 | node = fake_node_from_id bucket.left+1 50 | assert !bucket.include?(node) 51 | assert bucket.in_range?(node) 52 | assert_equal bucket, routing.bucket_by_node(node) 53 | 54 | r = bucket.add node 55 | assert r 56 | end 57 | 58 | def test_non_overlap 59 | routing = routing_table 1000 60 | 61 | max_id = 0 62 | ivget(routing, :@buckets).each_with_index do |b, i| 63 | assert b.left > max_id if i > 0 64 | assert b.right > max_id 65 | max_id = b.right 66 | assert_equal 2**Kademlia::ID_SIZE - 1, b.right if i == routing.buckets_count - 1 67 | end 68 | end 69 | 70 | def test_full_range 71 | [1, 16, 17, 1000].each do |num_nodes| 72 | routing = routing_table num_nodes 73 | 74 | max_id = 0 75 | ivget(routing, :@buckets).each_with_index do |b, i| 76 | assert_equal max_id, b.left 77 | assert b.right > max_id 78 | max_id = b.right + 1 79 | assert_equal 2**Kademlia::ID_SIZE - 1, b.right if i == routing.buckets_count - 1 80 | end 81 | end 82 | end 83 | 84 | def test_neighbours 85 | routing = routing_table 1000 86 | 87 | 1000.times do |i| 88 | node = random_node 89 | 90 | nearest_bucket = routing.buckets_by_distance(node)[0] 91 | next if nearest_bucket.empty? 92 | 93 | node_a = ivget(nearest_bucket, :@nodes)[0] 94 | node_b = fake_node_from_id node_a.id+1 95 | assert_equal node_a, routing.neighbours(node_b)[0] 96 | 97 | node_b = fake_node_from_id node_a.id-1 98 | assert_equal node_a, routing.neighbours(node_b)[0] 99 | end 100 | end 101 | 102 | def test_setup 103 | # nodes connect to any peer and do a lookup for themselves 104 | proto = get_wired_protocol 105 | wire = proto.wire 106 | other = routing_table 107 | 108 | # lookup self 109 | proto.bootstrap [other.node] 110 | msg = wire.poll other.node 111 | 112 | assert_equal [:find_node, proto.routing.node, proto.routing.node.id], msg 113 | assert_equal nil, wire.poll(other.node) 114 | assert_equal [], wire.messages 115 | 116 | # respond with neighbours 117 | closest = other.neighbours(msg[2]) 118 | assert_equal Kademlia::K, closest.size 119 | proto.recv_neighbours random_node, closest 120 | 121 | # expect A=3 lookups 122 | Kademlia::A.times do |i| 123 | msg = wire.poll closest[i] 124 | assert_equal [:find_node, proto.routing.node, proto.routing.node.id], msg 125 | end 126 | 127 | # and pings for all nodes 128 | closest.each do |node| 129 | msg = wire.poll node 130 | assert_equal :ping, msg[0] 131 | end 132 | 133 | # nothing else 134 | assert_equal [], wire.messages 135 | end 136 | 137 | def test_eviction 138 | proto = get_wired_protocol 139 | proto.instance_variable_set :@routing, routing_table 140 | wire = proto.wire 141 | 142 | node = proto.routing.neighbours(random_node)[0] 143 | proto.ping node 144 | msg = wire.poll(node) 145 | assert_equal :ping, msg[0] 146 | assert_equal [], wire.messages 147 | 148 | proto.recv_pong node, msg[2] 149 | assert_equal [], wire.messages 150 | assert proto.routing.include?(node) 151 | 152 | assert_equal node, proto.routing.bucket_by_node(node).tail 153 | end 154 | 155 | def test_eviction_node_active 156 | proto = get_wired_protocol 157 | routing = routing_table 10000 # set high, so add won't split 158 | proto.instance_variable_set :@routing, routing 159 | wire = proto.wire 160 | 161 | # get a full bucket 162 | full_buckets = ivget(routing, :@buckets).select {|b| b.full? && !b.should_split? } 163 | bucket = full_buckets[0] 164 | assert !bucket.should_split? 165 | assert_equal Kademlia::K, bucket.size 166 | 167 | bucket_nodes = bucket.to_a # bucket nodes copy 168 | eviction_candidate = bucket.head 169 | 170 | # create node to insert 171 | node = random_node 172 | node.instance_variable_set :@id, bucket.left+1 173 | assert bucket.in_range?(node) 174 | assert_equal bucket, routing.bucket_by_node(node) 175 | 176 | # insert node 177 | proto.update node 178 | 179 | # expect bucket not split 180 | assert Kademlia::K, bucket.size 181 | assert_equal bucket_nodes, bucket.to_a 182 | assert_equal eviction_candidate, bucket.head 183 | 184 | # expect node not to be in bucket yet 185 | assert !bucket.include?(node) 186 | assert !routing.include?(node) 187 | 188 | # expect a ping to bucket.head 189 | msg = wire.poll(eviction_candidate) 190 | assert_equal :ping, msg[0] 191 | assert_equal proto.node, msg[1] 192 | assert_equal 1, ivget(proto, :@expected_pongs).size 193 | 194 | expected_pingid = ivget(proto, :@expected_pongs).keys[0] 195 | assert_equal 96, expected_pingid.size 196 | 197 | echo = expected_pingid[0,32] 198 | assert_equal 32, echo.size 199 | assert_equal [], wire.messages 200 | 201 | # reply late 202 | sleep Kademlia::REQUEST_TIMEOUT 203 | proto.recv_pong eviction_candidate, echo 204 | 205 | # expect no other messages 206 | assert_equal [], wire.messages 207 | 208 | # expect node be added 209 | assert routing.include?(node) 210 | assert !routing.include?(eviction_candidate) 211 | assert_equal node, bucket.tail 212 | assert !ivget(bucket, :@replacement_cache).include?(eviction_candidate) 213 | end 214 | 215 | def test_eviction_node_split 216 | proto = get_wired_protocol 217 | routing = routing_table 1000 # set low, so we'll split 218 | proto.instance_variable_set :@routing, routing 219 | wire = proto.wire 220 | 221 | full_buckets = ivget(routing, :@buckets).select {|b| b.full? && b.should_split? } 222 | bucket = full_buckets[0] 223 | assert bucket.should_split? 224 | assert Kademlia::K, bucket.size 225 | 226 | bucket_nodes = bucket.to_a 227 | eviction_candidate = bucket.head 228 | 229 | node = random_node 230 | node.instance_variable_set :@id, bucket.left+1 231 | assert bucket.in_range?(node) 232 | assert_equal bucket, routing.bucket_by_node(node) 233 | 234 | proto.update node 235 | 236 | # bucket is splitted to two new bucket, but itself is not changed 237 | assert_equal bucket_nodes, bucket.to_a 238 | assert_equal eviction_candidate, bucket.head 239 | 240 | assert !bucket.include?(node) 241 | assert routing.include?(node) 242 | 243 | assert !wire.poll(eviction_candidate) 244 | assert_equal [], wire.messages 245 | 246 | assert routing.include?(node) 247 | assert_equal eviction_candidate, bucket.head 248 | end 249 | 250 | def test_ping_adds_sender 251 | proto = get_wired_protocol 252 | assert_equal 0, proto.routing.size 253 | 254 | 10.times do |i| 255 | proto.recv_ping random_node, "some id #{i}" 256 | assert_equal i+1, proto.routing.size 257 | end 258 | ensure 259 | WireMock.reset 260 | end 261 | 262 | def test_two 263 | one = get_wired_protocol 264 | one.instance_variable_set :@routing, routing_table(100) 265 | two = get_wired_protocol 266 | wire = one.wire 267 | assert two.node != one.node 268 | 269 | two.ping one.node 270 | wire.process([one, two]) 271 | 272 | # find :two on :one, because two has only :one in its routing table 273 | two.find_node two.node.id 274 | 275 | # :one replies with K neighbours to :two, because :one has a 100 nodes routing table 276 | # :two forward :find_node to the first A neighbours, then ping all neighbours 277 | wire.process([one, two], 2) 278 | assert wire.messages.size >= Kademlia::K 279 | 280 | msg = wire.messages.shift 281 | assert_equal :find_node, msg[1] 282 | 283 | wire.messages[Kademlia::A..-1].each do |m| 284 | assert_equal :ping, m[1] 285 | end 286 | ensure 287 | WireMock.reset 288 | end 289 | 290 | def test_many 291 | WireMock.reset 292 | 293 | num_nodes = 17 294 | assert num_nodes >= Kademlia::K+1 295 | 296 | protos = [] 297 | num_nodes.times do |i| 298 | protos.push get_wired_protocol 299 | end 300 | 301 | bootstrap = protos[0] 302 | wire = bootstrap.wire 303 | 304 | # bootstrap 305 | # after this bootstrap node has all nodes in its routing table 306 | protos[1..-1].each do |p| 307 | p.bootstrap [bootstrap.node] 308 | wire.process protos 309 | end 310 | 311 | # now everybody does a find node to fill the buckets 312 | protos[1..-1].each do |p| 313 | p.find_node p.node.id # find_node to bootstrap node 314 | wire.process protos # can all send in parallel 315 | end 316 | 317 | protos.each_with_index do |p, i| 318 | assert p.routing.size >= Kademlia::K 319 | end 320 | end 321 | 322 | def test_find_closest 323 | WireMock.reset 324 | 325 | num_tests = 10 326 | num_nodes = 50 327 | 328 | protos = [] 329 | num_nodes.times do |i| 330 | protos.push get_wired_protocol 331 | end 332 | 333 | bootstrap = protos[0] 334 | wire = bootstrap.wire 335 | 336 | # bootstrap 337 | # after this bootstrap node has all nodes in its routing table 338 | protos[1..-1].each do |p| 339 | p.bootstrap [bootstrap.node] 340 | wire.process protos 341 | end 342 | 343 | # now everybody does a find node to fill the buckets 344 | protos[1..-1].each do |p| 345 | p.find_node p.node.id # find_node to bootstrap node 346 | wire.process protos # can all send in parallel 347 | end 348 | 349 | all_nodes = protos.map(&:node) 350 | 351 | protos[0,num_tests].each_with_index do |p, i| 352 | all_nodes.each do |node, j| 353 | next if p.node == node 354 | p.find_node node.id 355 | p.wire.process protos 356 | assert_equal node, p.routing.neighbours(node)[0] 357 | end 358 | end 359 | end 360 | 361 | private 362 | 363 | def random_pubkey 364 | SecureRandom.random_bytes(Kademlia::PUBKEY_SIZE / 8) 365 | end 366 | 367 | def random_node 368 | Kademlia::Node.new random_pubkey 369 | end 370 | 371 | def routing_table(num_nodes=1000) 372 | node = random_node 373 | routing = Kademlia::RoutingTable.new node 374 | 375 | num_nodes.times do |i| 376 | routing.add random_node 377 | assert routing.buckets_count <= i + 2 378 | end 379 | 380 | assert routing.buckets_count <= 512 381 | routing 382 | end 383 | 384 | def fake_node_from_id(id) 385 | random_node.tap do |node| 386 | node.instance_variable_set :@id, id 387 | end 388 | end 389 | 390 | class WireMock < Kademlia::WireInterface 391 | @@messages = [] 392 | 393 | def self.reset 394 | @@messages.clear 395 | end 396 | 397 | def initialize(sender) 398 | raise ArgumentError unless sender.is_a?(DEVp2p::Kademlia::Node) 399 | @sender = sender 400 | raise "messages must be empty" unless @@messages.empty? 401 | end 402 | 403 | def messages 404 | @@messages 405 | end 406 | 407 | def send_ping(node) 408 | echo = SecureRandom.hex(16) 409 | @@messages.push [node, :ping, @sender, echo] 410 | echo 411 | end 412 | 413 | def send_pong(node, echo) 414 | @@messages.push [node, :pong, @sender, echo] 415 | end 416 | 417 | def send_find_node(node, nodeid) 418 | @@messages.push [node, :find_node, @sender, nodeid] 419 | end 420 | 421 | def send_neighbours(node, neighbours) 422 | @@messages.push [node, 'neighbours', @sender, neighbours] 423 | end 424 | 425 | def poll(node) 426 | @@messages.each_with_index do |x, i| 427 | if x[0] == node 428 | @@messages.delete_at i 429 | return x[1..-1] 430 | end 431 | end 432 | 433 | nil 434 | end 435 | 436 | ## 437 | # process messages until none are left or if process steps messages if 438 | # steps > 0 439 | # 440 | def process(kademlia_protocols, steps=0) 441 | i = 0 442 | proto_by_node = kademlia_protocols.map {|p| [p.node, p] }.to_h 443 | 444 | while !@@messages.empty? 445 | msg = @@messages.shift 446 | raise 'expect Node' unless msg[2].is_a?(DEVp2p::Kademlia::Node) 447 | 448 | target = proto_by_node[msg[0]] 449 | cmd = "recv_#{msg[1]}" 450 | target.send cmd, *msg[2..-1] 451 | 452 | i += 1 453 | return if steps > 0 && i == steps 454 | end 455 | 456 | raise 'expect all messages be processed' unless @@messages.empty? 457 | end 458 | end 459 | 460 | def get_wired_protocol 461 | node = random_node 462 | Kademlia::Protocol.new node, WireMock.new(node) 463 | end 464 | 465 | end 466 | -------------------------------------------------------------------------------- /test/multiplexed_session_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'test_helper' 3 | 4 | class MultiplexedSessionTest < Minitest::Test 5 | include DEVp2p 6 | 7 | class PeerMock 8 | attr :config, :capabilities 9 | 10 | def initialize 11 | @config = Hashie::Mash.new( 12 | p2p: {listen_port: 3000}, 13 | node: {id: "\x00"*64 }, 14 | client_version_string: 'devp2p 0.0.0' 15 | ) 16 | @capabilities = [['p2p', 2], ['eth', 57]] 17 | end 18 | 19 | def mock1(x); end 20 | alias :receive_hello :mock1 21 | alias :send_packet :mock1 22 | alias :stop :mock1 23 | end 24 | 25 | def test_session 26 | proto = P2PProtocol.new PeerMock.new, WiredService.new(App.new) 27 | hello_packet = proto.create_hello 28 | p0 = 0 29 | 30 | responder_privkey = Crypto.mk_privkey 'secret1' 31 | responder_pubkey = Crypto.privtopub responder_privkey 32 | responder = MultiplexedSession.new responder_privkey, hello_packet 33 | responder.add_protocol p0 34 | 35 | initiator_privkey = Crypto.mk_privkey 'secret2' 36 | initiator = MultiplexedSession.new initiator_privkey, hello_packet, responder_pubkey 37 | initiator.add_protocol p0 38 | 39 | # send auth 40 | msg = ivget(initiator, :@message_queue).deq(true) 41 | assert msg # <- send_init_msg 42 | assert ivget(initiator,:@packet_queue).empty? 43 | assert !responder.initiator? 44 | 45 | # receive auth 46 | responder.add_message msg 47 | assert ivget(responder, :@packet_queue).empty? 48 | assert responder.ready? 49 | 50 | # send auth ack and hello 51 | ack_msg = ivget(responder, :@message_queue).deq(true) 52 | hello_msg = ivget(responder, :@message_queue).deq(true) 53 | assert hello_msg 54 | 55 | # receive auth ack and hello 56 | initiator.add_message ack_msg + hello_msg 57 | assert initiator.ready? 58 | hello_packet = ivget(initiator, :@packet_queue).deq(true) # from responder 59 | assert hello_packet.instance_of?(Packet) 60 | 61 | # initiator sends hello 62 | hello_msg = ivget(initiator, :@message_queue).deq(true) # from initiator's own 63 | assert hello_msg 64 | 65 | # hello received by responder 66 | responder.add_message hello_msg 67 | hello_packet = ivget(responder, :@packet_queue).deq(true) 68 | assert hello_packet.instance_of?(Packet) 69 | 70 | # assert we received an actual hello packet 71 | data = proto.class::Hello.decode_payload hello_packet.payload 72 | assert_equal 4, data[:version] 73 | 74 | # test normal operation 75 | ping = proto.create_ping 76 | initiator.add_packet ping 77 | msg = ivget(initiator, :@message_queue).deq(true) 78 | 79 | # receive ping 80 | responder.add_message msg 81 | ping_packet = ivget(responder, :@packet_queue).deq(true) 82 | assert ping_packet.instance_of?(Packet) 83 | data = proto.class::Ping.decode_payload ping_packet.payload 84 | 85 | # reply with pong 86 | pong = proto.create_pong 87 | responder.add_packet pong 88 | msg = ivget(responder, :@message_queue).deq(true) 89 | 90 | # receive pong 91 | initiator.add_message msg 92 | pong_packet = ivget(initiator, :@packet_queue).deq(true) 93 | assert pong_packet.instance_of?(Packet) 94 | data = proto.class::Pong.decode_payload pong_packet.payload 95 | end 96 | 97 | end 98 | -------------------------------------------------------------------------------- /test/multiplexer_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'test_helper' 3 | 4 | class MultiplexerTest < Minitest::Test 5 | include DEVp2p 6 | 7 | def setup 8 | @mux = Multiplexer.new 9 | @protos = [0,1,2] 10 | @protos.each {|id| @mux.add_protocol id } 11 | end 12 | 13 | def test_frame 14 | # test normal packet 15 | packet0 = Packet.new @protos[0], 0, 'x'*100 16 | @mux.add_packet packet0 17 | 18 | frames = @mux.pop_frames 19 | assert_equal 1, frames.size 20 | 21 | f = frames.first 22 | message = f.as_bytes 23 | 24 | # check framing 25 | fs = f.frame_size 26 | assert_equal fs, message.size 27 | 28 | _fs = 16 + 16 + f.enc_cmd_id.size + packet0.payload.size + 16 29 | _fs += Frame.padding - _fs % Frame.padding 30 | assert_equal _fs, fs 31 | assert message[(32 + f.enc_cmd_id.size)..-1] =~ /\A#{packet0.payload}/ 32 | 33 | packets = @mux.decode message 34 | assert_equal 0, @mux.decode_buffer.size 35 | assert_equal packet0.payload.size, packets[0].payload.size 36 | assert_equal packet0.payload, packets[0].payload 37 | assert_equal packet0, packets[0] 38 | end 39 | 40 | def test_chunked 41 | packet1 = Packet.new @protos[1], 0, "\x00" * @mux.class.max_window_size * 2 + 'x' 42 | @mux.add_packet packet1 43 | 44 | frames = @mux.pop_all_frames 45 | assert_equal packet1.payload.size, frames.map(&:payload).map(&:size).reduce(0, &:+) 46 | 47 | all_frames_length = frames.map(&:frame_size).reduce(0, &:+) 48 | @mux.add_packet packet1 49 | message = @mux.pop_all_frames_as_bytes 50 | assert_equal all_frames_length, message.size 51 | 52 | packets = @mux.decode message 53 | assert_equal 0, @mux.decode_buffer.size 54 | assert_equal packet1.payload, packets[0].payload 55 | assert_equal packet1, packets[0] 56 | assert_equal 1, packets.size 57 | end 58 | 59 | def test_chunked_big 60 | logger = Logging.logger.root 61 | 62 | payload = "\x00" * 10 * 1024**2 # 10MB 63 | packet1 = Packet.new @protos[0], 0, payload 64 | logger.info "large payload size: #{payload.size}" 65 | 66 | t = Time.now 67 | @mux.add_packet packet1 68 | logger.info "framing: #{Time.now - t}" 69 | 70 | t = Time.now 71 | messages = @mux.pop_all_frames.map(&:as_bytes) 72 | logger.info "popping frames: #{Time.now - t}" 73 | 74 | t = Time.now 75 | packets = nil 76 | messages.each do |m| 77 | packets = @mux.decode m 78 | break unless packets.empty? 79 | end 80 | logger.info "decoding frames: #{Time.now - t}" 81 | 82 | assert_equal 0, @mux.decode_buffer.size 83 | assert_equal packet1.payload, packets[0].payload 84 | assert_equal packet1, packets[0] 85 | assert_equal 1, packets.size 86 | end 87 | 88 | def test_remain 89 | packet1 = Packet.new @protos[1], 0, "\x00"*100 90 | @mux.add_packet packet1 91 | message = @mux.pop_all_frames_as_bytes 92 | 93 | tail = message[0,50] 94 | message += tail 95 | packets = @mux.decode message 96 | 97 | assert_equal packet1, packets[0] 98 | assert_equal 1, packets.size 99 | assert_equal tail.size, @mux.decode_buffer.size 100 | 101 | message = message[1..-1] 102 | assert_raises(MultiplexerError) { @mux.decode message } 103 | end 104 | 105 | def test_multiplexer 106 | assert_equal @protos[0], @mux.next_protocol 107 | assert_equal @protos[1], @mux.next_protocol 108 | assert_equal @protos[2], @mux.next_protocol 109 | assert_equal @protos[0], @mux.next_protocol 110 | 111 | assert_equal [], @mux.pop_frames 112 | assert_equal 0, @mux.num_active_protocols 113 | 114 | packet0 = Packet.new @protos[0], 0, 'x'*100 115 | @mux.add_packet packet0 116 | assert_equal 1, @mux.num_active_protocols 117 | 118 | frames = @mux.pop_frames 119 | assert_equal 1, frames.size 120 | assert_equal frames[0].frame_size, frames[0].as_bytes.size 121 | 122 | @mux.add_packet packet0 123 | assert_equal 1, @mux.num_active_protocols 124 | message = @mux.pop_all_frames_as_bytes 125 | packets = @mux.decode message 126 | assert_equal packet0.payload.size, packets[0].payload.size 127 | assert_equal packet0.payload, packets[0].payload 128 | assert_equal packet0, packets[0] 129 | 130 | assert_equal 0, @mux.pop_frames.size 131 | 132 | # big packet 133 | packet1 = Packet.new @protos[1], 0, "\x00"* @mux.class.max_window_size * 2 134 | @mux.add_packet packet1 135 | 136 | message = @mux.pop_all_frames_as_bytes 137 | packets = @mux.decode message 138 | assert_equal packet1.payload, packets[0].payload 139 | assert_equal packet1, packets[0] 140 | assert_equal 1, packets.size 141 | 142 | # mix packet types 143 | packet2 = Packet.new @protos[0], 0, "\x00"*200, true 144 | @mux.add_packet packet1 145 | @mux.add_packet packet0 146 | @mux.add_packet packet2 147 | message = @mux.pop_all_frames_as_bytes 148 | packets = @mux.decode message 149 | assert_equal [packet2, packet0, packet1], packets 150 | 151 | # packets with different protocols 152 | packet3 = Packet.new @protos[1], 0, "\x00"*3000, false 153 | @mux.add_packet packet1 154 | @mux.add_packet packet0 155 | @mux.add_packet packet2 156 | @mux.add_packet packet3 157 | @mux.add_packet packet3 158 | @mux.add_packet packet3 159 | assert_equal @protos[0], @mux.next_protocol 160 | 161 | # thus next with data is p1 with packet3 162 | message = @mux.pop_all_frames_as_bytes 163 | packets = @mux.decode message 164 | assert_equal [packet3, packet2, packet0, packet3, packet3, packet1], packets 165 | 166 | # test buffer remains, incomplete frames 167 | packet1 = Packet.new @protos[1], 0, "\x00"*100 168 | @mux.add_packet packet1 169 | 170 | message = @mux.pop_all_frames_as_bytes 171 | tail = message[0,50] 172 | message += tail 173 | packets = @mux.decode message 174 | 175 | assert_equal packet1, packets[0] 176 | assert_equal 1, packets.size 177 | assert_equal tail.size, @mux.decode_buffer.size 178 | end 179 | 180 | end 181 | -------------------------------------------------------------------------------- /test/p2p_protocol_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'test_helper' 3 | 4 | class P2PProtocolTest < Minitest::Test 5 | include DEVp2p 6 | 7 | class PeerMock 8 | include Concurrent::Async 9 | 10 | attr :packets, :config, :capabilities, :stopped, :hello_received, :remote_client_version, :remote_pubkey, :remote_hello_version 11 | 12 | def initialize 13 | @packets = [] 14 | @config = Hashie::Mash.new( 15 | p2p: {listen_port: 3000}, 16 | node: { id: "\x00"*64 }, 17 | client_version_string: "devp2p 0.0.0" 18 | ) 19 | @capabilities = [['p2p', 2], ['eth', 57]] 20 | @stopped = false 21 | @hello_received = false 22 | @remote_client_version = '' 23 | @remote_pubkey = '' 24 | @remote_hello_version = 0 25 | end 26 | 27 | def receive_hello(proto, kwargs) 28 | kwargs = Hashie.symbolize_keys kwargs 29 | required = %i(version client_version_string capabilities listen_port remote_pubkey) 30 | raise ArgumentError, "you must provide #{required}" unless required.all? {|k| kwargs.has_key?(k) } 31 | 32 | version = kwargs[:version] 33 | client_version_string = kwargs[:client_version_string] 34 | capabilities = kwargs[:capabilities] 35 | listen_port = kwargs[:listen_port] 36 | remote_pubkey = kwargs[:remote_pubkey] 37 | 38 | capabilities.each do |(name, ver)| 39 | raise ArgumentError, 'capability name must be string' unless name.instance_of?(String) 40 | raise ArgumentError, 'capability version must be integer' unless ver.is_a?(Integer) 41 | end 42 | 43 | @hello_received = true 44 | @remote_client_version = client_version_string 45 | @remote_pubkey = remote_pubkey 46 | @remote_hello_version = version 47 | end 48 | 49 | def send_packet(packet) 50 | @packets.push packet 51 | end 52 | 53 | def stop 54 | @stopped = true 55 | end 56 | end 57 | 58 | def setup 59 | @peer = PeerMock.new 60 | @proto = P2PProtocol.new @peer, WiredService.new(App.new) 61 | end 62 | 63 | def test_eip8_hello 64 | eip8_hello = Utils.decode_hex 'f87137916b6e6574682f76302e39312f706c616e39cdc5836574683dc6846d6f726b1682270fb840fda1cff674c90c9a197539fe3dfb53086ace64f83ed7c6eabec741f7f381cc803e52ab2cd55d5569bce4347107a310dfd5f88a010cd2ffd1005ca406f1842877c883666f6f836261720304' 65 | 66 | test_packet = Packet.new 0, 1, eip8_hello 67 | @proto.receive_hello test_packet 68 | assert_equal true, @peer.hello_received 69 | end 70 | 71 | def test_callback 72 | r = [] 73 | 74 | cb = lambda do |proto, **data| 75 | #assert_equal @proto, proto 76 | r.push data 77 | end 78 | @proto.receive_pong_callbacks.push cb 79 | 80 | @proto.send_ping 81 | sleep 0.1 82 | ping_packet = @peer.packets.pop 83 | @proto.receive_ping ping_packet 84 | sleep 0.1 85 | pong_packet = @peer.packets.pop 86 | @proto.receive_pong pong_packet 87 | sleep 0.1 88 | assert @peer.packets.empty? 89 | assert_equal 1, r.size 90 | assert_equal({}, r[0]) 91 | end 92 | 93 | end 94 | -------------------------------------------------------------------------------- /test/peer_manager_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'test_helper' 3 | 4 | class PeerTest < Minitest::Test 5 | include DEVp2p 6 | 7 | def test_app_restart 8 | host, port = '127.0.0.1', 3020 9 | 10 | a_config = { 11 | p2p: { 12 | listen_host: host, 13 | listen_port: port 14 | }, 15 | node: { 16 | privkey_hex: Utils.encode_hex(Crypto.keccak256('a')) 17 | } 18 | } 19 | a_app = App.new a_config 20 | PeerManager.register_with_app(a_app) 21 | 22 | # Restart app 10-times: there should be no exception 23 | 10.times do |i| 24 | sleep 0.1 25 | a_app.start 26 | assert !a_app.services.peermanager.stopped? 27 | 28 | sleep 0.1 29 | try_tcp_connect host, port 30 | sleep 0.1 31 | assert_equal 0, a_app.services.peermanager.num_peers 32 | 33 | sleep 0.1 34 | a_app.stop 35 | assert_equal nil, a_app.services.peermanager 36 | end 37 | 38 | # start the app 10-times: there should be no exception 39 | #10.times do |i| 40 | # a_app.start 41 | # assert !a_app.services.peermanager.stopped? 42 | # sleep 0.1 43 | # try_tcp_connect host, port 44 | #end 45 | 46 | sleep 0.1 47 | a_app.stop 48 | assert_equal nil, a_app.services.peermanager 49 | end 50 | 51 | private 52 | 53 | def try_tcp_connect(host, port) 54 | s = TCPSocket.new host, port 55 | s.close 56 | end 57 | 58 | end 59 | -------------------------------------------------------------------------------- /test/peer_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'test_helper' 3 | 4 | class PeerTest < Minitest::Test 5 | include DEVp2p 6 | 7 | def test_handshake 8 | a_app, b_app = get_connected_apps 9 | 10 | sleep 0.1 11 | a_app.stop 12 | b_app.stop 13 | end 14 | 15 | class ::DEVp2p::P2PProtocol 16 | class Transfer < ::DEVp2p::Command 17 | cmd_id 4 18 | structure(raw_data: RLP::Sedes.binary) 19 | 20 | def create(proto, raw_data='') 21 | [raw_data] 22 | end 23 | end 24 | end 25 | 26 | def test_big_transfer 27 | a_app, b_app = get_connected_apps 28 | sleep 0.1 29 | 30 | a_protocol = ivget(a_app.services.peermanager, :@peers)[0].protocols[P2PProtocol] 31 | b_protocol = ivget(b_app.services.peermanager, :@peers)[0].protocols[P2PProtocol] 32 | 33 | t = Time.now 34 | cb = ->(proto, **data) { puts "took #{Time.now - t}, data: #{data['raw_data']}" } 35 | 36 | b_protocol.receive_transfer_callbacks.push cb 37 | raw_data = '0' * 1000 * 1000 38 | a_protocol.send_transfer raw_data 39 | 40 | sleep 0.5 41 | a_app.stop 42 | sleep 0.5 43 | b_app.stop 44 | sleep 0.1 45 | end 46 | 47 | private 48 | 49 | def get_connected_apps 50 | a_app = get_app 'a' 51 | b_app = get_app 'b' 52 | 53 | a_peermgr = a_app.services.peermanager 54 | b_peermgr = b_app.services.peermanager 55 | sleep 0.1 56 | 57 | # connect 58 | b_config = get_config 'b' 59 | host = b_config[:p2p][:listen_host] 60 | port = b_config[:p2p][:listen_port] 61 | pubkey = Crypto.privtopub Utils.decode_hex(b_config[:node][:privkey_hex]) 62 | a_peermgr.connect host, port, pubkey 63 | sleep 0.1 64 | 65 | return a_app, b_app 66 | end 67 | 68 | def get_app(name) 69 | config = get_config name 70 | app = App.new config 71 | PeerManager.register_with_app app 72 | app.start 73 | app 74 | rescue 75 | puts $! 76 | puts $!.backtrace[0,10].join("\n") 77 | end 78 | 79 | def get_config(name) 80 | { p2p: { 81 | listen_host: '127.0.0.1', 82 | listen_port: 3000 + name[0].ord 83 | }, 84 | node: { 85 | privkey_hex: Utils.encode_hex(Crypto.keccak256(name)) 86 | } 87 | } 88 | end 89 | 90 | end 91 | -------------------------------------------------------------------------------- /test/rlpx_session_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'test_helper' 3 | 4 | class RLPxSessionTest < Minitest::Test 5 | include DEVp2p 6 | 7 | def test_session 8 | initiator = RLPxSession.new Crypto::ECCx.new(Crypto.mk_privkey('secret1')), true 9 | initiator_pubkey = initiator.ecc.raw_pubkey 10 | 11 | responder = RLPxSession.new Crypto::ECCx.new(Crypto.mk_privkey('secret2')) 12 | responder_pubkey = responder.ecc.raw_pubkey 13 | 14 | auth_msg = initiator.create_auth_message responder_pubkey 15 | auth_msg_ct = initiator.encrypt_auth_message auth_msg, responder_pubkey 16 | 17 | responder.decode_authentication auth_msg_ct 18 | auth_ack_msg = responder.create_auth_ack_message 19 | auth_ack_msg_ct = responder.encrypt_auth_ack_message auth_ack_msg, false, initiator_pubkey 20 | 21 | initiator.decode_auth_ack_message auth_ack_msg_ct 22 | 23 | initiator.setup_cipher 24 | responder.setup_cipher 25 | 26 | assert_equal ivget(responder, :@ecdhe_shared_secret), ivget(initiator, :@ecdhe_shared_secret) 27 | assert_equal ivget(responder, :@token), ivget(initiator, :@token) 28 | assert_equal ivget(responder, :@aes_secret), ivget(initiator, :@aes_secret) 29 | assert_equal ivget(responder, :@mac_secret), ivget(initiator, :@mac_secret) 30 | 31 | assert_equal ivget(responder, :@egress_mac).digest, ivget(initiator, :@ingress_mac).digest 32 | assert_equal ivget(responder, :@ingress_mac).digest, ivget(initiator, :@egress_mac).digest 33 | 34 | return initiator, responder 35 | end 36 | 37 | def test_multiplexing 38 | initiator, responder = test_session 39 | imux = Multiplexer.new initiator 40 | rmux = Multiplexer.new responder 41 | 42 | p1 = 1 43 | imux.add_protocol p1 44 | rmux.add_protocol p1 45 | 46 | packet1 = Packet.new p1, 0, "\x00"*100 47 | imux.add_packet packet1 48 | msg = imux.pop_all_frames_as_bytes 49 | packets = rmux.decode(msg) 50 | 51 | assert_equal 1, packets.size 52 | assert_equal packet1, packets[0] 53 | end 54 | 55 | def test_many_sessions 56 | 20.times {|i| test_session } 57 | end 58 | 59 | EIP8Values = { 60 | key_a: Utils.decode_hex('49a7b37aa6f6645917e7b807e9d1c00d4fa71f18343b0d4122a4d2df64dd6fee'), 61 | key_b: Utils.decode_hex('b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291'), 62 | pub_a: Utils.decode_hex('fda1cff674c90c9a197539fe3dfb53086ace64f83ed7c6eabec741f7f381cc803e52ab2cd55d5569bce4347107a310dfd5f88a010cd2ffd1005ca406f1842877'), 63 | pub_b: Utils.decode_hex('ca634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd31387574077f301b421bc84df7266c44e9e6d569fc56be00812904767bf5ccd1fc7f'), 64 | eph_key_a: Utils.decode_hex('869d6ecf5211f1cc60418a13b9d870b22959d0c16f02bec714c960dd2298a32d'), 65 | eph_key_b: Utils.decode_hex('e238eb8e04fee6511ab04c6dd3c89ce097b11f25d584863ac2b6d5b35b1847e4'), 66 | eph_pub_a: Utils.decode_hex('654d1044b69c577a44e5f01a1209523adb4026e70c62d1c13a067acabc09d2667a49821a0ad4b634554d330a15a58fe61f8a8e0544b310c6de7b0c8da7528a8d'), 67 | eph_pub_b: Utils.decode_hex('b6d82fa3409da933dbf9cb0140c5dde89f4e64aec88d476af648880f4a10e1e49fe35ef3e69e93dd300b4797765a747c6384a6ecf5db9c2690398607a86181e4'), 68 | nonce_a: Utils.decode_hex('7e968bba13b6c50e2c4cd7f241cc0d64d1ac25c7f5952df231ac6a2bda8ee5d6'), 69 | nonce_b: Utils.decode_hex('559aead08264d5795d3909718cdd05abd49572e84fe55590eef31a88a08fdffd') 70 | } 71 | 72 | EIP8Handshakes = [ 73 | { auth: Utils.decode_hex([ 74 | '048ca79ad18e4b0659fab4853fe5bc58eb83992980f4c9cc147d2aa31532efd29a3d3dc6a3d89eaf', 75 | '913150cfc777ce0ce4af2758bf4810235f6e6ceccfee1acc6b22c005e9e3a49d6448610a58e98744', 76 | 'ba3ac0399e82692d67c1f58849050b3024e21a52c9d3b01d871ff5f210817912773e610443a9ef14', 77 | '2e91cdba0bd77b5fdf0769b05671fc35f83d83e4d3b0b000c6b2a1b1bba89e0fc51bf4e460df3105', 78 | 'c444f14be226458940d6061c296350937ffd5e3acaceeaaefd3c6f74be8e23e0f45163cc7ebd7622', 79 | '0f0128410fd05250273156d548a414444ae2f7dea4dfca2d43c057adb701a715bf59f6fb66b2d1d2', 80 | '0f2c703f851cbf5ac47396d9ca65b6260bd141ac4d53e2de585a73d1750780db4c9ee4cd4d225173', 81 | 'a4592ee77e2bd94d0be3691f3b406f9bba9b591fc63facc016bfa8'].join), 82 | ack: Utils.decode_hex([ 83 | '049f8abcfa9c0dc65b982e98af921bc0ba6e4243169348a236abe9df5f93aa69d99cadddaa387662', 84 | 'b0ff2c08e9006d5a11a278b1b3331e5aaabf0a32f01281b6f4ede0e09a2d5f585b26513cb794d963', 85 | '5a57563921c04a9090b4f14ee42be1a5461049af4ea7a7f49bf4c97a352d39c8d02ee4acc416388c', 86 | '1c66cec761d2bc1c72da6ba143477f049c9d2dde846c252c111b904f630ac98e51609b3b1f58168d', 87 | 'dca6505b7196532e5f85b259a20c45e1979491683fee108e9660edbf38f3add489ae73e3dda2c71b', 88 | 'd1497113d5c755e942d1'].join), 89 | auth_version: 4, 90 | ack_version: 4, 91 | eip8_format: false 92 | }, 93 | 94 | { auth: Utils.decode_hex([ 95 | '01b304ab7578555167be8154d5cc456f567d5ba302662433674222360f08d5f1534499d3678b513b', 96 | '0fca474f3a514b18e75683032eb63fccb16c156dc6eb2c0b1593f0d84ac74f6e475f1b8d56116b84', 97 | '9634a8c458705bf83a626ea0384d4d7341aae591fae42ce6bd5c850bfe0b999a694a49bbbaf3ef6c', 98 | 'da61110601d3b4c02ab6c30437257a6e0117792631a4b47c1d52fc0f8f89caadeb7d02770bf999cc', 99 | '147d2df3b62e1ffb2c9d8c125a3984865356266bca11ce7d3a688663a51d82defaa8aad69da39ab6', 100 | 'd5470e81ec5f2a7a47fb865ff7cca21516f9299a07b1bc63ba56c7a1a892112841ca44b6e0034dee', 101 | '70c9adabc15d76a54f443593fafdc3b27af8059703f88928e199cb122362a4b35f62386da7caad09', 102 | 'c001edaeb5f8a06d2b26fb6cb93c52a9fca51853b68193916982358fe1e5369e249875bb8d0d0ec3', 103 | '6f917bc5e1eafd5896d46bd61ff23f1a863a8a8dcd54c7b109b771c8e61ec9c8908c733c0263440e', 104 | '2aa067241aaa433f0bb053c7b31a838504b148f570c0ad62837129e547678c5190341e4f1693956c', 105 | '3bf7678318e2d5b5340c9e488eefea198576344afbdf66db5f51204a6961a63ce072c8926c'].join), 106 | ack: Utils.decode_hex([ 107 | '01ea0451958701280a56482929d3b0757da8f7fbe5286784beead59d95089c217c9b917788989470', 108 | 'b0e330cc6e4fb383c0340ed85fab836ec9fb8a49672712aeabbdfd1e837c1ff4cace34311cd7f4de', 109 | '05d59279e3524ab26ef753a0095637ac88f2b499b9914b5f64e143eae548a1066e14cd2f4bd7f814', 110 | 'c4652f11b254f8a2d0191e2f5546fae6055694aed14d906df79ad3b407d94692694e259191cde171', 111 | 'ad542fc588fa2b7333313d82a9f887332f1dfc36cea03f831cb9a23fea05b33deb999e85489e645f', 112 | '6aab1872475d488d7bd6c7c120caf28dbfc5d6833888155ed69d34dbdc39c1f299be1057810f34fb', 113 | 'e754d021bfca14dc989753d61c413d261934e1a9c67ee060a25eefb54e81a4d14baff922180c395d', 114 | '3f998d70f46f6b58306f969627ae364497e73fc27f6d17ae45a413d322cb8814276be6ddd13b885b', 115 | '201b943213656cde498fa0e9ddc8e0b8f8a53824fbd82254f3e2c17e8eaea009c38b4aa0a3f306e8', 116 | '797db43c25d68e86f262e564086f59a2fc60511c42abfb3057c247a8a8fe4fb3ccbadde17514b7ac', 117 | '8000cdb6a912778426260c47f38919a91f25f4b5ffb455d6aaaf150f7e5529c100ce62d6d92826a7', 118 | '1778d809bdf60232ae21ce8a437eca8223f45ac37f6487452ce626f549b3b5fdee26afd2072e4bc7', 119 | '5833c2464c805246155289f4'].join), 120 | auth_version: 4, 121 | ack_version: 4, 122 | eip8_format: true, 123 | }, 124 | 125 | { auth: Utils.decode_hex([ 126 | '01b8044c6c312173685d1edd268aa95e1d495474c6959bcdd10067ba4c9013df9e40ff45f5bfd6f7', 127 | '2471f93a91b493f8e00abc4b80f682973de715d77ba3a005a242eb859f9a211d93a347fa64b597bf', 128 | '280a6b88e26299cf263b01b8dfdb712278464fd1c25840b995e84d367d743f66c0e54a586725b7bb', 129 | 'f12acca27170ae3283c1073adda4b6d79f27656993aefccf16e0d0409fe07db2dc398a1b7e8ee93b', 130 | 'cd181485fd332f381d6a050fba4c7641a5112ac1b0b61168d20f01b479e19adf7fdbfa0905f63352', 131 | 'bfc7e23cf3357657455119d879c78d3cf8c8c06375f3f7d4861aa02a122467e069acaf513025ff19', 132 | '6641f6d2810ce493f51bee9c966b15c5043505350392b57645385a18c78f14669cc4d960446c1757', 133 | '1b7c5d725021babbcd786957f3d17089c084907bda22c2b2675b4378b114c601d858802a55345a15', 134 | '116bc61da4193996187ed70d16730e9ae6b3bb8787ebcaea1871d850997ddc08b4f4ea668fbf3740', 135 | '7ac044b55be0908ecb94d4ed172ece66fd31bfdadf2b97a8bc690163ee11f5b575a4b44e36e2bfb2', 136 | 'f0fce91676fd64c7773bac6a003f481fddd0bae0a1f31aa27504e2a533af4cef3b623f4791b2cca6', 137 | 'd490'].join), 138 | ack: Utils.decode_hex([ 139 | '01f004076e58aae772bb101ab1a8e64e01ee96e64857ce82b1113817c6cdd52c09d26f7b90981cd7', 140 | 'ae835aeac72e1573b8a0225dd56d157a010846d888dac7464baf53f2ad4e3d584531fa203658fab0', 141 | '3a06c9fd5e35737e417bc28c1cbf5e5dfc666de7090f69c3b29754725f84f75382891c561040ea1d', 142 | 'dc0d8f381ed1b9d0d4ad2a0ec021421d847820d6fa0ba66eaf58175f1b235e851c7e2124069fbc20', 143 | '2888ddb3ac4d56bcbd1b9b7eab59e78f2e2d400905050f4a92dec1c4bdf797b3fc9b2f8e84a482f3', 144 | 'd800386186712dae00d5c386ec9387a5e9c9a1aca5a573ca91082c7d68421f388e79127a5177d4f8', 145 | '590237364fd348c9611fa39f78dcdceee3f390f07991b7b47e1daa3ebcb6ccc9607811cb17ce51f1', 146 | 'c8c2c5098dbdd28fca547b3f58c01a424ac05f869f49c6a34672ea2cbbc558428aa1fe48bbfd6115', 147 | '8b1b735a65d99f21e70dbc020bfdface9f724a0d1fb5895db971cc81aa7608baa0920abb0a565c9c', 148 | '436e2fd13323428296c86385f2384e408a31e104670df0791d93e743a3a5194ee6b076fb6323ca59', 149 | '3011b7348c16cf58f66b9633906ba54a2ee803187344b394f75dd2e663a57b956cb830dd7a908d4f', 150 | '39a2336a61ef9fda549180d4ccde21514d117b6c6fd07a9102b5efe710a32af4eeacae2cb3b1dec0', 151 | '35b9593b48b9d3ca4c13d245d5f04169b0b1'].join), 152 | auth_version: 56, 153 | ack_version: 57, 154 | eip8_format: true, 155 | } 156 | ] 157 | 158 | def test_eip8_handshake_messages 159 | initiator = RLPxSession.new Crypto::ECCx.new(EIP8Values[:key_a]), true 160 | responder = RLPxSession.new Crypto::ECCx.new(EIP8Values[:key_b]) 161 | 162 | EIP8Handshakes.each do |handshake| 163 | ack_rest = initiator.decode_auth_ack_message handshake[:ack] 164 | assert_equal EIP8Values[:eph_pub_b], ivget(initiator, :@remote_ephemeral_pubkey) 165 | assert_equal EIP8Values[:nonce_b], ivget(initiator, :@responder_nonce) 166 | assert_equal handshake[:eip8_format], ivget(initiator, :@got_eip8_ack) 167 | assert_equal handshake[:ack_version], ivget(initiator, :@remote_version) 168 | assert_equal '', ack_rest 169 | 170 | auth_rest = responder.decode_authentication handshake[:auth] 171 | assert_equal EIP8Values[:eph_pub_a], ivget(responder, :@remote_ephemeral_pubkey) 172 | assert_equal EIP8Values[:nonce_a], ivget(responder, :@initiator_nonce) 173 | assert_equal EIP8Values[:pub_a], ivget(responder, :@remote_pubkey) 174 | assert_equal handshake[:eip8_format], ivget(responder, :@got_eip8_auth) 175 | assert_equal '', auth_rest 176 | end 177 | end 178 | 179 | def test_eip8_key_derivation 180 | responder = RLPxSession.new Crypto::ECCx.new(EIP8Values[:key_b]), false, EIP8Values[:eph_key_b] 181 | responder.decode_authentication EIP8Handshakes[1][:auth] 182 | ack = responder.create_auth_ack_message nil, EIP8Values[:nonce_b] 183 | responder.encrypt_auth_ack_message ack 184 | 185 | responder.setup_cipher 186 | want_aes_secret = Utils.decode_hex('80e8632c05fed6fc2a13b0f8d31a3cf645366239170ea067065aba8e28bac487') 187 | want_mac_secret = Utils.decode_hex('2ea74ec5dae199227dff1af715362700e989d889d7a493cb0639691efb8e5f98') 188 | assert_equal want_aes_secret, ivget(responder, :@aes_secret) 189 | assert_equal want_mac_secret, ivget(responder, :@mac_secret) 190 | 191 | mac_digest = responder.ingress_mac('foo') 192 | want_mac_digest = Utils.decode_hex '0c7ec6340062cc46f5e9f1e3cf86f8c8c403c5a0964f5df0ebd34a75ddc86db5' 193 | assert_equal want_mac_digest, mac_digest 194 | end 195 | 196 | def test_auth_ack_is_eip8_for_eip8_auth 197 | responder = RLPxSession.new Crypto::ECCx.new(EIP8Values[:key_b]) 198 | responder.decode_authentication EIP8Handshakes[1][:auth] 199 | assert ivget(responder, :@got_eip8_auth) 200 | 201 | ack = responder.create_auth_ack_message nil, nil, 55 202 | ack_ct = responder.encrypt_auth_ack_message ack 203 | 204 | initiator = RLPxSession.new Crypto::ECCx.new(EIP8Values[:key_a]), true 205 | initiator.decode_auth_ack_message ack_ct 206 | assert ivget(initiator, :@got_eip8_ack) 207 | assert_equal 55, initiator.remote_version 208 | end 209 | 210 | def test_macs 211 | initiator, responder = test_session 212 | 213 | assert_equal responder.egress_mac(''), initiator.ingress_mac('') 214 | assert_equal responder.ingress_mac(''), initiator.egress_mac('') 215 | 216 | 5.times do |i| 217 | msg = 'test' 218 | id = initiator.egress_mac(msg) 219 | rd = responder.ingress_mac(msg) 220 | assert_equal id, rd 221 | end 222 | end 223 | 224 | def test_mac_enc 225 | initiator, responder = test_session 226 | 227 | msg = 'a'*16 228 | assert_equal responder.mac_enc(msg), initiator.mac_enc(msg) 229 | end 230 | 231 | def test_aes_enc 232 | initiator, responder = test_session 233 | 234 | msg = 'test' 235 | c = initiator.aes_enc(msg) 236 | assert_equal msg.size, c.size 237 | 238 | d = responder.aes_dec(c) 239 | assert_equal msg, d 240 | end 241 | 242 | def test_encryption 243 | initiator, responder = test_session 244 | 245 | 5.times do |i| 246 | msg_frame = Crypto.keccak256("#{i}f") * i + 'notpadded' 247 | msg_frame_padded = Utils.rzpad16 msg_frame 248 | 249 | msg_header = Frame.encode_body_size(msg_frame.size) + Crypto.keccak256(i.to_s)[0,16-3] 250 | msg_ct = initiator.encrypt msg_header, msg_frame_padded 251 | 252 | r = responder.decrypt msg_ct 253 | assert_equal msg_header, r[:header] 254 | assert_equal msg_frame, r[:frame] 255 | end 256 | 257 | 5.times do |i| 258 | msg_frame = Crypto.keccak256 "#{i}f" 259 | msg_header = Frame.encode_body_size(msg_frame.size) + Crypto.keccak256(i.to_s)[0,16-3] 260 | msg_ct = responder.encrypt(msg_header, msg_frame) 261 | 262 | r = initiator.decrypt msg_ct 263 | assert_equal msg_header, r[:header] 264 | assert_equal msg_frame, r[:frame] 265 | end 266 | end 267 | 268 | def test_body_length 269 | initiator, responder = test_session 270 | 271 | msg_frame = Crypto.keccak256('test') + 'notpadded' 272 | msg_frame_padded = Utils.rzpad16 msg_frame 273 | msg_header = Frame.encode_body_size(msg_frame.size) + Crypto.keccak256('x')[0,16-3] 274 | msg_ct = initiator.encrypt(msg_header, msg_frame_padded) 275 | 276 | r = responder.decrypt msg_ct 277 | assert_equal msg_header, r[:header] 278 | assert_equal msg_frame, r[:frame] 279 | 280 | # test excess data 281 | msg_ct2 = initiator.encrypt msg_header, msg_frame_padded 282 | r = responder.decrypt "#{msg_ct2}excess data" 283 | assert_equal msg_header, r[:header] 284 | assert_equal msg_frame, r[:frame] 285 | assert_equal msg_ct.size, r[:bytes_read] 286 | 287 | # test data underflow 288 | data = initiator.encrypt msg_header, msg_frame_padded 289 | header = responder.decrypt_header data[0,32] 290 | body_size = Frame.decode_body_size(header) 291 | assert_raises(FormatError) { responder.decrypt_body data[32...-1], body_size } 292 | end 293 | 294 | end 295 | -------------------------------------------------------------------------------- /test/service_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'test_helper' 3 | 4 | class ServiceTest < Minitest::Test 5 | include DEVp2p 6 | 7 | class TestService < Service 8 | attr :counter 9 | 10 | def initialize(app) 11 | super(app) 12 | @counter = 0 13 | end 14 | 15 | def start 16 | @run = Thread.new do 17 | loop do 18 | @counter += 1 19 | sleep 0.01 20 | end 21 | end 22 | end 23 | 24 | def stop 25 | @run.kill 26 | end 27 | end 28 | 29 | def test_base_service 30 | app = App.new 31 | 32 | klass = TestService.register_with_app app 33 | assert_equal '', klass.name 34 | 35 | # register another service 36 | TestService.name 'other' 37 | klass2 = TestService.register_with_app app 38 | 39 | app.start 40 | sleep 0.1 41 | 42 | s = app.services[klass.name] 43 | s2 = app.services[klass2.name] 44 | 45 | assert s.counter > 0 46 | assert s2.counter > 0 47 | assert (s.counter - s2.counter) <= 2 48 | 49 | app.stop 50 | end 51 | 52 | end 53 | -------------------------------------------------------------------------------- /test/sync_queue_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'test_helper' 3 | 4 | class SyncQueueTest < Minitest::Test 5 | 6 | class Actor 7 | include Concurrent::Async 8 | 9 | attr :queue 10 | 11 | def initialize(max_size=nil) 12 | @queue = SyncQueue.new max_size 13 | end 14 | 15 | def enq(x) 16 | @queue.enq x 17 | end 18 | 19 | def deq 20 | @queue.deq 21 | end 22 | 23 | def add 24 | a = deq 25 | b = deq 26 | a + b 27 | end 28 | 29 | def peek 30 | @queue.peek 31 | end 32 | end 33 | 34 | def test_enq_deq_raw 35 | a = Actor.new 2 36 | a.queue.enq 1 37 | a.queue.enq 2 38 | 39 | ivar = a.async.enq 3 40 | sleep 0.1 41 | assert ivar.pending? 42 | 43 | a.queue.deq 44 | sleep 0.1 45 | assert ivar.fulfilled? 46 | 47 | ivar = a.async.enq 4 48 | sleep 0.1 49 | assert ivar.pending? 50 | 51 | a.queue.deq 52 | a.queue.deq 53 | a.queue.deq 54 | 55 | ivar = a.async.deq 56 | sleep 0.1 57 | assert ivar.pending? 58 | 59 | a.queue.enq 0 60 | sleep 0.1 61 | assert ivar.fulfilled? 62 | assert a.queue.empty? 63 | end 64 | 65 | def test_enq_deq 66 | a = Actor.new 67 | ivar = a.async.add 68 | sleep 0.1 69 | assert ivar.pending? 70 | 71 | a.queue.enq 1 72 | sleep 0.1 73 | assert ivar.pending? 74 | 75 | a.queue.enq 2 76 | sleep 0.1 77 | assert ivar.fulfilled? 78 | assert_equal 3, ivar.value 79 | end 80 | 81 | def test_peek 82 | a = Actor.new 83 | ivar = a.async.peek 84 | sleep 0.1 85 | assert ivar.pending? 86 | 87 | a.queue.enq 1 88 | sleep 0.1 89 | assert ivar.fulfilled? 90 | assert_equal 1, ivar.value 91 | end 92 | 93 | end 94 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'minitest/autorun' 3 | require 'devp2p' 4 | #require 'pry-byebug' 5 | 6 | Logging.logger.root.appenders = [ 7 | Logging::Appenders.file( 8 | File.expand_path('../../test.log', __FILE__), 9 | layout: Logging.layouts.pattern.new(pattern: "%.1l, [%d] %5l -- %c: %m\n") 10 | ), 11 | Logging.appenders.stdout 12 | ] 13 | Logging.logger.root.level = :debug 14 | 15 | def ivget(obj, name) 16 | obj.instance_variable_get(name) 17 | end 18 | -------------------------------------------------------------------------------- /test/utils_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : ascii-8bit -*- 2 | require 'test_helper' 3 | 4 | class UtilsTest < Minitest::Test 5 | include DEVp2p 6 | 7 | def test_int_to_big_endian4 8 | assert_equal "\x00\x00\x00\x01", Utils.int_to_big_endian4(1) 9 | end 10 | 11 | def test_sxor 12 | assert_equal 'PPP', Utils.sxor('abc', '123') 13 | end 14 | 15 | def test_update_config_with_defaults 16 | c = {a: {b: 1}, g: 5} 17 | d = {a: {b: 2, c: 3}, d: 4, e: {f: 1}} 18 | r = {a: {b: 1, c: 3}, d: 4, e: {f: 1}, g: 5} 19 | assert_equal r, Utils.update_config_with_defaults(c, d) 20 | 21 | c = {a: {b: 1}, g: 5, h: [], k: [2]} 22 | d = {a: {b: 2, c: 3}, d: 4, e: {f: 1, i: [1, 2]}, j: []} 23 | r = {a: {b: 1, c: 3}, d: 4, e: {f: 1, i: [1, 2]}, j: [], g: 5, h: [], k: [2]} 24 | assert_equal r, Utils.update_config_with_defaults(c, d) 25 | end 26 | 27 | def test_underscore 28 | assert_equal 'camel', Utils.underscore('Camel') 29 | assert_equal 'camel_case', Utils.underscore('CamelCase') 30 | end 31 | 32 | end 33 | --------------------------------------------------------------------------------