Skip to content

Commit ee9efc8

Browse files
ADR 30: Bolt Handshake Manifest v1 (#619)
* Bolt Handshake Manifest v1 * add unit tests * use back when selecting protocol * better formatting of protocol Co-authored-by: Robsdedude <dev@rouvenbauer.de> * remove echoing back of capabilities Co-authored-by: Robsdedude <dev@rouvenbauer.de> * remove redundant params Co-authored-by: Robsdedude <dev@rouvenbauer.de> * size and capacity update for supported protocols slice Co-authored-by: Robsdedude <dev@rouvenbauer.de> * pr feedback * refactor to use encoding binary library * refactored selectProtocol * fix byte overflow in select protocol * better error handling of incorrect protocol version * remove 4.1 check and redundant unit test * Minor code simplification * Optimize: make readVarInt return read bytes * use binary const instead Co-authored-by: Robsdedude <dev@rouvenbauer.de> * use binary const instead Co-authored-by: Robsdedude <dev@rouvenbauer.de> * add unit test for invalid uint64 --------- Co-authored-by: Robsdedude <dev@rouvenbauer.de> Co-authored-by: Robsdedude <rouven.bauer@neo4j.com>
1 parent 15e6f76 commit ee9efc8

6 files changed

Lines changed: 488 additions & 53 deletions

File tree

neo4j/internal/bolt/bolt4.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -236,11 +236,8 @@ func (b *bolt4) Connect(
236236
hello := map[string]any{
237237
"user_agent": userAgent,
238238
}
239-
// On bolt >= 4.1 add routing to enable/disable routing
240-
if b.minor >= 1 {
241-
if routingContext != nil {
242-
hello["routing"] = routingContext
243-
}
239+
if routingContext != nil {
240+
hello["routing"] = routingContext
244241
}
245242
checkUtcPatch := b.minor >= 3
246243
if checkUtcPatch {

neo4j/internal/bolt/bolt4_test.go

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -267,37 +267,6 @@ func TestBolt4(outer *testing.T) {
267267
bolt.Close(context.Background())
268268
})
269269

270-
outer.Run("No routing in hello on 4.0", func(t *testing.T) {
271-
routingContext := map[string]string{"some": "thing"}
272-
conn, srv, cleanup := setupBolt4Pipe(t)
273-
defer cleanup()
274-
go func() {
275-
srv.waitForHandshake()
276-
srv.acceptVersion(4, 0)
277-
hmap := srv.waitForHello()
278-
_, exists := hmap["routing"].(map[string]any)
279-
if exists {
280-
panic("Should be no routing entry")
281-
}
282-
srv.acceptHello()
283-
}()
284-
bolt, err := Connect(
285-
context.Background(),
286-
"serverName",
287-
conn,
288-
auth,
289-
"007",
290-
routingContext,
291-
nil,
292-
logger,
293-
nil,
294-
idb.NotificationConfig{},
295-
DefaultReadBufferSize,
296-
)
297-
AssertNoError(t, err)
298-
bolt.Close(context.Background())
299-
})
300-
301270
outer.Run("Failed authentication", func(t *testing.T) {
302271
conn, srv, cleanup := setupBolt4Pipe(t)
303272
defer cleanup()

neo4j/internal/bolt/connect.go

Lines changed: 194 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@ package bolt
2020

2121
import (
2222
"context"
23+
"encoding/binary"
2324
"fmt"
25+
"io"
2426
"net"
27+
"strings"
2528

2629
"github.com/neo4j/neo4j-go-driver/v5/neo4j/internal/db"
2730
"github.com/neo4j/neo4j-go-driver/v5/neo4j/internal/errorutil"
@@ -35,12 +38,18 @@ type protocolVersion struct {
3538
back byte // Number of minor versions back
3639
}
3740

38-
// Supported versions in priority order
41+
func (p *protocolVersion) formatProtocol() string {
42+
return fmt.Sprintf("%#04X%02X%02X", p.back, p.minor, p.major)
43+
}
44+
45+
// versions lists the supported protocol versions in priority order.
46+
// The first proposal is a marker indicating that the client wishes to use the
47+
// new manifest-style negotiation.
3948
var versions = [4]protocolVersion{
49+
{major: 0xFF, minor: 0x01, back: 0x00}, // Bolt manifest marker
4050
{major: 5, minor: 7, back: 7},
4151
{major: 4, minor: 4, back: 2},
42-
{major: 4, minor: 1},
43-
{major: 3, minor: 0},
52+
{major: 3, minor: 0, back: 0},
4453
}
4554

4655
// Connect initiates the negotiation of the Bolt protocol version.
@@ -70,6 +79,7 @@ func Connect(ctx context.Context,
7079
boltLogger.LogClientMessage("", "<MAGIC> %#010X", handshake[0:4])
7180
boltLogger.LogClientMessage("", "<HANDSHAKE> %#010X %#010X %#010X %#010X", handshake[4:8], handshake[8:12], handshake[12:16], handshake[16:20])
7281
}
82+
// Write handshake proposals to server
7383
_, err := racing.NewRacingWriter(conn).Write(ctx, handshake)
7484
if err != nil {
7585
errorListener.OnDialError(ctx, serverName, err)
@@ -84,14 +94,29 @@ func Connect(ctx context.Context,
8494
return nil, err
8595
}
8696

87-
if boltLogger != nil {
88-
boltLogger.LogServerMessage("", "<HANDSHAKE> %#010X", buf)
89-
}
90-
9197
major := buf[3]
9298
minor := buf[2]
9399

100+
if major == 80 && minor == 84 {
101+
return nil, &errorutil.UsageError{Message: "server responded HTTP. Make sure you are not trying to connect to the http endpoint " +
102+
"(HTTP defaults to port 7474 whereas BOLT defaults to port 7687)"}
103+
}
104+
105+
// Log legacy handshake response.
106+
if !(major == 0xFF && minor == 0x01) && boltLogger != nil {
107+
boltLogger.LogServerMessage("", "<HANDSHAKE> %#010X", buf)
108+
}
109+
94110
bufferedConn := bufferedConnection(conn, readBufferSize)
111+
112+
// If the server selected manifest negotiation, perform the extended handshake.
113+
if major == 0xFF && minor == 0x01 {
114+
major, minor, err = performManifestNegotiation(ctx, bufferedConn, serverName, errorListener, boltLogger, buf)
115+
if err != nil {
116+
return nil, err
117+
}
118+
}
119+
95120
var boltConn db.Connection
96121
switch major {
97122
case 3:
@@ -103,10 +128,6 @@ func Connect(ctx context.Context,
103128
case 0:
104129
return nil, fmt.Errorf("server did not accept any of the requested Bolt versions (%#v)", versions)
105130
default:
106-
if major == 80 && minor == 84 {
107-
return nil, &errorutil.UsageError{Message: "server responded HTTP. Make sure you are not trying to connect to the http endpoint " +
108-
"(HTTP defaults to port 7474 whereas BOLT defaults to port 7687)"}
109-
}
110131
return nil, &errorutil.UsageError{Message: fmt.Sprintf("server responded with unsupported version %d.%d", major, minor)}
111132
}
112133
if err = boltConn.Connect(ctx, int(minor), auth, userAgent, routingContext, notificationConfig); err != nil {
@@ -115,3 +136,165 @@ func Connect(ctx context.Context,
115136
}
116137
return boltConn, nil
117138
}
139+
140+
// performManifestNegotiation handles the manifest-style handshake.
141+
// Returns the negotiated protocol's major and minor version.
142+
func performManifestNegotiation(
143+
ctx context.Context,
144+
conn io.ReadWriteCloser,
145+
serverName string,
146+
errorListener ConnectionErrorListener,
147+
boltLogger log.BoltLogger,
148+
response []byte,
149+
) (byte, byte, error) {
150+
reader := racing.NewRacingReader(conn)
151+
152+
// Read the protocol offerings.
153+
supported, err := readProtocolOfferings(ctx, reader, serverName, errorListener)
154+
if err != nil {
155+
return 0, 0, err
156+
}
157+
158+
// Read the capability mask.
159+
_, capBytes, err := readCapabilityMask(ctx, reader, serverName, errorListener)
160+
if err != nil {
161+
return 0, 0, err
162+
}
163+
164+
// Log the complete server handshake message.
165+
logManifestHandshake(boltLogger, response, supported, capBytes)
166+
167+
// Select an acceptable protocol version.
168+
chosen, err := selectProtocol(supported)
169+
if err != nil {
170+
errorListener.OnDialError(ctx, serverName, err)
171+
// Best-effort attempt to send an invalid handshake (ignore any error).
172+
invalidHandshake := []byte{0x00, 0x00, 0x00, 0x00, 0x00} // 4 bytes for version + 1 byte for capabilities.
173+
_, _ = racing.NewRacingWriter(conn).Write(ctx, invalidHandshake)
174+
return 0, 0, err
175+
}
176+
177+
// Send the handshake confirmation.
178+
if err = sendHandshakeConfirmation(ctx, conn, boltLogger, errorListener, serverName, chosen, []byte{0x00}); err != nil {
179+
return 0, 0, err
180+
}
181+
182+
return chosen.major, chosen.minor, nil
183+
}
184+
185+
// readProtocolOfferings reads the number of protocol offerings and returns the count and
186+
// a slice of supported protocol versions.
187+
func readProtocolOfferings(ctx context.Context, r racing.RacingReader, serverName string, errorListener ConnectionErrorListener) ([]protocolVersion, error) {
188+
count, _, err := readVarInt(ctx, r)
189+
if err != nil {
190+
errorListener.OnDialError(ctx, serverName, err)
191+
return nil, fmt.Errorf("failed to read manifest protocol count: %w", err)
192+
}
193+
supported := make([]protocolVersion, 0, count)
194+
for i := uint64(0); i < count; i++ {
195+
var versionBytes [4]byte
196+
_, err := r.ReadFull(ctx, versionBytes[:])
197+
if err != nil {
198+
errorListener.OnDialError(ctx, serverName, err)
199+
return nil, fmt.Errorf("failed to read manifest protocol version: %w", err)
200+
}
201+
supported = append(supported, protocolVersion{
202+
back: versionBytes[1],
203+
minor: versionBytes[2],
204+
major: versionBytes[3],
205+
})
206+
}
207+
return supported, nil
208+
}
209+
210+
// readCapabilityMask reads the capability bit mask (a Base128 VarInt) and returns both the
211+
// raw value and its encoded byte slice.
212+
func readCapabilityMask(ctx context.Context, r racing.RacingReader, serverName string, errorListener ConnectionErrorListener) (uint64, []byte, error) {
213+
capMask, capBytes, err := readVarInt(ctx, r)
214+
if err != nil {
215+
errorListener.OnDialError(ctx, serverName, err)
216+
return 0, capBytes, fmt.Errorf("failed to read capability mask: %w", err)
217+
}
218+
return capMask, capBytes, nil
219+
}
220+
221+
// logManifestHandshake logs the complete server handshake message for manifest negotiation.
222+
// It prints the initial response, count of offerings, each supported protocol, and the capability mask.
223+
func logManifestHandshake(boltLogger log.BoltLogger, response []byte, supported []protocolVersion, capBytes []byte) {
224+
if boltLogger == nil {
225+
return
226+
}
227+
supportedProtocols := make([]string, 0, len(supported))
228+
for _, p := range supported {
229+
supportedProtocols = append(supportedProtocols, p.formatProtocol())
230+
}
231+
boltLogger.LogServerMessage("", "<HANDSHAKE> %s [%d] %s %s",
232+
fmt.Sprintf("%#X", response),
233+
len(supported),
234+
strings.Join(supportedProtocols, " "),
235+
fmt.Sprintf("%#X", capBytes))
236+
}
237+
238+
// selectProtocol iterates over our protocol proposals (skipping the manifest marker)
239+
// and returns the first candidate whose major version matches and whose minor version
240+
// falls within the range offered by the server.
241+
func selectProtocol(offers []protocolVersion) (protocolVersion, error) {
242+
for _, candidate := range versions[1:] {
243+
for v := int(candidate.minor); v >= int(candidate.minor)-int(candidate.back); v-- {
244+
for _, offer := range offers {
245+
if offer.major != candidate.major {
246+
continue
247+
}
248+
if byte(v) <= offer.minor && byte(v) >= offer.minor-offer.back {
249+
return protocolVersion{major: candidate.major, minor: byte(v)}, nil
250+
}
251+
}
252+
}
253+
}
254+
return protocolVersion{}, fmt.Errorf("none of the server offered Bolt versions are supported (offered: %#v)", offers)
255+
}
256+
257+
// sendHandshakeConfirmation sends the chosen protocol version and capability mask back to the server.
258+
func sendHandshakeConfirmation(ctx context.Context, conn io.ReadWriteCloser, boltLogger log.BoltLogger, errorListener ConnectionErrorListener, serverName string, chosen protocolVersion, capBytes []byte) error {
259+
chosenBytes := []byte{0x00, 0x00, chosen.minor, chosen.major}
260+
if boltLogger != nil {
261+
boltLogger.LogClientMessage("", "<HANDSHAKE> %#X %#X", chosenBytes, capBytes)
262+
}
263+
writer := racing.NewRacingWriter(conn)
264+
if _, err := writer.Write(ctx, chosenBytes); err != nil {
265+
errorListener.OnDialError(ctx, serverName, err)
266+
return err
267+
}
268+
if _, err := writer.Write(ctx, capBytes); err != nil {
269+
errorListener.OnDialError(ctx, serverName, err)
270+
return err
271+
}
272+
return nil
273+
}
274+
275+
// readVarInt returns a Base128-encoded variable-length integer from the reader.
276+
func readVarInt(ctx context.Context, r racing.RacingReader) (uint64, []byte, error) {
277+
var buf [binary.MaxVarintLen64]byte
278+
// Read one byte at a time until a byte with the MSB not set is encountered.
279+
for i := 0; i < len(buf); i++ {
280+
if _, err := r.Read(ctx, buf[i:i+1]); err != nil {
281+
return 0, buf[:i], err
282+
}
283+
// If the continuation bit is not set, we've reached the end of the varint.
284+
if buf[i]&0x80 == 0 {
285+
value, n := binary.Uvarint(buf[:i+1])
286+
if n <= 0 {
287+
return 0, buf[:i+1], fmt.Errorf("failed to decode varint")
288+
}
289+
return value, buf[:i+1], nil
290+
}
291+
}
292+
return 0, buf[:], fmt.Errorf("varint too long")
293+
}
294+
295+
// encodeVarInt returns the encoded unsigned integer into a Base128 variable-length integer.
296+
func encodeVarInt(value uint64) []byte {
297+
var buf [binary.MaxVarintLen64]byte
298+
n := binary.PutUvarint(buf[:], value)
299+
return buf[:n]
300+
}

0 commit comments

Comments
 (0)