sourcegraph / gophercon-2018-liveblog

Documents how to write a great liveblog post and how to submit your post for the GopherCon 2018 liveblog hosted by Sourcegraph at https://sourcegraph.com/gophercon
1 stars 2 forks source link

Implementing a Network Protocol in Go #26

Closed beyang closed 6 years ago

beyang commented 6 years ago

Presenter: Matt Layher

Liveblogger: Beyang Liu

An very detailed walkthrough of implementing a networking protocol (NDP in IPv6) in Go, with many, many code snippets.


Matt Layher (@mdlayher, talks) is an engineer at Digital Ocean.

Intro

Outline:

Intro to IPv6

IPv6 adoption:

image

What is IPv6?

How is IPv6 different from IPv4?

IPv6 tips and tricks

Intro to NDP

What is NDP?

image

image

IPv6 and NDP’s big advantage:

SLAAC via NDP

NDP and Go

Package ndp overview

How do we go from bytes to a complete NDP package?

From bytes to messages

NDP message basics:

Parsing bytes:

ndp.Message interface

// A Message is a Neighbor Discovery Protocol message.
type Message interface {
    // Type specifies the ICMPv6 type for a Message.
    Type() ipv6.ICMPType

    // Called via MarshalMessage and ParseMessage.
    marshal() ([]byte, error)
    unmarshal(b []byte) error
}

ndp.ParseMessage

ndp.ParseMessage function does bounds checking validation, determines concrete type, continues parsing:

func ParseMessage(b []byte) (Message, error) {
    // Bounds check!!!

    // Determine ndp.Message from ICMPv6 type.

    // Unmarshal ICMPv6 data into ndp.Message implementation.
}

Bounds checking: when using slice elements, you must perform bounds checks to avoid panics:

// The ICMPv6 header is fixed length.
const icmpLen = 4
if len(b) < icmpLen {
    return nil, io.ErrUnexpectedEOF
}

Determining ndp.Message type: use a switch to choose the right interface implementation:

// Select the correct ndp.Message type based on ICMPv6 header.
var m Message
switch t := ipv6.ICMPType(b[0]); t {
case ipv6.ICMPTypeNeighborSolicitation:
    m = new(NeighborSolicitation)
default:
    return nil, fmt.Errorf("ndp: unrecognized ICMPv6 type: %d", t)
}

Unmarshal the ndp.Message implementation: call into the type’s methods to do the rest of the work, skipping the header

// Unmarshal remaining bytes into correct ndp.Message type.
if err := m.unmarshal(b[icmpLen:]); err != nil {
    return nil, err
}

A couple of comments on the parsing logic:

Our first ndp.Message implementation

What an ICMPv6 + NDP NS message looks like:

image

The ndp.NeighborSolicitation type mimics the structure defined by the RFC, using doc comments to provide references:

Mimic structure defined by RFC, use doc comments to provide references

// A NeighborSolicitation is a Neighbor Solicitation message as
// described in RFC 4861, Section 4.3.
type NeighborSolicitation struct {
    TargetAddress net.IP
    Options       []Option
}

(Neat godoc feature: it will automatically hyperlink to the RFC as defined above in the comments.)

Checking for IPv4 and IPv6 addresses:

checkIPv6 function:

// checkIPv6 verifies that ip is an IPv6 address.
func checkIPv6(ip net.IP) error {
    // To16 returns nil when ip is not a valid IPv4/IPv6 address.
//
// To4 returns non-nil when ip is an IPv4 address.
    if ip.To16() == nil || ip.To4() != nil {
        return fmt.Errorf("ndp: invalid IPv6 address: %q",
ip.String())
    }

    return nil
}

ndp.NeighborSolicitation unmarshaling validates incoming bytes and replaces the structure all at once:

func (ns *NeighborSolicitation) unmarshal(b []byte) error {
        // Bounds checking!!! (don't want to get paged at 3am in the morning because your code panicked)

    // Validation

    // Replacing contents of the NeighborSolicitation
}

