Python从头实现以太坊(四):查找邻居节点

Python从头实现以太坊系列索引:
一、Ping
二、Pinging引导节点
三、解码引导节点的响应
四、查找邻居节点
五、类-Kademlia协议
六、Routing

这是我写的从头完整实现以太坊协议系列的第四部分(第一部分第二部分第三部分,如果你以前没看过,建议你从第一部分开始看)。等这个系列教程结束的时候,你就可以爬取以太坊网络获取对等端点,同步和验证区块链,为以太坊虚拟机编写智能合约,以及挖以太币。我们现在正在实现其发现协议部分。一旦完成,我们就可以用一种类似torrent的方式下载区块链。我们上一次完成了Ping引导节点并解码和验证其Pong响应。今天我们要实现FindNeighbors请求和Neighbors响应,我们将用它们爬取以太坊网络。

这一部分不难,我们简单地为FindNeighborsNeighbors的数据包定义类结构,并像我们之前发送PingNodePong那样将它们发送即可。但是,想要成功发送FindNeighbors数据包,还需要满足一些必备条件。我们并没有在协议文档中看到这些条件,是因为文档比源代码旧。go-ethereum源代码的发现协议采用v4版本。但是RLPx协议(我们的实现)却只到v3版本。源代码里甚至还有一个叫discv5的模块,表明它们正在实现v5版本,不过,通过检查引导节点发回的Ping消息的version字段,我们发现它们跑的依然是v4版本。

v4版本的协议要求,为了获取FindNeighbors请求的响应,必须先要有一次UDP"握手"。我们在udp.go源文件里面可以看到:

