diff --git a/cmd/devp2p/internal/ethtest/conn.go b/cmd/devp2p/internal/ethtest/conn.go index 02579f8b55..2de51069c3 100644 --- a/cmd/devp2p/internal/ethtest/conn.go +++ b/cmd/devp2p/internal/ethtest/conn.go @@ -17,6 +17,7 @@ package ethtest import ( + "bytes" "crypto/ecdsa" "errors" "fmt" @@ -132,6 +133,31 @@ func (c *Conn) Write(proto Proto, code uint64, msg any) error { var errDisc error = errors.New("disconnect") +// decodeDisconnect parses a disconnect message payload. Per the RLPx spec the +// payload is a list containing a single reason, but some implementations +// (including older geth) sent the reason as a bare byte. Accept both forms. +func decodeDisconnect(data []byte) (p2p.DiscReason, error) { + s := rlp.NewStream(bytes.NewReader(data), uint64(len(data))) + k, _, err := s.Kind() + if err != nil { + return 0, err + } + var reason p2p.DiscReason + if k == rlp.List { + if _, err := s.List(); err != nil { + return 0, err + } + if err := s.Decode(&reason); err != nil { + return 0, err + } + return reason, nil + } + if err := s.Decode(&reason); err != nil { + return 0, err + } + return reason, nil +} + // ReadEth reads an Eth sub-protocol wire message. func (c *Conn) ReadEth() (any, error) { c.SetReadDeadline(time.Now().Add(timeout)) @@ -343,11 +369,11 @@ loop: } return fmt.Errorf("wrong protocol version: have %v, want %v", msg.ProtocolVersion, c.caps) case discMsg: - var msg []p2p.DiscReason - if rlp.DecodeBytes(data, &msg); len(msg) == 0 { - return errors.New("invalid disconnect message") + reason, decErr := decodeDisconnect(data) + if decErr != nil { + return fmt.Errorf("invalid disconnect message: %v (raw=0x%x)", decErr, data) } - return fmt.Errorf("disconnect received: %v", pretty.Sdump(msg)) + return fmt.Errorf("disconnect received: %v", reason) case pingMsg: // TODO (renaynay): in the future, this should be an error // (PINGs should not be a response upon fresh connection) diff --git a/cmd/devp2p/internal/ethtest/conn_test.go b/cmd/devp2p/internal/ethtest/conn_test.go new file mode 100644 index 0000000000..0b50ca3463 --- /dev/null +++ b/cmd/devp2p/internal/ethtest/conn_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of go-ethereum. +// +// go-ethereum is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// go-ethereum is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with go-ethereum. If not, see . + +package ethtest + +import ( + "testing" + + "github.com/ethereum/go-ethereum/p2p" +) + +func TestDecodeDisconnect(t *testing.T) { + tests := []struct { + name string + payload []byte + want p2p.DiscReason + wantErr bool + }{ + { + name: "list form (spec-compliant)", + payload: []byte{0xc1, 0x04}, // [4] = TooManyPeers + want: p2p.DiscTooManyPeers, + }, + { + name: "list form with reason zero", + payload: []byte{0xc1, 0x80}, // [0] = Requested + want: p2p.DiscRequested, + }, + { + name: "bare byte form (legacy geth)", + payload: []byte{0x04}, // 4 = TooManyPeers + want: p2p.DiscTooManyPeers, + }, + { + name: "bare byte form zero", + payload: []byte{0x80}, // 0 = Requested + want: p2p.DiscRequested, + }, + { + name: "empty payload", + payload: []byte{}, + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := decodeDisconnect(tc.payload) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got reason=%v", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("got reason %v, want %v", got, tc.want) + } + }) + } +}