To validate byte inputs, ensure that field values make sense, typically using rules defined by RFC. I.e., verify that we don't have any sneaky IPv4 addresses:

// Skip reserved area.
addr := b[4:nsLen]
if err := checkIPv6(addr); err != nil {
    return err
}

To replace the structure while unmarshaling, (1) dereference the pointer and replace contents with completed structure. (2) Always make a copy of data from the input slice; don’t assume it’s safe to retain:

*ns = NeighborSolicitation{
    TargetAddress: make(net.IP, net.IPv6len),
    Options:       options,
}

copy(ns.TargetAddress, addr)

From messages to bytes

Things to remember when marshaling messages:

ndp.MarshalMessage function

Marshal an ndp.Message into binary, prepend ICMPv6 header

func MarshalMessage(m Message) ([]byte, error) {
    // Call m’s marshal method

    // Pack bytes into an ICMPv6 header
}

When marshaling ndp.Messages, simplicity wins. Allocating is okay until your performance needs are not met:

mb, err := m.marshal()
if err != nil {
    return nil, err
}

Same goes for ICMPv6 messages:

im := icmp.Message{
    Type: m.Type(),
    Body: &icmp.DefaultMessageBody{
        Data: mb,
    },
}

return im.Marshal(nil)

ndp.NeighborSolicitation marshaling

Validate before you allocate:

func (ns *NeighborSolicitation) marshal() ([]byte, error) {
    // Validation

    // Allocation
}

Don’t bother allocating memory until you’ve checked your inputs:

// Only accept IPv6 target.
if err := checkIPv6(ns.TargetAddress); err != nil {
    return nil, err
}

Allocate once, if possible (allocating once is ideal for speed, but keep it simple):

// Allocate enough space for base message.
b := make([]byte, nsLen)
copy(b[4:], ns.TargetAddress)

// Append any option bytes.
ob, err := marshalOptions(ns.Options)
if err != nil {
    return nil, err
}
b = append(b, ob...)

When allocating memory...

ndp.Message API

ndp.Message types:

image

ndp.Message usage:

m := &ndp.NeighborSolicitation{
    TargetAddress: target,
    Options: []ndp.Option{&ndp.LinkLayerAddress{
        Direction: ndp.Source,
        Addr:      addr,
    }},
}

b, err := ndp.MarshalMessage(m)
if err != nil {
    return fmt.Errorf("failed to marshal: %v", err)
}
m, err := ndp.ParseMessage(b[:n])
if err != nil {
    return fmt.Errorf("failed to parse: %v", err)
}

switch m := m.(type) {
case *ndp.NeighborAdvertisement:
    printNA(m)
case *ndp.NeighborSolicitation:
    printNS(m)
default:
        log.Printf("%#v", m)
}

From bytes to options

NDP option basics

Options are encoded in type, length, value (TLV) format:

TLV options: image

ndp.Option interface

// An Option is a Neighbor Discovery Protocol option.
type Option interface {
    // Code specifies the NDP option code for an Option.
    Code() uint8

    // Called when dealing with a Message's Options.
    marshal() ([]byte, error)
    unmarshal(b []byte) error
}

Parsing options:

marshalOptions function

// marshalOptions marshals Options into a single byte slice.
func marshalOptions(options []Option) ([]byte, error) {
    // For each option…

        // Marshal the option

        // Append it to the output
}

parseOptions function

Parsing is just a little trickier:

// parseOptions parses a slice of Options from a byte slice.
func parseOptions(b []byte) ([]Option, error) {
    // Iterate until no bytes remain…

        // Bounds check!!!

// Read 2 bytes: type/length

// Determine if option is known

// Append to output slice
}

ndp.Option types include the following:

Tips for implementing options:

ndp.RawOption type

It directly exposes the TLV fields, so code outside this package can pass options that aren't defined in this package. If a particular type of option is used often enough, we can add first-class support for it later.