func (req *findnode) handle(t *udp, from *net.UDPAddr, fromID NodeID, mac []byte) error {
    if expired(req.Expiration) {
        return errExpired
    }
    if t.db.node(fromID) == nil {
        // No bond exists, we don't process the packet. This prevents
        // an attack vector where the discovery protocol could be used
        // to amplify traffic in a DDOS attack. A malicious actor
        // would send a findnode request with the IP address and UDP
        // port of the target as the source address. The recipient of
        // the findnode packet would then send a neighbors packet
        // (which is a much bigger packet than findnode) to the victim.
        return errUnknownNode
    }

为了处理findnode数据包(FindNeighbors的Go实现),代码首先检查请求来源fromID是否在它的已知节点记录里面。如果不在,它就丢弃这个请求(难怪我之前的请求一直出问题,现在弄明白了)。

为了成为已知节点,首先我们必须ping引导节点。当引导节点接收到ping,它会先响应一个pong,然后再发一个ping,并等待我们响应一个pong回去。一旦我们响应了pong,我们的nodeID就会进入引导节点的已知节点列表。

因此,为了能够发送FindNeighbors数据包,首先,我们需要创建与PingNodePong数据包具有相同功能的FindNeighborsNeighbors类。然后,我们需要在receive_ping中加一个Pong的响应以便跟引导节点UDP握手。接着,我们需要调整PingServer使之能持续监听数据包。最后,我们需要调整send_ping.py脚本:发送一个ping,留足够的时间等待引导节点依次响应pongping,之后假设我们正确实现了pong响应的话,就可以发送FindNeighbors数据包并接收Neighbors响应了。

https://github.com/HuangFJ/pyeth下载本项目代码:
git clone https://github.com/HuangFJ/pyeth

创建FindNeighbors和Neighbors类

在此系列前一部分我们为PingNodePong创建了类,这一节,我们要以同样的方式为FindNeighborsNeighbors创建Python类。我们为每个类都创建__init____str__packunpack方法,并为PingServer类添加receive_的方法。

对于FindNeighbors规范描述的数据包结构是:

FindNeighbours packet-type: 0x03
struct FindNeighbours
{
    NodeId target; // Id of a node. The responding node will send back nodes closest to the target.
    uint32_t timestamp;
};

target是一个NodeId类型,它是一个64字节的公钥。这意味着我们可以在packunpack方法中存储和提取它。对于__str__,我将使用binascii.b2a_hex把字节打印成16进制格式。除此以外,其他代码跟我们在PingNodePong所见到的相似。所以,我们在discovery.py编写:

class FindNeighbors(object):
    packet_type = '\x03'

    def __init__(self, target, timestamp):
        self.target = target
        self.timestamp = timestamp

    def __str__(self):
        return "(FN " + binascii.b2a_hex(self.target)[:7] + "... " + str(self.ti\
mestamp) + ")"

    def pack(self):
        return [
            self.target,
            struct.pack(">I", self.timestamp)
        ]

    @classmethod
    def unpack(cls, packed):
        timestamp = struct.unpack(">I", packed[1])[0]
        return cls(packed[0], timestamp)

对于Neighbors,数据包结构为:

Neighbors packet-type: 0x04
struct Neighbours
{
    list nodes: struct Neighbour
    {
        inline Endpoint endpoint;
        NodeId node;
    };

    uint32_t timestamp;
};

这要求我们先定义一个Neighbor类,我将在之后定义并取名为Node。对于Neighbors,唯一新概念是nodes是一个列表,所以我们将使用map来打包和解包数据:

class Neighbors(object):
    packet_type = '\x04'

    def __init__(self, nodes, timestamp):
        self.nodes = nodes
        self.timestamp = timestamp

    def __str__(self):
        return "(Ns [" + ", ".join(map(str, self.nodes)) + "] " + str(self.times\
tamp) + ")"

    def pack(self):
        return [
            map(lambda x: x.pack(), self.nodes),
            struct.pack(">I", self.timestamp)
        ]

    @classmethod
    def unpack(cls, packed):
        nodes = map(lambda x: Node.unpack(x), packed[0])
        timestamp = struct.unpack(">I", packed[1])[0]
        return cls(nodes, timestamp)

对于Node,唯一新概念是endpoint是内联打包,所以endpoint.pack()后成为一个单独的列表项,但是它不必,它只要把nodeID追加到此列表末端。

class Node(object):

    def __init__(self, endpoint, node):
        self.endpoint = endpoint
        self.node = node

    def __str__(self):
        return "(N " + binascii.b2a_hex(self.node)[:7] + "...)"

    def pack(self):
        packed  = self.endpoint.pack()
        packed.append(node)
        return packed

    @classmethod
    def unpack(cls, packed):
        endpoint = EndPoint.unpack(packed[0:3])
        return cls(endpoint, packed[3])

对于新建的数据包类,让我们定义新的PingServer方法来接收数据包,先简单地定义:

def receive_find_neighbors(self, payload):
    print " received FindNeighbors"
    print "", FindNeighbors.unpack(rlp.decode(payload))

def receive_neighbors(self, payload):
    print " received Neighbors"
    print "", Neighbors.unpack(rlp.decode(payload))

PingServerreceive方法里面,我们也要调整response_types派发表:

response_types = {
    PingNode.packet_type : self.receive_ping,
    Pong.packet_type : self.receive_pong,
    FindNeighbors.packet_type : self.receive_find_neighbors,
    Neighbors.packet_type : self.receive_neighbors
}

让服务器持续监听

为了让服务可以持续监听数据包,还有几个事项需要处理:

  • PingServer的功能变得更通用,因此我们将它改名为Server
  • 我们通过设置self.sock.setblocking(0)让服务器的套接字不再阻塞。
  • 让我们把receive方法中#verify hash上面的代码移到新的listen方法中,并给receive添加一个新的参数data。这个新的listen函数循环以select等待数据包的到达并以receive响应。select函数的作用是在可选的超时时间内等待直至资源可用。
  • 我们把从套接字读取的字节数增加到2048,因为一些以太数据包大小超过1024字节长。
  • 我们将udp_listen更改为listen_thread,并将线程对象返回,我们把线程的daemon字段设置为True,这意味着即便监听线程依然在运行,进程也将终止。(之前进程是挂起的)

最终相应的代码部分是这样的:

...
import select
...
class Server(object):

    def __init__(self, my_endpoint):
        ...
        ## set socket non-blocking mode
        self.sock.setblocking(0)

    ...
    def receive(self, data):
        ## verify hash
        msg_hash = data[:32]
        ...

    ...
    def listen(self):
        print "listening..."
        while True:
            ready = select.select([self.sock], [], [], 1.0)
            if ready[0]:
                data, addr = self.sock.recvfrom(2048)
                print "received message[", addr, "]:"
                self.receive(data)

    ...
    def listen_thread(self):
        thread = threading.Thread(target = self.listen)
        thread.daemon = True
        return thread

响应pings

我们必须修改Server类的receive_ping方法以响应一个Pong。这也要求我们将Serverping方法修改成更通用的函数send。原来ping创建一个PingNode对象并发送,现在变成了send接收一个新的packet自变量,做发送前准备并发送。

def receive_ping(self, payload, msg_hash):
    print " received Ping"
    ping = PingNode.unpack(rlp.decode(payload))
    pong = Pong(ping.endpoint_from, msg_hash, time.time() + 60)
    print "  sending Pong response: " + str(pong)
    self.send(pong, pong.to)
...

def send(self, packet, endpoint):
    message = self.wrap_packet(packet)
    print "sending " + str(packet)
    self.sock.sendto(message, (endpoint.address.exploded, endpoint.udpPort))

注意receive_ping有一个新的msg_hash参数。这个参数需放进位于Serverreceive方法里面的dispatch调用中,以及所有其他receive_开头的函数。

def receive_pong(self, payload, msg_hash):
...
def receive_find_neighbors(self, payload, msg_hash):
...
def receive_neighbors(self, payload, msg_hash):
...
def receive(self, data):
    ## verify hash
    msg_hash = data[:32]
    ...
    dispatch(payload, msg_hash)

其他修复

因为引导节点使用v4版本的RLPx协议。但是规范文档和我们的实现使用的是v3,我们需要把PingNode unpack方法的packed[0]==cls.version注释掉。在我可以找到基于新版本的集中文档之前,我不打算修改类的实际版本号。在前一篇文章里面,我忘记了把解包的timestamp包含到cls的参数里面,所以你的uppack看上去要像下面这样:

@classmethod
def unpack(cls, packed):
    ## assert(packed[0] == cls.version)
    endpoint_from = EndPoint.unpack(packed[1])
    endpoint_to = EndPoint.unpack(packed[2])
    timestamp = struct.unpack(">I", packed[3])[0]
    return cls(endpoint_from, endpoint_to, timestamp)

v4的另一个变化是EndPoint编码的第二个自变量是可选的,所以你需要在unpack方法中阐释。如果没有的话,你要设置tcpPort等于udpPort

@classmethod
def unpack(cls, packed):
    udpPort = struct.unpack(">H", packed[1])[0]
    if packed[2] == '':
        tcpPort = udpPort
    else:
        tcpPort = struct.unpack(">H", packed[2])[0]
    return cls(packed[0], udpPort, tcpPort)

对之前版本代码的最后一个修改是,Pongpack方法有一个拼写错误,timestamp应该改为self.timestamp。之所以没发现是因为我们从未发送过Pong消息:

def pack(self):
    return [
        self.to.pack(),
        self.echo,
        struct.pack(">I", self.timestamp)]

修改send_ping.py

我们需要重写send_ping.py以阐释新的发送流程。

from discovery import EndPoint, PingNode, Server, FindNeighbors, Node
import time
import binascii

bootnode_key = "3f1d12044546b76342d59d4a05532c14b85aa669704bfe1f864fe079415aa2c02d743e03218e57a33fb94523adb54032871a6c51b2cc5514cb7c7e35b3ed0a99"

bootnode_endpoint = EndPoint(u'13.93.211.84',
                    30303,
                    30303)

bootnode = Node(bootnode_endpoint,
                binascii.a2b_hex(bootnode_key))

my_endpoint = EndPoint(u'52.4.20.183', 30303, 30303)    
server = Server(my_endpoint)

listen_thread = server.listen_thread()
listen_thread.start()

fn = FindNeighbors(bootnode.node, time.time() + 60)
ping = PingNode(my_endpoint, bootnode.endpoint, time.time() + 60)

## introduce self
server.send(ping, bootnode.endpoint)
## wait for pong-ping-pong
time.sleep(3)
## ask for neighbors
server.send(fn, bootnode.endpoint)
## wait for response
time.sleep(3)

首先,我们从params/bootnodes.go扒一个引导节点的key,创建一个Node对象,作为我们的第一个联系对象。然后我们创建一个服务器,启动监听线程,并创建PingNodeFindNeighbors数据包。接着我们按照握手流程,ping引导节点,接收一个pong和一个ping。我们将响应一个pong以使自己成为一个公认已知节点。最后我们就可以发送fn数据包。引导节点应该会以Neighbors响应。

执行python send_ping.py你应该可以看到:

$ python send_ping.py
sending (Ping 3 (EP 52.4.20.183 30303 30303) (EP 13.93.211.84 30303 30303) 1502819202.25)
listening...
received message[ ('13.93.211.84', 30303) ]:
 Verified message hash.
 Verified signature.
 received Pong
 (Pong (EP 52.4.20.183 30303 30303) <echo hash=""> 1502819162)
received message[ ('13.93.211.84', 30303) ]:
 Verified message hash.
 Verified signature.
 received Ping
   sending Pong response: (Pong (EP 13.93.211.84 30303 30303) <echo hash=""> 1502819202.34)
sending (Pong (EP 13.93.211.84 30303 30303) <echo hash=""> 1502819202.34)
sending (FN 3f1d120... 1502983026.6)
received message[ ('13.93.211.84', 30303) ]:
 Verified message hash.
 Verified signature.
 received Neighbors
 (Ns [(N 9e44f97...), (N 112917b...), (N ebf683d...), (N 2232e47...), (N f6ff826...), (N 7524431...), (N 804613e...), (N 78e5ce9...), (N c6dd88f...), (N 1dbf854...), (N 48a80a9...), (N 8b6c265...)] 1502982991)
received message[ ('13.93.211.84', 30303) ]:
 Verified message hash.
 Verified signature.
 received Neighbors
 (Ns [(N 8567bc4...), (N bf48f6a...), (N f8cb486...), (N 8e7e82e...)] 1502982991)

引导节点分两个数据包响应了16个邻居节点。

下一次,我们将构建一个流程来爬取这些邻居直到我们有足够的对等端点可以同步区块链。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 162,408评论 4 371
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,690评论 2 307
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 112,036评论 0 255
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,726评论 0 221
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 53,123评论 3 296
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 41,037评论 1 225
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,178评论 2 318
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,964评论 0 213
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,703评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,863评论 2 254
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,333评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,658评论 3 263
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,374评论 3 244
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,195评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,988评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,167评论 2 285
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,970评论 2 279

推荐阅读更多精彩内容