// A RawOption is an Option in its raw and unprocessed format.
// Unknown Options can be represented using a RawOption.
type RawOption struct {
    Type   uint8
    Length uint8
    Value  []byte
}

// Code implements Option.
func (r *RawOption) Code() byte { return r.Type }

ndp.Option types: image

ndp.Option usage:

var ra ndp.RouterAdvertisement
ra.Options = []ndp.Option{
&ndp.LinkLayerAddress{
        Direction: ndp.Source,
    Addr:      addr,
},
ndp.NewMTU(1500),
    &ndp.PrefixInformation{
        PrefixLength: 32,
        Prefix:       net.ParseIP("2001:db8::"),
        SLAAC:        true,
    },
}

Fuzzing byte parsers

Fuzzing lets you catch and prevent errors arising from unexpected and unhandled input cases. E.g., avoid errors like this one:

panic: runtime error: slice bounds out of range

goroutine 127 [running]:
testing.tRunner.func1(0xc4201453b0)
        /usr/local/go/src/testing/testing.go:742 +0x29d
panic(0x5dd240, 0x7378d0)
        /usr/local/go/src/runtime/panic.go:502 +0x229
github.com/mdlayher/ndp.(*NeighborAdvertisement).unmarshal(0xc42013d280, 0xc4200dd924, 0x10, 0x1c, 0xc4200dd920, 0x10)
        /home/matt/src/github.com/mdlayher/ndp/message.go:149 +0x2b1
github.com/mdlayher/ndp.ParseMessage(0xc4200dd920, 0x14, 0x20, 0x4, 0x14, 0xc4200dd920, 0x4)
        /home/matt/src/github.com/mdlayher/ndp/message.go:85 +0x168
github.com/mdlayher/ndp_test.TestParseMessageError.func1.1(0xc4201453b0)
        /home/matt/src/github.com/mdlayher/ndp/message_test.go:176 +0xd3
testing.tRunner(0xc4201453b0, 0xc420141440)
        /usr/local/go/src/testing/testing.go:777 +0xd0
created by testing.(*T).Run
        /usr/local/go/src/testing/testing.go:824 +0x2e0

Enter Dmitry Vyukov's go-fuzz. If you’re parsing raw bytes, there’s a high potential for unexpected behavior:

github.com/dvyukov/go-fuzz address this problem:

go-fuzz setup:

//+build gofuzz

package ndp

// Fuzz is an entry point for go-fuzz.
func Fuzz(data []byte) int {
    return fuzz(data)
}

func fuzz(data []byte) int {
    m, err := ParseMessage(data)
    if err != nil {
        return 0 // Invalid, not interesting!
    }
    b2, err := MarshalMessage(m)
    if err != nil {
        panic(err)
    }
    if _, err := ParseMessage(b2); err != nil {
        panic(err)
    }
    return 1 // Valid, interesting!
}

go-fuzz usage:

go-fuzz conclusions: Use it! Use go-fuzz on ALL byte parsers: github.com/dvyukov/go-fuzz

ndp.Conn

Let's implement the struct that represents an NDP connection.

“Conn” types represent network connections. They typically have the following:

net vs x/net

ICMPv6 networking packages in Go:

Here's how you create a ICMPv6 listener (this is a privileged operation, usually requires root):

// Open raw ICMPv6 listener on eth0’s link-local address.
addr := "fe80::7d64:35ff:fee7:cbc4%eth0"
ic, err := icmp.ListenPacket("ip6:ipv6-icmp", addr)
if err != nil {
    return err
}

Reading ICMPv6 messages is similar to standard APIs, but also returns IPv6 control messages:

b := make([]byte, 1024)
n, cm, src, err := c.pc.ReadFrom(b)
if err != nil {
    return nil, nil, nil, err
}

return b[:n], cm, src.IP, nil

Writing ICMPv6 messages is similar to standard APIs, but you can specify IPv6 control messages:

// Write bytes to the specified target.
_, err := c.pc.WriteTo(b, cm, &net.IPAddr{
    IP:   ip,
    Zone: c.ifi.Name,
})
return err

ndp.Conn usage:

Create an ndp.Conn by selecting an interface, dialing ICMPv6, and specifying an address to listen on:

ifi, err := net.InterfaceByName("eth0")
if err != nil {
    log.Fatalf("failed to get interface: %v", err)
}

// Dial IPv6 + ICMPv6 connection.
c, ip, err := ndp.Dial(ifi, ndp.LinkLocal)
if err != nil {
    log.Fatalf("failed to dial NDP: %v", err)
}

How to read messages? Here's a code snippet to keep reading and printing messages until an error occurs:

for {
msg, _, from, err := c.ReadFrom()
if err != nil {
    return nil, err
}

printMessage(msg, from)
}

Writing ndp.Messages: Send a router solicitation to trigger router advertisements on the network:

m := &ndp.RouterSolicitation{
    Options: []ndp.Option{&ndp.LinkLayerAddress{
            Direction: ndp.Source, Addr: addr,
    }},
}

dst := net.IPv6linklocalallrouters
if err := c.WriteTo(m, nil, dst); err != nil {
    return nil, err
}

Build a tool to test your package

Add a cmd/ directory with a testing utility during development

Introducing the ndp tool:

$ ndp [listen]
# Listens for any NDP messages that pass through the interface
$ ndp rs
# Sends a router solicitation; wait for a router advertisement
$ ndp -t fd00::1 ns
# Sends a neighbor solicitation; wait for a neighbor advertisement

Easier to use than tcpdump. E.g., here's the tcpdump command and output to watch for NDP packets:

$ sudo tcpdump -i enp4s0 'icmp6 && (ip6[40] == 133 or ip6[40] == 134)'
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on enp4s0, link-type EN10MB (Ethernet), capture size 262144 bytes
16:02:42.774725 IP6 nerr-2 > ip6-allrouters: ICMP6, router solicitation, length 16
16:02:42.777116 IP6 _gateway > ip6-allnodes: ICMP6, router advertisement, length 88

Compare that with ndp:

$ sudo ./bin/ndp rs
ndp> interface: enp4s0, link-layer address: 74:d4:35:e7:cb:c4, IPv6 address: fe80::e563:9887:3aca:e01e
ndp rs> router solicitation:
    - source link-layer address: 74:d4:35:e7:cb:c4

ndp rs> router advertisement from: fe80::618:d6ff:fea1:ceb7:
    - hop limit:        64
    - router lifetime:  30m0s
    - options:
        - prefix information: 2600:6c4a:787f:d200::/64, flags: [OA], valid: 24h0m0s, preferred: 4h0m0s
        - prefix information: fd00::/64, flags: [OA], valid: 24h0m0s, preferred: 4h0m0s
        - source link-layer address: 04:18:d6:a1:ce:b7

Troubleshooting your ISP’s equipment with Go

You can also use Go to troubleshoot any difficulties your ISP has with IPv6.

Ubiquiti EdgeRouter Lite can run Go programs:

desktop $ GOARCH=mips64 go build -o ndp_mips64
desktop $ scp ndp_mips64 router:~/ndp
 router $ sudo ./ndp -i eth1 rs
$ sudo ./ndp -i eth1 rs
ndp> interface: eth1, link-layer address: 04:18:d6:a1:ce:b7, IPv6 address: fe80::618:d6ff:fea1:ceb7
ndp rs> router solicitation:
    - source link-layer address: 04:18:d6:a1:ce:b7
..............................................................................................^C
ndp rs> sent 95 router solicitation(s)

Conclusions

Resources:

ryan-blunden commented 6 years ago

Its live:

Post: https://about.sourcegraph.com/go/gophercon-2018-implementing-a-network-protocol-in-go/ Tweet: https://twitter.com/srcgraph/status/1034927484397998081