Repository: metallb/metallb
Pull request #958 (2021-09-17 16:42:25)

FRR: Implementation of BGP interface for FRR
This pull request is an initial attempt at implementing the MetalLB BGP interface for FRR.

It is not complete as:
* does not handle all BGP configurations (e.g. communities)
* does not integrate with FRR itself (only generated configuration) as this requires https://github.com/metallb/metallb/pull/935
* the translation between MetalLB configuration and FRR configuration may not be completely accurate. This will require further integration with FRR to confirm.

cgoncalves: LGTM. This PR provides a good foundation. PR #935 will add the FRR reloader part and PR #969 will integrate the two by signaling the FRR reloader that an updated configuration has been generated and should be applied.

Thank you very much for considering my review comments and cherry-picking suggested commits into this PR!
fedepaol: A super minor comment about the `Commint` name, but looks great!
LGTM
fedepaol: Should we squash the commits?
markdgray: @fede I will squash them up to the `commit()` one. However, I think we should revert the last one about lists/maps as we discussed previously?
fedepaol: > @fede I will squash them up to the `commit()` one. However, I think we should revert the last one about lists/maps as we discussed previously?

Yep, makes sense to me
fede: Hey, I think you are mentioning the wrong username :)
markdgray: > Hey, I think you are mentioning the wrong username :)

You are right! @fedepaol 
johananl: FYI, I've started reviewing this.
markdgray: > Thanks a lot for this effort @markdgray.
> 
> I am not done reviewing, however I already have some important feedback so submitting the current state since there is a chance it's going to affect some major things in the implementation.

Thanks for the review so far @johananl !
fedepaol: Looks great! Left a few comments, not really blocking.
markdgray: Great, I'll update
fedepaol: LGTM


Patch:
diff --git a/design/speaker.md b/design/speaker.md
index 94615bc8..be2dcdbf 100644
--- a/design/speaker.md
+++ b/design/speaker.md
@@ -98,9 +98,9 @@ New() method which are currently:
 
 * **addr**: IP address and port of peer
 * **srcAddr**: Source IP address
-* **asn**: Local ASN
+* **myASN**: Local ASN
 * **routerID**: Specify the BGP Router ID of the session
-* **peerASN**: ASN of the router that we are connecting to
+* **asn**: ASN of the router that we are connecting to
 * **holdTime**: BGP Hold Time specifies how long to maintain sessions for
 * **Password**: TCP MD5 sock option for connection
 * **myNode**
diff --git a/internal/bgp/bgp.go b/internal/bgp/bgp.go
index eb5484c3..82ba7c17 100644
--- a/internal/bgp/bgp.go
+++ b/internal/bgp/bgp.go
@@ -3,484 +3,14 @@
 package bgp // import "go.universe.tf/metallb/internal/bgp"
 
 import (
-	"bytes"
-	"context"
-	"encoding/binary"
-	"errors"
-	"fmt"
-	"hash/crc32"
 	"io"
-	"io/ioutil"
 	"net"
-	"os"
 	"reflect"
-	"sync"
-	"syscall"
 	"time"
-	"unsafe"
 
 	"github.com/go-kit/kit/log"
-	"github.com/go-kit/kit/log/level"
-	"golang.org/x/sys/unix"
 )
 
-var errClosed = errors.New("session closed")
-
-// Session represents one BGP session to an external router.
-type Session struct {
-	asn              uint32
-	routerID         net.IP // May be nil, meaning "derive from context"
-	myNode           string
-	addr             string
-	srcAddr          net.IP
-	peerASN          uint32
-	peerFBASNSupport bool
-	holdTime         time.Duration
-	logger           log.Logger
-	password         string
-
-	newHoldTime chan bool
-	backoff     backoff
-
-	mu             sync.Mutex
-	cond           *sync.Cond
-	closed         bool
-	conn           net.Conn
-	actualHoldTime time.Duration
-	defaultNextHop net.IP
-	advertised     map[string]*Advertisement
-	new            map[string]*Advertisement
-}
-
-// run tries to stay connected to the peer, and pumps route updates to it.
-func (s *Session) run() {
-	defer stats.DeleteSession(s.addr)
-	for {
-		if err := s.connect(); err != nil {
-			if err == errClosed {
-				return
-			}
-			level.Error(s.logger).Log("op", "connect", "error", err, "msg", "failed to connect to peer")
-			backoff := s.backoff.Duration()
-			time.Sleep(backoff)
-			continue
-		}
-		stats.SessionUp(s.addr)
-		s.backoff.Reset()
-
-		level.Info(s.logger).Log("event", "sessionUp", "msg", "BGP session established")
-
-		if !s.sendUpdates() {
-			return
-		}
-		stats.SessionDown(s.addr)
-		level.Warn(s.logger).Log("event", "sessionDown", "msg", "BGP session down")
-	}
-}
-
-// sendUpdates waits for changes to desired advertisements, and pushes
-// them out to the peer.
-func (s *Session) sendUpdates() bool {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	if s.closed {
-		return false
-	}
-	if s.conn == nil {
-		return true
-	}
-
-	ibgp := s.asn == s.peerASN
-	fbasn := s.peerFBASNSupport
-
-	if s.new != nil {
-		s.advertised, s.new = s.new, nil
-	}
-
-	for c, adv := range s.advertised {
-		if err := sendUpdate(s.conn, s.asn, ibgp, fbasn, s.defaultNextHop, adv); err != nil {
-			s.abort()
-			level.Error(s.logger).Log("op", "sendUpdate", "ip", c, "error", err, "msg", "failed to send BGP update")
-			return true
-		}
-		stats.UpdateSent(s.addr)
-	}
-	stats.AdvertisedPrefixes(s.addr, len(s.advertised))
-
-	for {
-		for s.new == nil && s.conn != nil {
-			s.cond.Wait()
-		}
-
-		if s.closed {
-			return false
-		}
-		if s.conn == nil {
-			return true
-		}
-		if s.new == nil {
-			// nil is "no pending updates", contrast to a non-nil
-			// empty map which means "withdraw all".
-			continue
-		}
-
-		for c, adv := range s.new {
-			if adv2, ok := s.advertised[c]; ok && adv.Equal(adv2) {
-				// Peer already has correct state for this
-				// advertisement, nothing to do.
-				continue
-			}
-
-			if err := sendUpdate(s.conn, s.asn, ibgp, fbasn, s.defaultNextHop, adv); err != nil {
-				s.abort()
-				level.Error(s.logger).Log("op", "sendUpdate", "prefix", c, "error", err, "msg", "failed to send BGP update")
-				return true
-			}
-			stats.UpdateSent(s.addr)
-		}
-
-		wdr := []*net.IPNet{}
-		for c, adv := range s.advertised {
-			if s.new[c] == nil {
-				wdr = append(wdr, adv.Prefix)
-			}
-		}
-		if len(wdr) > 0 {
-			if err := sendWithdraw(s.conn, wdr); err != nil {
-				s.abort()
-				for _, pfx := range wdr {
-					level.Error(s.logger).Log("op", "sendWithdraw", "prefix", pfx, "error", err, "msg", "failed to send BGP withdraw")
-				}
-				return true
-			}
-			stats.UpdateSent(s.addr)
-		}
-		s.advertised, s.new = s.new, nil
-		stats.AdvertisedPrefixes(s.addr, len(s.advertised))
-	}
-}
-
-// connect establishes the BGP session with the peer.
-// Sets TCP_MD5 sockopt if password is !="".
-func (s *Session) connect() error {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	if s.closed {
-		return errClosed
-	}
-
-	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
-	defer cancel()
-	deadline, _ := ctx.Deadline()
-	conn, err := dialMD5(ctx, s.addr, s.srcAddr, s.password)
-	if err != nil {
-		return fmt.Errorf("dial %q: %s", s.addr, err)
-	}
-
-	if err = conn.SetDeadline(deadline); err != nil {
-		conn.Close()
-		return fmt.Errorf("setting deadline on conn to %q: %s", s.addr, err)
-	}
-
-	addr, ok := conn.LocalAddr().(*net.TCPAddr)
-	if !ok {
-		conn.Close()
-		return fmt.Errorf("getting local addr for default nexthop to %q: %s", s.addr, err)
-	}
-	s.defaultNextHop = addr.IP
-
-	routerID := s.routerID
-	if routerID == nil {
-		routerID, err = getRouterID(s.defaultNextHop, s.myNode)
-		if err != nil {
-			return err
-		}
-	}
-
-	if err = sendOpen(conn, s.asn, routerID, s.holdTime); err != nil {
-		conn.Close()
-		return fmt.Errorf("send OPEN to %q: %s", s.addr, err)
-	}
-
-	op, err := readOpen(conn)
-	if err != nil {
-		conn.Close()
-		return fmt.Errorf("read OPEN from %q: %s", s.addr, err)
-	}
-	if op.asn != s.peerASN {
-		conn.Close()
-		return fmt.Errorf("unexpected peer ASN %d, want %d", op.asn, s.peerASN)
-	}
-	s.peerFBASNSupport = op.fbasn
-	if s.asn > 65536 && !s.peerFBASNSupport {
-		conn.Close()
-		return fmt.Errorf("peer does not support 4-byte ASNs")
-	}
-
-	// BGP session is established, clear the connect timeout deadline.
-	if err := conn.SetDeadline(time.Time{}); err != nil {
-		conn.Close()
-		return fmt.Errorf("clearing deadline on conn to %q: %s", s.addr, err)
-	}
-
-	// Consume BGP messages until the connection closes.
-	go s.consumeBGP(conn)
-
-	// Send one keepalive to say that yes, we accept the OPEN.
-	if err := sendKeepalive(conn); err != nil {
-		conn.Close()
-		return fmt.Errorf("accepting peer OPEN from %q: %s", s.addr, err)
-	}
-
-	// Set up regular keepalives from now on.
-	s.actualHoldTime = s.holdTime
-	if op.holdTime < s.actualHoldTime {
-		s.actualHoldTime = op.holdTime
-	}
-	select {
-	case s.newHoldTime <- true:
-	default:
-	}
-
-	s.conn = conn
-	return nil
-}
-
-func hashRouterId(hostname string) (net.IP, error) {
-	buf := new(bytes.Buffer)
-	err := binary.Write(buf, binary.LittleEndian, crc32.ChecksumIEEE([]byte(hostname)))
-	if err != nil {
-		return nil, err
-	}
-	return net.IP(buf.Bytes()), nil
-}
-
-// Ipv4; Use the address as-is.
-// Ipv6; Pick the first ipv4 address on the same interface as the address.
-func getRouterID(addr net.IP, myNode string) (net.IP, error) {
-	if addr.To4() != nil {
-		return addr, nil
-	}
-
-	ifaces, err := net.Interfaces()
-	if err != nil {
-		return hashRouterId(myNode)
-	}
-	for _, i := range ifaces {
-		addrs, err := i.Addrs()
-		if err != nil {
-			continue
-		}
-		for _, a := range addrs {
-			var ip net.IP
-			switch v := a.(type) {
-			case *net.IPNet:
-				ip = v.IP
-			case *net.IPAddr:
-				ip = v.IP
-			}
-
-			if ip.Equal(addr) {
-				// This is the interface.
-				// Loop through the addresses again and search for ipv4
-				for _, a := range addrs {
-					var ip net.IP
-					switch v := a.(type) {
-					case *net.IPNet:
-						ip = v.IP
-					case *net.IPAddr:
-						ip = v.IP
-					}
-					if ip.To4() != nil {
-						return ip, nil
-					}
-				}
-				return hashRouterId(myNode)
-			}
-		}
-	}
-	return hashRouterId(myNode)
-}
-
-// sendKeepalives sends BGP KEEPALIVE packets at the negotiated rate
-// whenever the session is connected.
-func (s *Session) sendKeepalives() {
-	var (
-		t  *time.Ticker
-		ch <-chan time.Time
-	)
-
-	for {
-		select {
-		case <-s.newHoldTime:
-			s.mu.Lock()
-			ht := s.actualHoldTime
-			s.mu.Unlock()
-			if t != nil {
-				t.Stop()
-				t = nil
-				ch = nil
-			}
-			if ht != 0 {
-				t = time.NewTicker(ht / 3)
-				ch = t.C
-			}
-
-		case <-ch:
-			if err := s.sendKeepalive(); err == errClosed {
-				// Session has been closed by package caller, we're
-				// done here.
-				return
-			}
-		}
-	}
-}
-
-// sendKeepalive sends a single BGP KEEPALIVE packet.
-func (s *Session) sendKeepalive() error {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-	if s.closed {
-		return errClosed
-	}
-	if s.conn == nil {
-		// No connection established, othing to do.
-		return nil
-	}
-	if err := sendKeepalive(s.conn); err != nil {
-		s.abort()
-		level.Error(s.logger).Log("op", "sendKeepalive", "error", err, "msg", "failed to send keepalive")
-		return fmt.Errorf("sending keepalive to %q: %s", s.addr, err)
-	}
-	return nil
-}
-
-// New creates a BGP session using the given session parameters.
-//
-// The session will immediately try to connect and synchronize its
-// local state with the peer.
-func New(l log.Logger, addr string, srcAddr net.IP, asn uint32, routerID net.IP, peerASN uint32, holdTime time.Duration, password string, myNode string) (*Session, error) {
-	ret := &Session{
-		addr:        addr,
-		srcAddr:     srcAddr,
-		asn:         asn,
-		routerID:    routerID.To4(),
-		myNode:      myNode,
-		peerASN:     peerASN,
-		holdTime:    holdTime,
-		logger:      log.With(l, "peer", addr, "localASN", asn, "peerASN", peerASN),
-		newHoldTime: make(chan bool, 1),
-		advertised:  map[string]*Advertisement{},
-		password:    password,
-	}
-	ret.cond = sync.NewCond(&ret.mu)
-	go ret.sendKeepalives()
-	go ret.run()
-
-	stats.sessionUp.WithLabelValues(ret.addr).Set(0)
-	stats.prefixes.WithLabelValues(ret.addr).Set(0)
-
-	return ret, nil
-}
-
-// consumeBGP receives BGP messages from the peer, and ignores
-// them. It does minimal checks for the well-formedness of messages,
-// and terminates the connection if something looks wrong.
-func (s *Session) consumeBGP(conn io.ReadCloser) {
-	defer func() {
-		s.mu.Lock()
-		defer s.mu.Unlock()
-		if s.conn == conn {
-			s.abort()
-		} else {
-			conn.Close()
-		}
-	}()
-
-	for {
-		hdr := struct {
-			Marker1, Marker2 uint64
-			Len              uint16
-			Type             uint8
-		}{}
-		if err := binary.Read(conn, binary.BigEndian, &hdr); err != nil {
-			// TODO: log, or propagate the error somehow.
-			return
-		}
-		if hdr.Marker1 != 0xffffffffffffffff || hdr.Marker2 != 0xffffffffffffffff {
-			// TODO: propagate
-			return
-		}
-		if hdr.Type == 3 {
-			// TODO: propagate better than just logging directly.
-			err := readNotification(conn)
-			level.Error(s.logger).Log("event", "peerNotification", "error", err, "msg", "peer sent notification, closing session")
-			return
-		}
-		if _, err := io.Copy(ioutil.Discard, io.LimitReader(conn, int64(hdr.Len)-19)); err != nil {
-			// TODO: propagate
-			return
-		}
-	}
-}
-
-// Set updates the set of Advertisements that this session's peer should receive.
-//
-// Changes are propagated to the peer asynchronously, Set may return
-// before the peer learns about the changes.
-func (s *Session) Set(advs ...*Advertisement) error {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	newAdvs := map[string]*Advertisement{}
-	for _, adv := range advs {
-		if adv.Prefix.IP.To4() == nil {
-			return fmt.Errorf("cannot advertise non-v4 prefix %q", adv.Prefix)
-		}
-
-		if adv.NextHop != nil && adv.NextHop.To4() == nil {
-			return fmt.Errorf("next-hop must be IPv4, got %q", adv.NextHop)
-		}
-		if len(adv.Communities) > 63 {
-			return fmt.Errorf("max supported communities is 63, got %d", len(adv.Communities))
-		}
-		newAdvs[adv.Prefix.String()] = adv
-	}
-
-	s.new = newAdvs
-	stats.PendingPrefixes(s.addr, len(s.new))
-	s.cond.Broadcast()
-	return nil
-}
-
-// abort closes any existing connection, updates stats, and cleans up
-// state ready for another connection attempt.
-func (s *Session) abort() {
-	if s.conn != nil {
-		s.conn.Close()
-		s.conn = nil
-		stats.SessionDown(s.addr)
-	}
-	// Next time we retry the connection, we can just skip straight to
-	// the desired end state.
-	if s.new != nil {
-		s.advertised, s.new = s.new, nil
-		stats.PendingPrefixes(s.addr, len(s.advertised))
-	}
-	s.cond.Broadcast()
-}
-
-// Close shuts down the BGP session.
-func (s *Session) Close() error {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-	s.closed = true
-	s.abort()
-	return nil
-}
-
 // Advertisement represents one network path and its BGP attributes.
 type Advertisement struct {
 	// The prefix being advertised to the peer.
@@ -508,212 +38,11 @@ func (a *Advertisement) Equal(b *Advertisement) bool {
 	return reflect.DeepEqual(a.Communities, b.Communities)
 }
 
-const (
-	// TCP MD5 Signature (RFC2385).
-	tcpMD5SIG = 14
-)
-
-// This  struct is defined at; linux-kernel: include/uapi/linux/tcp.h,
-// It  must be kept in sync with that definition, see current version:
-// https://github.com/torvalds/linux/blob/v4.16/include/uapi/linux/tcp.h#L253
-// nolint[structcheck]
-type tcpmd5sig struct {
-	ssFamily uint16
-	ss       [126]byte
-	pad1     uint16
-	keylen   uint16
-	pad2     uint32
-	key      [80]byte
+type Session interface {
+	io.Closer
+	Set(advs ...*Advertisement) error
 }
 
-// DialTCP does the part of creating a connection manually,  including setting the
-// proper TCP MD5 options when the password is not empty. Works by manupulating
-// the low level FD's, skipping the net.Conn API as it has not hooks to set
-// the neccessary sockopts for TCP MD5.
-func dialMD5(ctx context.Context, addr string, srcAddr net.IP, password string) (net.Conn, error) {
-	// If srcAddr exists on any of the local network interfaces, use it as the
-	// source address of the TCP socket. Otherwise, use the IPv6 unspecified
-	// address ("::") to let the kernel figure out the source address.
-	// NOTE: On Linux, "::" also includes "0.0.0.0" (all IPv4 addresses).
-	a := "[::]"
-	if srcAddr != nil {
-		ifs, err := net.Interfaces()
-		if err != nil {
-			return nil, fmt.Errorf("Querying local interfaces: %w", err)
-		}
-
-		if !localAddressExists(ifs, srcAddr) {
-			return nil, fmt.Errorf("Address %q doesn't exist on this host", srcAddr)
-		}
-
-		a = fmt.Sprintf("[%s]", srcAddr.String())
-	}
-
-	laddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:0", a))
-	if err != nil {
-		return nil, fmt.Errorf("Error resolving local address: %s ", err)
-	}
-
-	raddr, err := net.ResolveTCPAddr("tcp", addr)
-	if err != nil {
-		return nil, fmt.Errorf("invalid remote address: %s ", err)
-	}
-
-	var family int
-	var ra, la unix.Sockaddr
-	if raddr.IP.To4() != nil {
-		family = unix.AF_INET
-		rsockaddr := &unix.SockaddrInet4{Port: raddr.Port}
-		copy(rsockaddr.Addr[:], raddr.IP.To4())
-		ra = rsockaddr
-		lsockaddr := &unix.SockaddrInet4{}
-		copy(lsockaddr.Addr[:], laddr.IP.To4())
-		la = lsockaddr
-	} else {
-		family = unix.AF_INET6
-		rsockaddr := &unix.SockaddrInet6{Port: raddr.Port}
-		copy(rsockaddr.Addr[:], raddr.IP.To16())
-		ra = rsockaddr
-		var zone uint32
-		if laddr.Zone != "" {
-			intf, errs := net.InterfaceByName(laddr.Zone)
-			if errs != nil {
-				return nil, errs
-			}
-			zone = uint32(intf.Index)
-		}
-		lsockaddr := &unix.SockaddrInet6{ZoneId: zone}
-		copy(lsockaddr.Addr[:], laddr.IP.To16())
-		la = lsockaddr
-	}
-
-	sockType := unix.SOCK_STREAM | unix.SOCK_CLOEXEC | unix.SOCK_NONBLOCK
-	proto := 0
-	fd, err := unix.Socket(family, sockType, proto)
-	if err != nil {
-		return nil, err
-	}
-
-	// A new socket was created so we must close it before this
-	// function returns either on failure or success. On success,
-	// net.FileConn() in newTCPConn() increases the refcount of
-	// the socket so this fi.Close() doesn't destroy the socket.
-	// The caller must call Close() with the file later.
-	// Note that the above os.NewFile() doesn't play with the
-	// refcount.
-	fi := os.NewFile(uintptr(fd), "")
-	defer fi.Close()
-
-	if password != "" {
-		sig := buildTCPMD5Sig(raddr.IP, password)
-		b := *(*[unsafe.Sizeof(sig)]byte)(unsafe.Pointer(&sig))
-		// Better way may be available in  Go 1.11, see go-review.googlesource.com/c/go/+/72810
-		if err = os.NewSyscallError("setsockopt", unix.SetsockoptString(fd, unix.IPPROTO_TCP, tcpMD5SIG, string(b[:]))); err != nil {
-			return nil, err
-		}
-	}
-
-	if err = unix.Bind(fd, la); err != nil {
-		return nil, os.NewSyscallError("bind", err)
-	}
-
-	err = unix.Connect(fd, ra)
-
-	switch err {
-	case syscall.EINPROGRESS, syscall.EALREADY, syscall.EINTR:
-	case nil:
-		return net.FileConn(fi)
-	default:
-		return nil, os.NewSyscallError("connect", err)
-	}
-
-	// With a non-blocking socket, the connection process is
-	// asynchronous, so we need to manually wait with epoll until the
-	// connection succeeds. All of the following is doing that, with
-	// appropriate use of the deadline in the context.
-	epfd, err := unix.EpollCreate1(syscall.EPOLL_CLOEXEC)
-	if err != nil {
-		return nil, err
-	}
-	defer unix.Close(epfd)
-
-	var event unix.EpollEvent
-	events := make([]unix.EpollEvent, 1)
-
-	event.Events = syscall.EPOLLIN | syscall.EPOLLOUT | syscall.EPOLLPRI
-	event.Fd = int32(fd)
-	if err = unix.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, fd, &event); err != nil {
-		return nil, err
-	}
-
-	for {
-		timeout := int(-1)
-		if deadline, ok := ctx.Deadline(); ok {
-			timeout = int(time.Until(deadline).Nanoseconds() / 1000000)
-			if timeout <= 0 {
-				return nil, fmt.Errorf("timeout")
-			}
-		}
-		nevents, err := unix.EpollWait(epfd, events, timeout)
-		if err != nil {
-			return nil, err
-		}
-		if nevents == 0 {
-			return nil, fmt.Errorf("timeout")
-		}
-		if nevents > 1 || events[0].Fd != int32(fd) {
-			return nil, fmt.Errorf("unexpected epoll behavior")
-		}
-
-		nerr, err := unix.GetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_ERROR)
-		if err != nil {
-			return nil, os.NewSyscallError("getsockopt", err)
-		}
-		switch err := syscall.Errno(nerr); err {
-		case syscall.EINPROGRESS, syscall.EALREADY, syscall.EINTR:
-		case syscall.Errno(0), unix.EISCONN:
-			return net.FileConn(fi)
-		default:
-			return nil, os.NewSyscallError("getsockopt", err)
-		}
-	}
-}
-
-func buildTCPMD5Sig(addr net.IP, key string) tcpmd5sig {
-	t := tcpmd5sig{}
-	if addr.To4() != nil {
-		t.ssFamily = unix.AF_INET
-		copy(t.ss[2:], addr.To4())
-	} else {
-		t.ssFamily = unix.AF_INET6
-		copy(t.ss[6:], addr.To16())
-	}
-
-	t.keylen = uint16(len(key))
-	copy(t.key[0:], []byte(key))
-
-	return t
-}
-
-// localAddressExists returns true if the address addr exists on any of the
-// network interfaces in the ifs slice.
-func localAddressExists(ifs []net.Interface, addr net.IP) bool {
-	for _, i := range ifs {
-		addresses, err := i.Addrs()
-		if err != nil {
-			continue
-		}
-
-		for _, a := range addresses {
-			ip, ok := a.(*net.IPNet)
-			if !ok {
-				continue
-			}
-			if ip.IP.Equal(addr) {
-				return true
-			}
-		}
-	}
-
-	return false
+type SessionManager interface {
+	NewSession(logger log.Logger, addr string, srcAddr net.IP, myASN uint32, routerID net.IP, asn uint32, hold time.Duration, password string, myNode string) (Session, error)
 }
diff --git a/internal/bgp/frr/config.go b/internal/bgp/frr/config.go
new file mode 100644
index 00000000..1ea8839f
--- /dev/null
+++ b/internal/bgp/frr/config.go
@@ -0,0 +1,136 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package frr
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"text/template"
+)
+
+var configFileName = "/etc/frr/frr.conf"
+
+// TODO: We will probably need to update this template when we start to
+// integrate with FRR. The current template is a reasonable first pass
+// and we can improve it in later commits.
+//
+// It may be necessary to arrange this into multiple nested templates
+// (https://pkg.go.dev/text/template#hdr-Nested_template_definitions), this
+// should also be considered.
+const configTemplate = `
+log stdout {{.Loglevel}}
+hostname {{.Hostname}}
+
+{{range .Routers -}}
+router bgp {{.MyASN}}
+  no bgp network import-check
+  no bgp default ipv4-unicast
+{{ if .RouterId }}
+  bgp router-id {{.RouterId}}
+{{- end }}
+{{range .Neighbors }}
+  neighbor {{.Addr}} remote-as {{.ASN}}
+  neighbor {{.Addr}} port {{.Port}}
+{{- end }}
+{{range $n := .Neighbors -}}
+{{range .Advertisements }}
+  address-family {{.Version}} unicast
+    neighbor {{$n.Addr}} activate
+    network {{.Prefix}}
+  exit-address-family
+{{- end}}
+{{end}}
+{{end}}
+`
+
+type frrConfig struct {
+	Loglevel string
+	Hostname string
+	Routers  map[string]*routerConfig
+}
+
+type routerConfig struct {
+	MyASN     uint32
+	RouterId  string
+	Neighbors map[string]*neighborConfig
+}
+
+type neighborConfig struct {
+	ASN            uint32
+	Addr           string
+	Port           uint16
+	Advertisements []*advertisementConfig
+}
+
+type advertisementConfig struct {
+	Version string
+	Prefix  string
+}
+
+// routerName() defines the format of the key of the "Routers" map in the
+// frrConfig struct.
+func routerName(srcAddr string, myASN uint32) string {
+	return fmt.Sprintf("%d@%s", myASN, srcAddr)
+}
+
+// neighborName() defines the format of key of the 'Neighbors' map in the
+// routerConfig struct.
+func neighborName(peerAddr string, ASN uint32) string {
+	return fmt.Sprintf("%d@%s", ASN, peerAddr)
+}
+
+// templateConfig uses the template library to template
+// 'globalConfigTemplate' using 'data'.
+func templateConfig(data interface{}) (string, error) {
+	t, err := template.New("FRR Config Template").Parse(configTemplate)
+	if err != nil {
+		return "", err
+	}
+
+	var b bytes.Buffer
+	err = t.Execute(&b, data)
+
+	return b.String(), err
+}
+
+// writeConfigFile writes the FRR configuration file (represented as a string)
+// to 'filename'.
+func writeConfig(config string, filename string) error {
+	return ioutil.WriteFile(filename, []byte(config), 0644)
+}
+
+// reloadConfig requests that FRR reloads the configuration file. This is
+// called after updating the configuration.
+func reloadConfig() error {
+	// TODO
+	return nil
+}
+
+// generateAndReloadConfigFile takes a 'struct frrConfig' and, using a template,
+// generates and writes a valid FRR configuration file. If this completes
+// successfully it will also force FRR to reload that configuration file.
+func generateAndReloadConfigFile(config *frrConfig) error {
+	filename, found := os.LookupEnv("FRR_CONFIG_FILE")
+	if found {
+		configFileName = filename
+	}
+
+	configString, err := templateConfig(config)
+	if err != nil {
+		return err
+	}
+
+	err = writeConfig(configString, configFileName)
+	if err != nil {
+		return err
+	}
+
+	err = reloadConfig()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/internal/bgp/frr/frr.go b/internal/bgp/frr/frr.go
new file mode 100644
index 00000000..80a58840
--- /dev/null
+++ b/internal/bgp/frr/frr.go
@@ -0,0 +1,240 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package frr
+
+import (
+	"fmt"
+	"net"
+	"os"
+	"strconv"
+	"time"
+
+	"github.com/go-kit/kit/log"
+	"go.universe.tf/metallb/internal/bgp"
+)
+
+// As the MetalLB controller should handle messages synchronously, there should
+// no need to lock this data structure. TODO: confirm this.
+type sessionManager struct {
+	sessions map[string]*session
+}
+
+type session struct {
+	myASN          uint32
+	routerID       net.IP // May be nil, meaning "derive from context"
+	myNode         string
+	addr           string
+	srcAddr        net.IP
+	asn            uint32
+	holdTime       time.Duration
+	logger         log.Logger
+	password       string
+	advertised     []*bgp.Advertisement
+	sessionManager *sessionManager
+}
+
+// Create a variable for os.Hostname() in order to make it easy to mock out
+// in unit tests.
+var osHostname = os.Hostname
+
+// sessionName() defines the format of the key of the 'sessions' map in
+// the 'frrState' struct.
+func sessionName(myAddr string, myAsn uint32, addr string, asn uint32) string {
+	return fmt.Sprintf("%d@%s-%d@%s", asn, addr, myAsn, myAddr)
+}
+
+func validate(adv *bgp.Advertisement) error {
+	if adv.Prefix.IP.To4() != nil {
+		if adv.NextHop != nil && adv.NextHop.To4() == nil {
+			return fmt.Errorf("next-hop must be IPv4, got %q", adv.NextHop)
+		}
+	} else if adv.Prefix.IP.To16() != nil {
+		if adv.NextHop != nil && adv.NextHop.To16() == nil {
+			return fmt.Errorf("next-hop must be IPv6, got %q", adv.NextHop)
+		}
+	} else {
+		return fmt.Errorf("unable to validate IP address")
+	}
+
+	if len(adv.Communities) > 63 {
+		return fmt.Errorf("max supported communities is 63, got %d", len(adv.Communities))
+	}
+	return nil
+}
+
+func (s *session) Set(advs ...*bgp.Advertisement) error {
+	sessionName := sessionName(s.srcAddr.String(), s.myASN, s.addr, s.asn)
+	if _, found := s.sessionManager.sessions[sessionName]; !found {
+		return fmt.Errorf("session not established before advertisement")
+	}
+
+	newAdvs := []*bgp.Advertisement{}
+	for _, adv := range advs {
+		err := validate(adv)
+		if err != nil {
+			return err
+		}
+		newAdvs = append(newAdvs, adv)
+	}
+	oldAdvs := s.advertised
+	s.advertised = newAdvs
+
+	// Attempt to create a config
+	config, err := s.sessionManager.createConfig()
+	if err != nil {
+		s.advertised = oldAdvs
+		return err
+	}
+
+	return generateAndReloadConfigFile(config)
+}
+
+// Close() shuts down the BGP session.
+func (s *session) Close() error {
+	err := s.sessionManager.deleteSession(s)
+	if err != nil {
+		return err
+	}
+
+	frrConfig, err := s.sessionManager.createConfig()
+	if err != nil {
+		return err
+	}
+
+	return generateAndReloadConfigFile(frrConfig)
+}
+
+// NewSession() creates a BGP session using the given session parameters.
+//
+// The session will immediately try to connect and synchronize its
+// local state with the peer.
+func (sm *sessionManager) NewSession(l log.Logger, addr string, srcAddr net.IP, myASN uint32, routerID net.IP, asn uint32, holdTime time.Duration, password string, myNode string) (bgp.Session, error) {
+	s := &session{
+		myASN:          myASN,
+		routerID:       routerID,
+		myNode:         myNode,
+		addr:           addr,
+		srcAddr:        srcAddr,
+		asn:            asn,
+		holdTime:       holdTime,
+		logger:         log.With(l, "peer", addr, "localASN", myASN, "peerASN", asn),
+		password:       password,
+		advertised:     []*bgp.Advertisement{},
+		sessionManager: sm,
+	}
+
+	_ = sm.addSession(s)
+
+	frrConfig, err := sm.createConfig()
+	if err != nil {
+		_ = sm.deleteSession(s)
+		return nil, err
+	}
+
+	err = generateAndReloadConfigFile(frrConfig)
+	if err != nil {
+		_ = sm.deleteSession(s)
+		return nil, err
+	}
+
+	return s, err
+}
+
+func (sm *sessionManager) addSession(s *session) error {
+	if s == nil {
+		return fmt.Errorf("invalid session")
+	}
+	sessionName := sessionName(s.srcAddr.String(), s.myASN, s.addr, s.asn)
+	sm.sessions[sessionName] = s
+
+	return nil
+}
+
+func (sm *sessionManager) deleteSession(s *session) error {
+	if s == nil {
+		return fmt.Errorf("invalid session")
+	}
+	sessionName := sessionName(s.srcAddr.String(), s.myASN, s.addr, s.asn)
+	delete(sm.sessions, sessionName)
+
+	return nil
+}
+
+func (sm *sessionManager) createConfig() (*frrConfig, error) {
+	hostname, err := osHostname()
+	if err != nil {
+		return nil, err
+	}
+
+	config := &frrConfig{
+		Hostname: hostname,
+		Loglevel: "", // TODO.
+		Routers:  make(map[string]*routerConfig),
+	}
+
+	for _, s := range sm.sessions {
+		var router *routerConfig
+		var neighbor *neighborConfig
+		var exist bool
+
+		routerName := routerName(s.routerID.String(), s.myASN)
+		if router, exist = config.Routers[routerName]; !exist {
+			router = &routerConfig{
+				MyASN:     s.myASN,
+				Neighbors: make(map[string]*neighborConfig),
+			}
+			if s.routerID != nil {
+				router.RouterId = s.routerID.String()
+			}
+			config.Routers[routerName] = router
+		}
+
+		neighborName := neighborName(s.addr, s.asn)
+		if neighbor, exist = router.Neighbors[neighborName]; !exist {
+			host, port, err := net.SplitHostPort(s.addr)
+			if err != nil {
+				return nil, err
+			}
+
+			portUint, err := strconv.ParseUint(port, 10, 16)
+			if err != nil {
+				return nil, err
+			}
+
+			neighbor = &neighborConfig{
+				ASN:            s.asn,
+				Addr:           host,
+				Port:           uint16(portUint),
+				Advertisements: make([]*advertisementConfig, 0),
+			}
+			router.Neighbors[neighborName] = neighbor
+		}
+
+		/* As 'session.advertised' is a map, we can be sure there are no
+		   duplicate prefixes and can, therefore, just add them to the
+		   'neighbor.Advertisements' list. */
+		for _, adv := range s.advertised {
+			var version string
+			if adv.Prefix.IP.To4() != nil {
+				version = "ipv4"
+			} else if adv.Prefix.IP.To16() != nil {
+				version = "ipv6"
+			}
+
+			advConfig := advertisementConfig{
+				Version: version,
+				Prefix:  adv.Prefix.String(),
+			}
+
+			neighbor.Advertisements = append(neighbor.Advertisements, &advConfig)
+		}
+	}
+
+	return config, nil
+}
+
+func NewSessionManager() *sessionManager {
+	return &sessionManager{
+		sessions: map[string]*session{},
+	}
+}
diff --git a/internal/bgp/backoff.go b/internal/bgp/native/backoff.go
similarity index 97%
rename from internal/bgp/backoff.go
rename to internal/bgp/native/backoff.go
index b4107b55..61c8b47d 100644
--- a/internal/bgp/backoff.go
+++ b/internal/bgp/native/backoff.go
@@ -1,6 +1,6 @@
 // SPDX-License-Identifier:Apache-2.0
 
-package bgp
+package native
 
 import "time"
 
diff --git a/internal/bgp/messages.go b/internal/bgp/native/messages.go
similarity index 98%
rename from internal/bgp/messages.go
rename to internal/bgp/native/messages.go
index 7b3cd5a3..3ee20d71 100644
--- a/internal/bgp/messages.go
+++ b/internal/bgp/native/messages.go
@@ -1,6 +1,6 @@
 // SPDX-License-Identifier:Apache-2.0
 
-package bgp
+package native
 
 import (
 	"bytes"
@@ -10,6 +10,8 @@ import (
 	"io/ioutil"
 	"net"
 	"time"
+
+	"go.universe.tf/metallb/internal/bgp"
 )
 
 func sendOpen(w io.Writer, asn uint32, routerID net.IP, holdTime time.Duration) error {
@@ -287,7 +289,7 @@ func readCapabilities(r io.Reader, ret *openResult) error {
 	}
 }
 
-func sendUpdate(w io.Writer, asn uint32, ibgp, fbasn bool, defaultNextHop net.IP, adv *Advertisement) error {
+func sendUpdate(w io.Writer, asn uint32, ibgp, fbasn bool, defaultNextHop net.IP, adv *bgp.Advertisement) error {
 	var b bytes.Buffer
 
 	hdr := struct {
@@ -333,7 +335,7 @@ func bytesForBits(n int) int {
 	return ((n + 7) &^ 7) / 8
 }
 
-func encodePathAttrs(b *bytes.Buffer, asn uint32, ibgp, fbasn bool, defaultNextHop net.IP, adv *Advertisement) error {
+func encodePathAttrs(b *bytes.Buffer, asn uint32, ibgp, fbasn bool, defaultNextHop net.IP, adv *bgp.Advertisement) error {
 	b.Write([]byte{
 		0x40, 1, // mandatory, origin
 		1, // len
diff --git a/internal/bgp/native/native.go b/internal/bgp/native/native.go
new file mode 100644
index 00000000..69a932ad
--- /dev/null
+++ b/internal/bgp/native/native.go
@@ -0,0 +1,708 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package native
+
+import (
+	"bytes"
+	"context"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"hash/crc32"
+	"io"
+	"io/ioutil"
+	"net"
+	"os"
+	"sync"
+	"syscall"
+	"time"
+	"unsafe"
+
+	"github.com/go-kit/kit/log"
+	"github.com/go-kit/kit/log/level"
+	"go.universe.tf/metallb/internal/bgp"
+	"golang.org/x/sys/unix"
+)
+
+var errClosed = errors.New("session closed")
+
+// session represents one BGP session to an external router.
+type session struct {
+	myASN            uint32
+	routerID         net.IP // May be nil, meaning "derive from context"
+	myNode           string
+	addr             string
+	srcAddr          net.IP
+	asn              uint32
+	peerFBASNSupport bool
+	holdTime         time.Duration
+	logger           log.Logger
+	password         string
+
+	newHoldTime chan bool
+	backoff     backoff
+
+	mu             sync.Mutex
+	cond           *sync.Cond
+	closed         bool
+	conn           net.Conn
+	actualHoldTime time.Duration
+	defaultNextHop net.IP
+	advertised     map[string]*bgp.Advertisement
+	new            map[string]*bgp.Advertisement
+}
+
+// The 'Native' implementation does not require a session manager .
+type sessionManager struct {
+}
+
+func NewSessionManager() *sessionManager {
+	return &sessionManager{}
+}
+
+// NewSession() creates a BGP session using the given session parameters.
+//
+// The session will immediately try to connect and synchronize its
+// local state with the peer.
+func (sm *sessionManager) NewSession(l log.Logger, addr string, srcAddr net.IP, myASN uint32, routerID net.IP, asn uint32, holdTime time.Duration, password string, myNode string) (bgp.Session, error) {
+	ret := &session{
+		addr:        addr,
+		srcAddr:     srcAddr,
+		myASN:       myASN,
+		routerID:    routerID.To4(),
+		myNode:      myNode,
+		asn:         asn,
+		holdTime:    holdTime,
+		logger:      log.With(l, "peer", addr, "localASN", myASN, "peerASN", asn),
+		newHoldTime: make(chan bool, 1),
+		advertised:  map[string]*bgp.Advertisement{},
+		password:    password,
+	}
+	ret.cond = sync.NewCond(&ret.mu)
+	go ret.sendKeepalives()
+	go ret.run()
+
+	stats.sessionUp.WithLabelValues(ret.addr).Set(0)
+	stats.prefixes.WithLabelValues(ret.addr).Set(0)
+
+	return ret, nil
+}
+
+// run tries to stay connected to the peer, and pumps route updates to it.
+func (s *session) run() {
+	defer stats.DeleteSession(s.addr)
+	for {
+		if err := s.connect(); err != nil {
+			if err == errClosed {
+				return
+			}
+			level.Error(s.logger).Log("op", "connect", "error", err, "msg", "failed to connect to peer")
+			backoff := s.backoff.Duration()
+			time.Sleep(backoff)
+			continue
+		}
+		stats.SessionUp(s.addr)
+		s.backoff.Reset()
+
+		level.Info(s.logger).Log("event", "sessionUp", "msg", "BGP session established")
+
+		if !s.sendUpdates() {
+			return
+		}
+		stats.SessionDown(s.addr)
+		level.Warn(s.logger).Log("event", "sessionDown", "msg", "BGP session down")
+	}
+}
+
+// sendUpdates waits for changes to desired advertisements, and pushes
+// them out to the peer.
+func (s *session) sendUpdates() bool {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if s.closed {
+		return false
+	}
+	if s.conn == nil {
+		return true
+	}
+
+	ibgp := s.myASN == s.asn
+	fbasn := s.peerFBASNSupport
+
+	if s.new != nil {
+		s.advertised, s.new = s.new, nil
+	}
+
+	for c, adv := range s.advertised {
+		if err := sendUpdate(s.conn, s.myASN, ibgp, fbasn, s.defaultNextHop, adv); err != nil {
+			s.abort()
+			level.Error(s.logger).Log("op", "sendUpdate", "ip", c, "error", err, "msg", "failed to send BGP update")
+			return true
+		}
+		stats.UpdateSent(s.addr)
+	}
+	stats.AdvertisedPrefixes(s.addr, len(s.advertised))
+
+	for {
+		for s.new == nil && s.conn != nil {
+			s.cond.Wait()
+		}
+
+		if s.closed {
+			return false
+		}
+		if s.conn == nil {
+			return true
+		}
+		if s.new == nil {
+			// nil is "no pending updates", contrast to a non-nil
+			// empty map which means "withdraw all".
+			continue
+		}
+
+		for c, adv := range s.new {
+			if adv2, ok := s.advertised[c]; ok && adv.Equal(adv2) {
+				// Peer already has correct state for this
+				// advertisement, nothing to do.
+				continue
+			}
+
+			if err := sendUpdate(s.conn, s.myASN, ibgp, fbasn, s.defaultNextHop, adv); err != nil {
+				s.abort()
+				level.Error(s.logger).Log("op", "sendUpdate", "prefix", c, "error", err, "msg", "failed to send BGP update")
+				return true
+			}
+			stats.UpdateSent(s.addr)
+		}
+
+		wdr := []*net.IPNet{}
+		for c, adv := range s.advertised {
+			if s.new[c] == nil {
+				wdr = append(wdr, adv.Prefix)
+			}
+		}
+		if len(wdr) > 0 {
+			if err := sendWithdraw(s.conn, wdr); err != nil {
+				s.abort()
+				for _, pfx := range wdr {
+					level.Error(s.logger).Log("op", "sendWithdraw", "prefix", pfx, "error", err, "msg", "failed to send BGP withdraw")
+				}
+				return true
+			}
+			stats.UpdateSent(s.addr)
+		}
+		s.advertised, s.new = s.new, nil
+		stats.AdvertisedPrefixes(s.addr, len(s.advertised))
+	}
+}
+
+// connect establishes the BGP session with the peer.
+// Sets TCP_MD5 sockopt if password is !="".
+func (s *session) connect() error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if s.closed {
+		return errClosed
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	deadline, _ := ctx.Deadline()
+	conn, err := dialMD5(ctx, s.addr, s.srcAddr, s.password)
+	if err != nil {
+		return fmt.Errorf("dial %q: %s", s.addr, err)
+	}
+
+	if err = conn.SetDeadline(deadline); err != nil {
+		conn.Close()
+		return fmt.Errorf("setting deadline on conn to %q: %s", s.addr, err)
+	}
+
+	addr, ok := conn.LocalAddr().(*net.TCPAddr)
+	if !ok {
+		conn.Close()
+		return fmt.Errorf("getting local addr for default nexthop to %q: %s", s.addr, err)
+	}
+	s.defaultNextHop = addr.IP
+
+	routerID := s.routerID
+	if routerID == nil {
+		routerID, err = getRouterID(s.defaultNextHop, s.myNode)
+		if err != nil {
+			return err
+		}
+	}
+
+	if err = sendOpen(conn, s.myASN, routerID, s.holdTime); err != nil {
+		conn.Close()
+		return fmt.Errorf("send OPEN to %q: %s", s.addr, err)
+	}
+
+	op, err := readOpen(conn)
+	if err != nil {
+		conn.Close()
+		return fmt.Errorf("read OPEN from %q: %s", s.addr, err)
+	}
+	if op.asn != s.asn {
+		conn.Close()
+		return fmt.Errorf("unexpected peer ASN %d, want %d", op.asn, s.asn)
+	}
+	s.peerFBASNSupport = op.fbasn
+	if s.myASN > 65536 && !s.peerFBASNSupport {
+		conn.Close()
+		return fmt.Errorf("peer does not support 4-byte ASNs")
+	}
+
+	// BGP session is established, clear the connect timeout deadline.
+	if err := conn.SetDeadline(time.Time{}); err != nil {
+		conn.Close()
+		return fmt.Errorf("clearing deadline on conn to %q: %s", s.addr, err)
+	}
+
+	// Consume BGP messages until the connection closes.
+	go s.consumeBGP(conn)
+
+	// Send one keepalive to say that yes, we accept the OPEN.
+	if err := sendKeepalive(conn); err != nil {
+		conn.Close()
+		return fmt.Errorf("accepting peer OPEN from %q: %s", s.addr, err)
+	}
+
+	// Set up regular keepalives from now on.
+	s.actualHoldTime = s.holdTime
+	if op.holdTime < s.actualHoldTime {
+		s.actualHoldTime = op.holdTime
+	}
+	select {
+	case s.newHoldTime <- true:
+	default:
+	}
+
+	s.conn = conn
+	return nil
+}
+
+func hashRouterId(hostname string) (net.IP, error) {
+	buf := new(bytes.Buffer)
+	err := binary.Write(buf, binary.LittleEndian, crc32.ChecksumIEEE([]byte(hostname)))
+	if err != nil {
+		return nil, err
+	}
+	return net.IP(buf.Bytes()), nil
+}
+
+// Ipv4; Use the address as-is.
+// Ipv6; Pick the first ipv4 address on the same interface as the address.
+func getRouterID(addr net.IP, myNode string) (net.IP, error) {
+	if addr.To4() != nil {
+		return addr, nil
+	}
+
+	ifaces, err := net.Interfaces()
+	if err != nil {
+		return hashRouterId(myNode)
+	}
+	for _, i := range ifaces {
+		addrs, err := i.Addrs()
+		if err != nil {
+			continue
+		}
+		for _, a := range addrs {
+			var ip net.IP
+			switch v := a.(type) {
+			case *net.IPNet:
+				ip = v.IP
+			case *net.IPAddr:
+				ip = v.IP
+			}
+
+			if ip.Equal(addr) {
+				// This is the interface.
+				// Loop through the addresses again and search for ipv4
+				for _, a := range addrs {
+					var ip net.IP
+					switch v := a.(type) {
+					case *net.IPNet:
+						ip = v.IP
+					case *net.IPAddr:
+						ip = v.IP
+					}
+					if ip.To4() != nil {
+						return ip, nil
+					}
+				}
+				return hashRouterId(myNode)
+			}
+		}
+	}
+	return hashRouterId(myNode)
+}
+
+// sendKeepalives sends BGP KEEPALIVE packets at the negotiated rate
+// whenever the session is connected.
+func (s *session) sendKeepalives() {
+	var (
+		t  *time.Ticker
+		ch <-chan time.Time
+	)
+
+	for {
+		select {
+		case <-s.newHoldTime:
+			s.mu.Lock()
+			ht := s.actualHoldTime
+			s.mu.Unlock()
+			if t != nil {
+				t.Stop()
+				t = nil
+				ch = nil
+			}
+			if ht != 0 {
+				t = time.NewTicker(ht / 3)
+				ch = t.C
+			}
+
+		case <-ch:
+			if err := s.sendKeepalive(); err == errClosed {
+				// Session has been closed by package caller, we're
+				// done here.
+				return
+			}
+		}
+	}
+}
+
+// sendKeepalive sends a single BGP KEEPALIVE packet.
+func (s *session) sendKeepalive() error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.closed {
+		return errClosed
+	}
+	if s.conn == nil {
+		// No connection established, othing to do.
+		return nil
+	}
+	if err := sendKeepalive(s.conn); err != nil {
+		s.abort()
+		level.Error(s.logger).Log("op", "sendKeepalive", "error", err, "msg", "failed to send keepalive")
+		return fmt.Errorf("sending keepalive to %q: %s", s.addr, err)
+	}
+	return nil
+}
+
+// consumeBGP receives BGP messages from the peer, and ignores
+// them. It does minimal checks for the well-formedness of messages,
+// and terminates the connection if something looks wrong.
+func (s *session) consumeBGP(conn io.ReadCloser) {
+	defer func() {
+		s.mu.Lock()
+		defer s.mu.Unlock()
+		if s.conn == conn {
+			s.abort()
+		} else {
+			conn.Close()
+		}
+	}()
+
+	for {
+		hdr := struct {
+			Marker1, Marker2 uint64
+			Len              uint16
+			Type             uint8
+		}{}
+		if err := binary.Read(conn, binary.BigEndian, &hdr); err != nil {
+			// TODO: log, or propagate the error somehow.
+			return
+		}
+		if hdr.Marker1 != 0xffffffffffffffff || hdr.Marker2 != 0xffffffffffffffff {
+			// TODO: propagate
+			return
+		}
+		if hdr.Type == 3 {
+			// TODO: propagate better than just logging directly.
+			err := readNotification(conn)
+			level.Error(s.logger).Log("event", "peerNotification", "error", err, "msg", "peer sent notification, closing session")
+			return
+		}
+		if _, err := io.Copy(ioutil.Discard, io.LimitReader(conn, int64(hdr.Len)-19)); err != nil {
+			// TODO: propagate
+			return
+		}
+	}
+}
+
+func validate(adv *bgp.Advertisement) error {
+	if adv.Prefix.IP.To4() == nil {
+		return fmt.Errorf("cannot advertise non-v4 prefix %q", adv.Prefix)
+	}
+
+	if adv.NextHop != nil && adv.NextHop.To4() == nil {
+		return fmt.Errorf("next-hop must be IPv4, got %q", adv.NextHop)
+	}
+	if len(adv.Communities) > 63 {
+		return fmt.Errorf("max supported communities is 63, got %d", len(adv.Communities))
+	}
+	return nil
+}
+
+// Set updates the set of Advertisements that this session's peer should receive.
+//
+// Changes are propagated to the peer asynchronously, Set may return
+// before the peer learns about the changes.
+func (s *session) Set(advs ...*bgp.Advertisement) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	newAdvs := map[string]*bgp.Advertisement{}
+	for _, adv := range advs {
+		err := validate(adv)
+		if err != nil {
+			return err
+		}
+		newAdvs[adv.Prefix.String()] = adv
+	}
+
+	s.new = newAdvs
+	stats.PendingPrefixes(s.addr, len(s.new))
+	s.cond.Broadcast()
+	return nil
+}
+
+// abort closes any existing connection, updates stats, and cleans up
+// state ready for another connection attempt.
+func (s *session) abort() {
+	if s.conn != nil {
+		s.conn.Close()
+		s.conn = nil
+		stats.SessionDown(s.addr)
+	}
+	// Next time we retry the connection, we can just skip straight to
+	// the desired end state.
+	if s.new != nil {
+		s.advertised, s.new = s.new, nil
+		stats.PendingPrefixes(s.addr, len(s.advertised))
+	}
+	s.cond.Broadcast()
+}
+
+// Close shuts down the BGP session.
+func (s *session) Close() error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.closed = true
+	s.abort()
+	return nil
+}
+
+const (
+	// TCP MD5 Signature (RFC2385).
+	tcpMD5SIG = 14
+)
+
+// This  struct is defined at; linux-kernel: include/uapi/linux/tcp.h,
+// It  must be kept in sync with that definition, see current version:
+// https://github.com/torvalds/linux/blob/v4.16/include/uapi/linux/tcp.h#L253
+// nolint[structcheck]
+type tcpmd5sig struct {
+	ssFamily uint16
+	ss       [126]byte
+	pad1     uint16
+	keylen   uint16
+	pad2     uint32
+	key      [80]byte
+}
+
+// DialTCP does the part of creating a connection manually,  including setting the
+// proper TCP MD5 options when the password is not empty. Works by manupulating
+// the low level FD's, skipping the net.Conn API as it has not hooks to set
+// the neccessary sockopts for TCP MD5.
+func dialMD5(ctx context.Context, addr string, srcAddr net.IP, password string) (net.Conn, error) {
+	// If srcAddr exists on any of the local network interfaces, use it as the
+	// source address of the TCP socket. Otherwise, use the IPv6 unspecified
+	// address ("::") to let the kernel figure out the source address.
+	// NOTE: On Linux, "::" also includes "0.0.0.0" (all IPv4 addresses).
+	a := "[::]"
+	if srcAddr != nil {
+		ifs, err := net.Interfaces()
+		if err != nil {
+			return nil, fmt.Errorf("Querying local interfaces: %w", err)
+		}
+
+		if !localAddressExists(ifs, srcAddr) {
+			return nil, fmt.Errorf("Address %q doesn't exist on this host", srcAddr)
+		}
+
+		a = fmt.Sprintf("[%s]", srcAddr.String())
+	}
+
+	laddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:0", a))
+	if err != nil {
+		return nil, fmt.Errorf("Error resolving local address: %s ", err)
+	}
+
+	raddr, err := net.ResolveTCPAddr("tcp", addr)
+	if err != nil {
+		return nil, fmt.Errorf("invalid remote address: %s ", err)
+	}
+
+	var family int
+	var ra, la unix.Sockaddr
+	if raddr.IP.To4() != nil {
+		family = unix.AF_INET
+		rsockaddr := &unix.SockaddrInet4{Port: raddr.Port}
+		copy(rsockaddr.Addr[:], raddr.IP.To4())
+		ra = rsockaddr
+		lsockaddr := &unix.SockaddrInet4{}
+		copy(lsockaddr.Addr[:], laddr.IP.To4())
+		la = lsockaddr
+	} else {
+		family = unix.AF_INET6
+		rsockaddr := &unix.SockaddrInet6{Port: raddr.Port}
+		copy(rsockaddr.Addr[:], raddr.IP.To16())
+		ra = rsockaddr
+		var zone uint32
+		if laddr.Zone != "" {
+			intf, errs := net.InterfaceByName(laddr.Zone)
+			if errs != nil {
+				return nil, errs
+			}
+			zone = uint32(intf.Index)
+		}
+		lsockaddr := &unix.SockaddrInet6{ZoneId: zone}
+		copy(lsockaddr.Addr[:], laddr.IP.To16())
+		la = lsockaddr
+	}
+
+	sockType := unix.SOCK_STREAM | unix.SOCK_CLOEXEC | unix.SOCK_NONBLOCK
+	proto := 0
+	fd, err := unix.Socket(family, sockType, proto)
+	if err != nil {
+		return nil, err
+	}
+
+	// A new socket was created so we must close it before this
+	// function returns either on failure or success. On success,
+	// net.FileConn() in newTCPConn() increases the refcount of
+	// the socket so this fi.Close() doesn't destroy the socket.
+	// The caller must call Close() with the file later.
+	// Note that the above os.NewFile() doesn't play with the
+	// refcount.
+	fi := os.NewFile(uintptr(fd), "")
+	defer fi.Close()
+
+	if password != "" {
+		sig := buildTCPMD5Sig(raddr.IP, password)
+		b := *(*[unsafe.Sizeof(sig)]byte)(unsafe.Pointer(&sig))
+		// Better way may be available in  Go 1.11, see go-review.googlesource.com/c/go/+/72810
+		if err = os.NewSyscallError("setsockopt", unix.SetsockoptString(fd, unix.IPPROTO_TCP, tcpMD5SIG, string(b[:]))); err != nil {
+			return nil, err
+		}
+	}
+
+	if err = unix.Bind(fd, la); err != nil {
+		return nil, os.NewSyscallError("bind", err)
+	}
+
+	err = unix.Connect(fd, ra)
+
+	switch err {
+	case syscall.EINPROGRESS, syscall.EALREADY, syscall.EINTR:
+	case nil:
+		return net.FileConn(fi)
+	default:
+		return nil, os.NewSyscallError("connect", err)
+	}
+
+	// With a non-blocking socket, the connection process is
+	// asynchronous, so we need to manually wait with epoll until the
+	// connection succeeds. All of the following is doing that, with
+	// appropriate use of the deadline in the context.
+	epfd, err := unix.EpollCreate1(syscall.EPOLL_CLOEXEC)
+	if err != nil {
+		return nil, err
+	}
+	defer unix.Close(epfd)
+
+	var event unix.EpollEvent
+	events := make([]unix.EpollEvent, 1)
+
+	event.Events = syscall.EPOLLIN | syscall.EPOLLOUT | syscall.EPOLLPRI
+	event.Fd = int32(fd)
+	if err = unix.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, fd, &event); err != nil {
+		return nil, err
+	}
+
+	for {
+		timeout := int(-1)
+		if deadline, ok := ctx.Deadline(); ok {
+			timeout = int(time.Until(deadline).Nanoseconds() / 1000000)
+			if timeout <= 0 {
+				return nil, fmt.Errorf("timeout")
+			}
+		}
+		nevents, err := unix.EpollWait(epfd, events, timeout)
+		if err != nil {
+			return nil, err
+		}
+		if nevents == 0 {
+			return nil, fmt.Errorf("timeout")
+		}
+		if nevents > 1 || events[0].Fd != int32(fd) {
+			return nil, fmt.Errorf("unexpected epoll behavior")
+		}
+
+		nerr, err := unix.GetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_ERROR)
+		if err != nil {
+			return nil, os.NewSyscallError("getsockopt", err)
+		}
+		switch err := syscall.Errno(nerr); err {
+		case syscall.EINPROGRESS, syscall.EALREADY, syscall.EINTR:
+		case syscall.Errno(0), unix.EISCONN:
+			return net.FileConn(fi)
+		default:
+			return nil, os.NewSyscallError("getsockopt", err)
+		}
+	}
+}
+
+func buildTCPMD5Sig(addr net.IP, key string) tcpmd5sig {
+	t := tcpmd5sig{}
+	if addr.To4() != nil {
+		t.ssFamily = unix.AF_INET
+		copy(t.ss[2:], addr.To4())
+	} else {
+		t.ssFamily = unix.AF_INET6
+		copy(t.ss[6:], addr.To16())
+	}
+
+	t.keylen = uint16(len(key))
+	copy(t.key[0:], []byte(key))
+
+	return t
+}
+
+// localAddressExists returns true if the address addr exists on any of the
+// network interfaces in the ifs slice.
+func localAddressExists(ifs []net.Interface, addr net.IP) bool {
+	for _, i := range ifs {
+		addresses, err := i.Addrs()
+		if err != nil {
+			continue
+		}
+
+		for _, a := range addresses {
+			ip, ok := a.(*net.IPNet)
+			if !ok {
+				continue
+			}
+			if ip.IP.Equal(addr) {
+				return true
+			}
+		}
+	}
+
+	return false
+}
diff --git a/internal/bgp/stats.go b/internal/bgp/native/stats.go
similarity index 99%
rename from internal/bgp/stats.go
rename to internal/bgp/native/stats.go
index a6e60644..bea27c0c 100644
--- a/internal/bgp/stats.go
+++ b/internal/bgp/native/stats.go
@@ -1,6 +1,6 @@
 // SPDX-License-Identifier:Apache-2.0
 
-package bgp
+package native
 
 import "github.com/prometheus/client_golang/prometheus"
 
diff --git a/speaker/bgp_controller.go b/speaker/bgp_controller.go
index a69a66a6..c0527ace 100644
--- a/speaker/bgp_controller.go
+++ b/speaker/bgp_controller.go
@@ -16,14 +16,14 @@ package main
 
 import (
 	"fmt"
-	"io"
 	"net"
 	"reflect"
 	"sort"
 	"strconv"
-	"time"
 
 	"go.universe.tf/metallb/internal/bgp"
+	bgpfrr "go.universe.tf/metallb/internal/bgp/frr"
+	bgpnative "go.universe.tf/metallb/internal/bgp/native"
 	"go.universe.tf/metallb/internal/config"
 	"go.universe.tf/metallb/internal/k8s"
 	v1 "k8s.io/api/core/v1"
@@ -33,17 +33,26 @@ import (
 	"github.com/go-kit/kit/log/level"
 )
 
+type bgpImplementation string
+
+const (
+	bgpNative bgpImplementation = "native"
+	bgpFrr    bgpImplementation = "frr"
+)
+
 type peer struct {
-	cfg *config.Peer
-	bgp session
+	cfg     *config.Peer
+	session bgp.Session
 }
 
 type bgpController struct {
-	logger     log.Logger
-	myNode     string
-	nodeLabels labels.Set
-	peers      []*peer
-	svcAds     map[string][]*bgp.Advertisement
+	logger         log.Logger
+	myNode         string
+	nodeLabels     labels.Set
+	peers          []*peer
+	svcAds         map[string][]*bgp.Advertisement
+	bgpType        bgpImplementation
+	sessionManager bgp.SessionManager
 }
 
 func (c *bgpController) SetConfig(l log.Logger, cfg *config.Config) error {
@@ -74,8 +83,8 @@ newPeers:
 			continue
 		}
 		level.Info(l).Log("event", "peerRemoved", "peer", p.cfg.Addr, "reason", "removedFromConfig", "msg", "peer deconfigured, closing BGP session")
-		if p.bgp != nil {
-			if err := p.bgp.Close(); err != nil {
+		if p.session != nil {
+			if err := p.session.Close(); err != nil {
 				level.Error(l).Log("op", "setConfig", "error", err, "peer", p.cfg.Addr, "msg", "failed to shut down BGP session")
 			}
 		}
@@ -177,14 +186,14 @@ func (c *bgpController) syncPeers(l log.Logger) error {
 		}
 
 		// Now, compare current state to intended state, and correct.
-		if p.bgp != nil && !shouldRun {
+		if p.session != nil && !shouldRun {
 			// Oops, session is running but shouldn't be. Shut it down.
 			level.Info(l).Log("event", "peerRemoved", "peer", p.cfg.Addr, "reason", "filteredByNodeSelector", "msg", "peer deconfigured, closing BGP session")
-			if err := p.bgp.Close(); err != nil {
+			if err := p.session.Close(); err != nil {
 				level.Error(l).Log("op", "syncPeers", "error", err, "peer", p.cfg.Addr, "msg", "failed to shut down BGP session")
 			}
-			p.bgp = nil
-		} else if p.bgp == nil && shouldRun {
+			p.session = nil
+		} else if p.session == nil && shouldRun {
 			// Session doesn't exist, but should be running. Create
 			// it.
 			level.Info(l).Log("event", "peerAdded", "peer", p.cfg.Addr, "msg", "peer configured, starting BGP session")
@@ -192,12 +201,12 @@ func (c *bgpController) syncPeers(l log.Logger) error {
 			if p.cfg.RouterID != nil {
 				routerID = p.cfg.RouterID
 			}
-			s, err := newBGP(c.logger, net.JoinHostPort(p.cfg.Addr.String(), strconv.Itoa(int(p.cfg.Port))), p.cfg.SrcAddr, p.cfg.MyASN, routerID, p.cfg.ASN, p.cfg.HoldTime, p.cfg.Password, c.myNode)
+			s, err := c.sessionManager.NewSession(c.logger, net.JoinHostPort(p.cfg.Addr.String(), strconv.Itoa(int(p.cfg.Port))), p.cfg.SrcAddr, p.cfg.MyASN, routerID, p.cfg.ASN, p.cfg.HoldTime, p.cfg.Password, c.myNode)
 			if err != nil {
 				level.Error(l).Log("op", "syncPeers", "error", err, "peer", p.cfg.Addr, "msg", "failed to create BGP session")
 				errs++
 			} else {
-				p.bgp = s
+				p.session = s
 				needUpdateAds = true
 			}
 		}
@@ -254,10 +263,10 @@ func (c *bgpController) updateAds() error {
 		allAds = append(allAds, ads...)
 	}
 	for _, peer := range c.peers {
-		if peer.bgp == nil {
+		if peer.session == nil {
 			continue
 		}
-		if err := peer.bgp.Set(allAds...); err != nil {
+		if err := peer.session.Set(allAds...); err != nil {
 			return err
 		}
 	}
@@ -272,11 +281,6 @@ func (c *bgpController) DeleteBalancer(l log.Logger, name, reason string) error
 	return c.updateAds()
 }
 
-type session interface {
-	io.Closer
-	Set(advs ...*bgp.Advertisement) error
-}
-
 func (c *bgpController) SetNode(l log.Logger, node *v1.Node) error {
 	nodeLabels := node.Labels
 	if nodeLabels == nil {
@@ -292,6 +296,14 @@ func (c *bgpController) SetNode(l log.Logger, node *v1.Node) error {
 	return c.syncPeers(l)
 }
 
-var newBGP = func(logger log.Logger, addr string, srcAddr net.IP, myASN uint32, routerID net.IP, asn uint32, hold time.Duration, password string, myNode string) (session, error) {
-	return bgp.New(logger, addr, srcAddr, myASN, routerID, asn, hold, password, myNode)
+// Create a new 'bgp.SessionManager' of type 'bgpType'.
+var newBGP = func(bgpType bgpImplementation) bgp.SessionManager {
+	switch bgpType {
+	case bgpNative:
+		return bgpnative.NewSessionManager()
+	case bgpFrr:
+		return bgpfrr.NewSessionManager()
+	default:
+		panic(fmt.Sprintf("unsupported BGP implementation type: %s", bgpType))
+	}
 }
diff --git a/speaker/main.go b/speaker/main.go
index 5f9b5d6d..4247432d 100644
--- a/speaker/main.go
+++ b/speaker/main.go
@@ -76,6 +76,13 @@ func main() {
 	)
 	flag.Parse()
 
+	// Note: Changing the MetalLB BGP implementation type should be considered
+	//       experimental.
+	bgpType, present := os.LookupEnv("METALLB_BGP_TYPE")
+	if !present {
+		bgpType = "native"
+	}
+
 	logger, err := logging.Init(*logLevel)
 	if err != nil {
 		fmt.Printf("failed to initialize logging: %s\n", err)
@@ -116,9 +123,10 @@ func main() {
 
 	// Setup all clients and speakers, config decides what is being done runtime.
 	ctrl, err := newController(controllerConfig{
-		MyNode: *myNode,
-		Logger: logger,
-		SList:  sList,
+		MyNode:  *myNode,
+		Logger:  logger,
+		SList:   sList,
+		bgpType: bgpImplementation(bgpType),
 	})
 	if err != nil {
 		level.Error(logger).Log("op", "startup", "error", err, "msg", "failed to create MetalLB controller")
@@ -172,6 +180,8 @@ type controllerConfig struct {
 	Logger log.Logger
 	SList  SpeakerList
 
+	bgpType bgpImplementation
+
 	// For testing only, and will be removed in a future release.
 	// See: https://github.com/metallb/metallb/issues/152.
 	DisableLayer2 bool
@@ -180,9 +190,11 @@ type controllerConfig struct {
 func newController(cfg controllerConfig) (*controller, error) {
 	protocols := map[config.Proto]Protocol{
 		config.BGP: &bgpController{
-			logger: cfg.Logger,
-			myNode: cfg.MyNode,
-			svcAds: make(map[string][]*bgp.Advertisement),
+			logger:         cfg.Logger,
+			myNode:         cfg.MyNode,
+			svcAds:         make(map[string][]*bgp.Advertisement),
+			bgpType:        cfg.bgpType,
+			sessionManager: newBGP(cfg.bgpType),
 		},
 	}
 


Test patch:
diff --git a/internal/bgp/frr/frr_test.go b/internal/bgp/frr/frr_test.go
new file mode 100644
index 00000000..6b4ab556
--- /dev/null
+++ b/internal/bgp/frr/frr_test.go
@@ -0,0 +1,397 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package frr
+
+import (
+	"flag"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/go-kit/kit/log"
+	"go.universe.tf/metallb/internal/bgp"
+)
+
+const testData = "testdata/"
+
+var classCMask = net.IPv4Mask(0xff, 0xff, 0xff, 0)
+
+var update = flag.Bool("update", false, "update .golden files")
+
+func testOsHostname() (string, error) {
+	return "dummyhostname", nil
+}
+
+func testCompareFiles(t *testing.T, configFile, goldenFile string) {
+	cmd := exec.Command("diff", configFile, goldenFile)
+	output, err := cmd.Output()
+	if err != nil {
+		t.Fatalf("command %s returned error: %s\n%s", cmd.String(), err, output)
+	}
+}
+
+func testUpdateGoldenFile(t *testing.T, configFile, goldenFile string) {
+	t.Log("update golden file")
+	cmd := exec.Command("cp", "-a", configFile, goldenFile)
+	output, err := cmd.Output()
+	if err != nil {
+		t.Fatalf("command %s returned %s and error: %s", cmd.String(), output, err)
+	}
+}
+
+func testGenerateFileNames(t *testing.T) (string, string) {
+	return filepath.Join(testData, filepath.FromSlash(t.Name())), filepath.Join(testData, filepath.FromSlash(t.Name())+".golden")
+}
+
+func testSetup(t *testing.T) {
+	configFile, _ := testGenerateFileNames(t)
+
+	_, err := os.Create(configFile)
+	if err != nil {
+		t.Fatalf("cannot create config file %s: %s", configFile, err)
+	}
+
+	osHostname = testOsHostname
+
+	os.Setenv("FRR_CONFIG_FILE", configFile)
+}
+
+func testCheckConfigFile(t *testing.T) {
+	configFile, goldenFile := testGenerateFileNames(t)
+
+	if *update {
+		testUpdateGoldenFile(t, configFile, goldenFile)
+	}
+	testCompareFiles(t, configFile, goldenFile)
+}
+
+func TestSingleSession(t *testing.T) {
+	testSetup(t)
+
+	l := log.NewNopLogger()
+	sessionManager := NewSessionManager()
+	session, err := sessionManager.NewSession(l, "10.2.2.254:179", net.ParseIP("10.1.1.254"), 100, net.ParseIP("10.1.1.254"), 200, time.Second, "password", "hostname")
+	if err != nil {
+		t.Fatalf("Could not create session: %s", err)
+	}
+	defer session.Close()
+
+	testCheckConfigFile(t)
+}
+
+func TestSingleSessionClose(t *testing.T) {
+	testSetup(t)
+
+	l := log.NewNopLogger()
+	sessionManager := NewSessionManager()
+	session, err := sessionManager.NewSession(l, "10.2.2.254:179", net.ParseIP("10.1.1.254"), 100, net.ParseIP("10.1.1.254"), 200, time.Second, "password", "hostname")
+	if err != nil {
+		t.Fatalf("Could not create session: %s", err)
+	}
+
+	session.Close()
+	testCheckConfigFile(t)
+}
+func TestTwoSessions(t *testing.T) {
+	testSetup(t)
+
+	l := log.NewNopLogger()
+	sessionManager := NewSessionManager()
+	session1, err := sessionManager.NewSession(l, "10.2.2.254:179", net.ParseIP("10.1.1.254"), 100, net.ParseIP("10.1.1.254"), 200, time.Second, "password", "hostname")
+	if err != nil {
+		t.Fatalf("Could not create session: %s", err)
+	}
+	defer session1.Close()
+	session2, err := sessionManager.NewSession(l, "10.4.4.254:179", net.ParseIP("10.3.3.254"), 300, net.ParseIP("10.3.3.254"), 400, time.Second, "password", "hostname")
+	if err != nil {
+		t.Fatalf("Could not create session: %s", err)
+	}
+	defer session2.Close()
+
+	testCheckConfigFile(t)
+}
+
+func TestTwoSessionsDuplicate(t *testing.T) {
+	testSetup(t)
+
+	l := log.NewNopLogger()
+	sessionManager := NewSessionManager()
+	session1, err := sessionManager.NewSession(l, "10.2.2.254:179", net.ParseIP("10.1.1.254"), 100, net.ParseIP("10.1.1.254"), 200, time.Second, "password", "hostname")
+	if err != nil {
+		t.Fatalf("Could not create session: %s", err)
+	}
+	defer session1.Close()
+	session2, err := sessionManager.NewSession(l, "10.2.2.254:179", net.ParseIP("10.1.1.254"), 100, net.ParseIP("10.1.1.254"), 200, time.Second, "password", "hostname")
+	if err != nil {
+		t.Fatalf("Could not create session: %s", err)
+	}
+	defer session2.Close()
+
+	testCheckConfigFile(t)
+}
+
+func TestTwoSessionsDuplicateRouter(t *testing.T) {
+	testSetup(t)
+
+	l := log.NewNopLogger()
+	sessionManager := NewSessionManager()
+	session1, err := sessionManager.NewSession(l, "10.2.2.254:179", net.ParseIP("10.1.1.254"), 100, net.ParseIP("10.1.1.254"), 200, time.Second, "password", "hostname")
+	if err != nil {
+		t.Fatalf("Could not create session: %s", err)
+	}
+	defer session1.Close()
+	session2, err := sessionManager.NewSession(l, "10.4.4.254:179", net.ParseIP("10.1.1.254"), 100, net.ParseIP("10.1.1.254"), 400, time.Second, "password", "hostname")
+	if err != nil {
+		t.Fatalf("Could not create session: %s", err)
+	}
+	defer session2.Close()
+
+	testCheckConfigFile(t)
+}
+
+func TestSingleAdvertisement(t *testing.T) {
+	testSetup(t)
+
+	l := log.NewNopLogger()
+	sessionManager := NewSessionManager()
+	session, err := sessionManager.NewSession(l, "10.2.2.254:179", net.ParseIP("10.1.1.254"), 100, net.ParseIP("10.1.1.254"), 200, time.Second, "password", "hostname")
+	if err != nil {
+		t.Fatalf("Could not create session: %s", err)
+	}
+	defer session.Close()
+
+	prefix := &net.IPNet{
+		IP:   net.ParseIP("172.16.1.10"),
+		Mask: classCMask,
+	}
+
+	adv := &bgp.Advertisement{
+		Prefix:  prefix,
+		NextHop: net.ParseIP("10.1.1.1"),
+	}
+
+	err = session.Set(adv)
+	if err != nil {
+		t.Fatalf("Could not advertise prefix: %s", err)
+	}
+
+	testCheckConfigFile(t)
+}
+
+func TestSingleAdvertisementNoRouterID(t *testing.T) {
+	testSetup(t)
+
+	l := log.NewNopLogger()
+	sessionManager := NewSessionManager()
+	session, err := sessionManager.NewSession(l, "10.2.2.254:179", net.ParseIP("10.1.1.254"), 100, nil, 200, time.Second, "password", "hostname")
+	if err != nil {
+		t.Fatalf("Could not create session: %s", err)
+	}
+	defer session.Close()
+
+	prefix := &net.IPNet{
+		IP:   net.ParseIP("172.16.1.10"),
+		Mask: classCMask,
+	}
+
+	adv := &bgp.Advertisement{
+		Prefix:  prefix,
+		NextHop: net.ParseIP("10.1.1.1"),
+	}
+
+	err = session.Set(adv)
+	if err != nil {
+		t.Fatalf("Could not advertise prefix: %s", err)
+	}
+
+	testCheckConfigFile(t)
+}
+
+func TestSingleAdvertisementInvalidPrefix(t *testing.T) {
+	testSetup(t)
+
+	l := log.NewNopLogger()
+	sessionManager := NewSessionManager()
+	session, err := sessionManager.NewSession(l, "10.2.2.254:179", net.ParseIP("10.1.1.254"), 100, net.ParseIP("10.1.1.254"), 200, time.Second, "password", "hostname")
+	if err != nil {
+		t.Fatalf("Could not create session: %s", err)
+	}
+	defer session.Close()
+
+	prefix := &net.IPNet{}
+
+	adv := &bgp.Advertisement{
+		Prefix:  prefix,
+		NextHop: net.ParseIP("10.1.1.1"),
+	}
+
+	err = session.Set(adv)
+	if err == nil {
+		t.Fatalf("Set should return error")
+	}
+
+	testCheckConfigFile(t)
+}
+
+func TestSingleAdvertisementInvalidNoPort(t *testing.T) {
+	testSetup(t)
+
+	l := log.NewNopLogger()
+	sessionManager := NewSessionManager()
+	session, err := sessionManager.NewSession(l, "10.2.2.254", net.ParseIP("10.1.1.254"), 100, net.ParseIP("10.1.1.254"), 200, time.Second, "password", "hostname")
+	if err == nil {
+		session.Close()
+		t.Fatalf("Should not be able to create session")
+	}
+
+	testCheckConfigFile(t)
+}
+
+func TestSingleAdvertisementInvalidNextHop(t *testing.T) {
+	t.Skip("TODO: bgp.Validate() incorrectly(?) returns err == nil")
+	testSetup(t)
+
+	l := log.NewNopLogger()
+	sessionManager := NewSessionManager()
+	session, err := sessionManager.NewSession(l, "10.2.2.254:179", net.ParseIP("10.1.1.254"), 100, net.ParseIP("10.1.1.254"), 200, time.Second, "password", "hostname")
+	if err != nil {
+		t.Fatalf("Could not create session: %s", err)
+	}
+	defer session.Close()
+
+	prefix := &net.IPNet{
+		IP:   net.ParseIP("172.16.1.10"),
+		Mask: classCMask,
+	}
+
+	adv := &bgp.Advertisement{
+		Prefix: prefix,
+	}
+
+	err = session.Set(adv)
+	if err != nil {
+		t.Fatalf("Could not advertise prefix: %s", err)
+	}
+
+	testCheckConfigFile(t)
+}
+
+func TestSingleAdvertisementStop(t *testing.T) {
+	testSetup(t)
+
+	l := log.NewNopLogger()
+	sessionManager := NewSessionManager()
+	session, err := sessionManager.NewSession(l, "10.2.2.254:179", net.ParseIP("10.1.1.254"), 100, net.ParseIP("10.1.1.254"), 200, time.Second, "password", "hostname")
+	if err != nil {
+		t.Fatalf("Could not create session: %s", err)
+	}
+	defer session.Close()
+
+	prefix := &net.IPNet{
+		IP:   net.ParseIP("172.16.1.10"),
+		Mask: classCMask,
+	}
+
+	adv := &bgp.Advertisement{
+		Prefix:  prefix,
+		NextHop: net.ParseIP("10.1.1.1"),
+	}
+
+	err = session.Set(adv)
+	if err != nil {
+		t.Fatalf("Could not advertise prefix: %s", err)
+	}
+
+	err = session.Set()
+	if err != nil {
+		t.Fatalf("Could not advertise prefix: %s", err)
+	}
+
+	testCheckConfigFile(t)
+}
+
+func TestSingleAdvertisementChange(t *testing.T) {
+	testSetup(t)
+
+	l := log.NewNopLogger()
+	sessionManager := NewSessionManager()
+	session, err := sessionManager.NewSession(l, "10.2.2.254:179", net.ParseIP("10.1.1.254"), 100, net.ParseIP("10.1.1.254"), 200, time.Second, "password", "hostname")
+	if err != nil {
+		t.Fatalf("Could not create session: %s", err)
+	}
+	defer session.Close()
+
+	prefix := &net.IPNet{
+		IP:   net.ParseIP("172.16.1.10"),
+		Mask: classCMask,
+	}
+
+	adv := &bgp.Advertisement{
+		Prefix:  prefix,
+		NextHop: net.ParseIP("10.1.1.1"),
+	}
+
+	err = session.Set(adv)
+	if err != nil {
+		t.Fatalf("Could not advertise prefix: %s", err)
+	}
+
+	prefix = &net.IPNet{
+		IP:   net.ParseIP("172.16.1.11"),
+		Mask: classCMask,
+	}
+
+	adv = &bgp.Advertisement{
+		Prefix:  prefix,
+		NextHop: net.ParseIP("10.1.1.1"),
+	}
+
+	err = session.Set(adv)
+	if err != nil {
+		t.Fatalf("Could not advertise prefix: %s", err)
+	}
+
+	testCheckConfigFile(t)
+}
+
+func TestTwoAdvertisements(t *testing.T) {
+	testSetup(t)
+
+	l := log.NewNopLogger()
+	sessionManager := NewSessionManager()
+	session, err := sessionManager.NewSession(l, "10.2.2.254:179", net.ParseIP("10.1.1.254"), 100, net.ParseIP("10.1.1.254"), 200, time.Second, "password", "hostname")
+	if err != nil {
+		t.Fatalf("Could not create session: %s", err)
+	}
+	defer session.Close()
+
+	prefix1 := &net.IPNet{
+		IP:   net.ParseIP("172.16.1.10"),
+		Mask: classCMask,
+	}
+
+	adv1 := &bgp.Advertisement{
+		Prefix:  prefix1,
+		NextHop: net.ParseIP("10.1.1.1"),
+	}
+
+	prefix2 := &net.IPNet{
+		IP:   net.ParseIP("172.16.1.11"),
+		Mask: classCMask,
+	}
+
+	adv2 := &bgp.Advertisement{
+		Prefix:  prefix2,
+		NextHop: net.ParseIP("10.1.1.1"),
+	}
+
+	err = session.Set(adv1, adv2)
+	if err != nil {
+		t.Fatalf("Could not advertise prefix: %s", err)
+	}
+
+	testCheckConfigFile(t)
+}
diff --git a/internal/bgp/frr/testdata/.gitignore b/internal/bgp/frr/testdata/.gitignore
new file mode 100644
index 00000000..64c24ad5
--- /dev/null
+++ b/internal/bgp/frr/testdata/.gitignore
@@ -0,0 +1,2 @@
+Test*
+!*.golden
diff --git a/internal/bgp/frr/testdata/TestSingleAdvertisement.golden b/internal/bgp/frr/testdata/TestSingleAdvertisement.golden
new file mode 100644
index 00000000..e298a9cf
--- /dev/null
+++ b/internal/bgp/frr/testdata/TestSingleAdvertisement.golden
@@ -0,0 +1,19 @@
+
+log stdout 
+hostname dummyhostname
+
+router bgp 100
+  no bgp network import-check
+  no bgp default ipv4-unicast
+
+  bgp router-id 10.1.1.254
+
+  neighbor 10.2.2.254 remote-as 200
+  neighbor 10.2.2.254 port 179
+
+  address-family ipv4 unicast
+    neighbor 10.2.2.254 activate
+    network 172.16.1.10/24
+  exit-address-family
+
+
diff --git a/internal/bgp/frr/testdata/TestSingleAdvertisementChange.golden b/internal/bgp/frr/testdata/TestSingleAdvertisementChange.golden
new file mode 100644
index 00000000..65fd204a
--- /dev/null
+++ b/internal/bgp/frr/testdata/TestSingleAdvertisementChange.golden
@@ -0,0 +1,19 @@
+
+log stdout 
+hostname dummyhostname
+
+router bgp 100
+  no bgp network import-check
+  no bgp default ipv4-unicast
+
+  bgp router-id 10.1.1.254
+
+  neighbor 10.2.2.254 remote-as 200
+  neighbor 10.2.2.254 port 179
+
+  address-family ipv4 unicast
+    neighbor 10.2.2.254 activate
+    network 172.16.1.11/24
+  exit-address-family
+
+
diff --git a/internal/bgp/frr/testdata/TestSingleAdvertisementInvalid.golden b/internal/bgp/frr/testdata/TestSingleAdvertisementInvalid.golden
new file mode 100644
index 00000000..0975efc9
--- /dev/null
+++ b/internal/bgp/frr/testdata/TestSingleAdvertisementInvalid.golden
@@ -0,0 +1,12 @@
+
+log stdout 
+hostname 
+
+router bgp 100
+  no bgp default ipv4-unicast
+  bgp router-id 10.1.1.254
+
+  neighbor 10.2.2.254 remote-as 200
+
+
+
diff --git a/internal/bgp/frr/testdata/TestSingleAdvertisementInvalidNoPort.golden b/internal/bgp/frr/testdata/TestSingleAdvertisementInvalidNoPort.golden
new file mode 100644
index 00000000..e69de29b
diff --git a/internal/bgp/frr/testdata/TestSingleAdvertisementInvalidPrefix.golden b/internal/bgp/frr/testdata/TestSingleAdvertisementInvalidPrefix.golden
new file mode 100644
index 00000000..2bdb8fd4
--- /dev/null
+++ b/internal/bgp/frr/testdata/TestSingleAdvertisementInvalidPrefix.golden
@@ -0,0 +1,15 @@
+
+log stdout 
+hostname dummyhostname
+
+router bgp 100
+  no bgp network import-check
+  no bgp default ipv4-unicast
+
+  bgp router-id 10.1.1.254
+
+  neighbor 10.2.2.254 remote-as 200
+  neighbor 10.2.2.254 port 179
+
+
+
diff --git a/internal/bgp/frr/testdata/TestSingleAdvertisementNoRouterID.golden b/internal/bgp/frr/testdata/TestSingleAdvertisementNoRouterID.golden
new file mode 100644
index 00000000..e16d59e0
--- /dev/null
+++ b/internal/bgp/frr/testdata/TestSingleAdvertisementNoRouterID.golden
@@ -0,0 +1,18 @@
+
+log stdout 
+hostname dummyhostname
+
+router bgp 100
+  no bgp network import-check
+  no bgp default ipv4-unicast
+
+
+  neighbor 10.2.2.254 remote-as 200
+  neighbor 10.2.2.254 port 179
+
+  address-family ipv4 unicast
+    neighbor 10.2.2.254 activate
+    network 172.16.1.10/24
+  exit-address-family
+
+
diff --git a/internal/bgp/frr/testdata/TestSingleAdvertisementStop.golden b/internal/bgp/frr/testdata/TestSingleAdvertisementStop.golden
new file mode 100644
index 00000000..2bdb8fd4
--- /dev/null
+++ b/internal/bgp/frr/testdata/TestSingleAdvertisementStop.golden
@@ -0,0 +1,15 @@
+
+log stdout 
+hostname dummyhostname
+
+router bgp 100
+  no bgp network import-check
+  no bgp default ipv4-unicast
+
+  bgp router-id 10.1.1.254
+
+  neighbor 10.2.2.254 remote-as 200
+  neighbor 10.2.2.254 port 179
+
+
+
diff --git a/internal/bgp/frr/testdata/TestSingleSession.golden b/internal/bgp/frr/testdata/TestSingleSession.golden
new file mode 100644
index 00000000..2bdb8fd4
--- /dev/null
+++ b/internal/bgp/frr/testdata/TestSingleSession.golden
@@ -0,0 +1,15 @@
+
+log stdout 
+hostname dummyhostname
+
+router bgp 100
+  no bgp network import-check
+  no bgp default ipv4-unicast
+
+  bgp router-id 10.1.1.254
+
+  neighbor 10.2.2.254 remote-as 200
+  neighbor 10.2.2.254 port 179
+
+
+
diff --git a/internal/bgp/frr/testdata/TestSingleSessionClose.golden b/internal/bgp/frr/testdata/TestSingleSessionClose.golden
new file mode 100644
index 00000000..a7fcabc6
--- /dev/null
+++ b/internal/bgp/frr/testdata/TestSingleSessionClose.golden
@@ -0,0 +1,5 @@
+
+log stdout 
+hostname dummyhostname
+
+
diff --git a/internal/bgp/frr/testdata/TestTwoAdvertisements.golden b/internal/bgp/frr/testdata/TestTwoAdvertisements.golden
new file mode 100644
index 00000000..6180fbad
--- /dev/null
+++ b/internal/bgp/frr/testdata/TestTwoAdvertisements.golden
@@ -0,0 +1,23 @@
+
+log stdout 
+hostname dummyhostname
+
+router bgp 100
+  no bgp network import-check
+  no bgp default ipv4-unicast
+
+  bgp router-id 10.1.1.254
+
+  neighbor 10.2.2.254 remote-as 200
+  neighbor 10.2.2.254 port 179
+
+  address-family ipv4 unicast
+    neighbor 10.2.2.254 activate
+    network 172.16.1.10/24
+  exit-address-family
+  address-family ipv4 unicast
+    neighbor 10.2.2.254 activate
+    network 172.16.1.11/24
+  exit-address-family
+
+
diff --git a/internal/bgp/frr/testdata/TestTwoSessions.golden b/internal/bgp/frr/testdata/TestTwoSessions.golden
new file mode 100644
index 00000000..336df76a
--- /dev/null
+++ b/internal/bgp/frr/testdata/TestTwoSessions.golden
@@ -0,0 +1,25 @@
+
+log stdout 
+hostname dummyhostname
+
+router bgp 100
+  no bgp network import-check
+  no bgp default ipv4-unicast
+
+  bgp router-id 10.1.1.254
+
+  neighbor 10.2.2.254 remote-as 200
+  neighbor 10.2.2.254 port 179
+
+
+router bgp 300
+  no bgp network import-check
+  no bgp default ipv4-unicast
+
+  bgp router-id 10.3.3.254
+
+  neighbor 10.4.4.254 remote-as 400
+  neighbor 10.4.4.254 port 179
+
+
+
diff --git a/internal/bgp/frr/testdata/TestTwoSessionsDuplicate.golden b/internal/bgp/frr/testdata/TestTwoSessionsDuplicate.golden
new file mode 100644
index 00000000..2bdb8fd4
--- /dev/null
+++ b/internal/bgp/frr/testdata/TestTwoSessionsDuplicate.golden
@@ -0,0 +1,15 @@
+
+log stdout 
+hostname dummyhostname
+
+router bgp 100
+  no bgp network import-check
+  no bgp default ipv4-unicast
+
+  bgp router-id 10.1.1.254
+
+  neighbor 10.2.2.254 remote-as 200
+  neighbor 10.2.2.254 port 179
+
+
+
diff --git a/internal/bgp/frr/testdata/TestTwoSessionsDuplicateRouter.golden b/internal/bgp/frr/testdata/TestTwoSessionsDuplicateRouter.golden
new file mode 100644
index 00000000..acdfc2b3
--- /dev/null
+++ b/internal/bgp/frr/testdata/TestTwoSessionsDuplicateRouter.golden
@@ -0,0 +1,18 @@
+
+log stdout 
+hostname dummyhostname
+
+router bgp 100
+  no bgp network import-check
+  no bgp default ipv4-unicast
+
+  bgp router-id 10.1.1.254
+
+  neighbor 10.2.2.254 remote-as 200
+  neighbor 10.2.2.254 port 179
+  neighbor 10.4.4.254 remote-as 400
+  neighbor 10.4.4.254 port 179
+
+
+
+
diff --git a/internal/bgp/messages_test.go b/internal/bgp/native/messages_test.go
similarity index 99%
rename from internal/bgp/messages_test.go
rename to internal/bgp/native/messages_test.go
index 45d00486..cd6d2507 100644
--- a/internal/bgp/messages_test.go
+++ b/internal/bgp/native/messages_test.go
@@ -1,6 +1,6 @@
 // SPDX-License-Identifier:Apache-2.0
 
-package bgp
+package native
 
 import (
 	"bytes"
diff --git a/internal/bgp/bgp_test.go b/internal/bgp/native/native_test.go
similarity index 99%
rename from internal/bgp/bgp_test.go
rename to internal/bgp/native/native_test.go
index c35887a3..1c8ce22c 100644
--- a/internal/bgp/bgp_test.go
+++ b/internal/bgp/native/native_test.go
@@ -2,7 +2,7 @@
 
 // +build disabled
 
-package bgp
+package native
 
 // This test is disabled when using the race detector, because of
 // https://github.com/osrg/gobgp/issues/1530.
diff --git a/speaker/bgp_controller_test.go b/speaker/bgp_controller_test.go
index 747d31a2..d79da061 100644
--- a/speaker/bgp_controller_test.go
+++ b/speaker/bgp_controller_test.go
@@ -91,6 +91,18 @@ func sortAds(ads map[string][]*bgp.Advertisement) {
 }
 
 type fakeBGP struct {
+	t              *testing.T
+	sessionManager fakeBGPSessionManager
+}
+
+func (f *fakeBGP) NewSessionManager(_ bgpImplementation) bgp.SessionManager {
+	f.sessionManager.t = f.t
+	f.sessionManager.gotAds = make(map[string][]*bgp.Advertisement)
+
+	return &f.sessionManager
+}
+
+type fakeBGPSessionManager struct {
 	t *testing.T
 
 	sync.Mutex
@@ -98,7 +110,7 @@ type fakeBGP struct {
 	gotAds map[string][]*bgp.Advertisement
 }
 
-func (f *fakeBGP) New(_ log.Logger, addr string, _ net.IP, _ uint32, _ net.IP, _ uint32, _ time.Duration, _, _ string) (session, error) {
+func (f *fakeBGPSessionManager) NewSession(_ log.Logger, addr string, _ net.IP, _ uint32, _ net.IP, _ uint32, _ time.Duration, _, _ string) (bgp.Session, error) {
 	f.Lock()
 	defer f.Unlock()
 
@@ -115,7 +127,7 @@ func (f *fakeBGP) New(_ log.Logger, addr string, _ net.IP, _ uint32, _ net.IP, _
 	}, nil
 }
 
-func (f *fakeBGP) Ads() map[string][]*bgp.Advertisement {
+func (f *fakeBGPSessionManager) Ads() map[string][]*bgp.Advertisement {
 	ret := map[string][]*bgp.Advertisement{}
 
 	f.Lock()
@@ -140,7 +152,7 @@ func (f *fakeBGP) Ads() map[string][]*bgp.Advertisement {
 }
 
 type fakeSession struct {
-	f    *fakeBGP
+	f    *fakeBGPSessionManager
 	addr string
 }
 
@@ -192,10 +204,9 @@ func (s *testK8S) Errorf(_ *v1.Service, evtType string, msg string, args ...inte
 
 func TestBGPSpeaker(t *testing.T) {
 	b := &fakeBGP{
-		t:      t,
-		gotAds: map[string][]*bgp.Advertisement{},
+		t: t,
 	}
-	newBGP = b.New
+	newBGP = b.NewSessionManager
 	c, err := newController(controllerConfig{
 		MyNode:        "pandora",
 		DisableLayer2: true,
@@ -883,7 +894,7 @@ func TestBGPSpeaker(t *testing.T) {
 			}
 		}
 
-		gotAds := b.Ads()
+		gotAds := b.sessionManager.Ads()
 		sortAds(test.wantAds)
 		sortAds(gotAds)
 		if diff := cmp.Diff(test.wantAds, gotAds); diff != "" {
@@ -894,10 +905,9 @@ func TestBGPSpeaker(t *testing.T) {
 
 func TestBGPSpeakerEPSlices(t *testing.T) {
 	b := &fakeBGP{
-		t:      t,
-		gotAds: map[string][]*bgp.Advertisement{},
+		t: t,
 	}
-	newBGP = b.New
+	newBGP = b.NewSessionManager
 	c, err := newController(controllerConfig{
 		MyNode:        "pandora",
 		DisableLayer2: true,
@@ -1696,7 +1706,7 @@ func TestBGPSpeakerEPSlices(t *testing.T) {
 			}
 		}
 
-		gotAds := b.Ads()
+		gotAds := b.sessionManager.Ads()
 		sortAds(test.wantAds)
 		sortAds(gotAds)
 		if diff := cmp.Diff(test.wantAds, gotAds); diff != "" {
@@ -1707,10 +1717,9 @@ func TestBGPSpeakerEPSlices(t *testing.T) {
 
 func TestNodeSelectors(t *testing.T) {
 	b := &fakeBGP{
-		t:      t,
-		gotAds: map[string][]*bgp.Advertisement{},
+		t: t,
 	}
-	newBGP = b.New
+	newBGP = b.NewSessionManager
 	c, err := newController(controllerConfig{
 		MyNode:        "pandora",
 		DisableLayer2: true,
@@ -1902,7 +1911,7 @@ func TestNodeSelectors(t *testing.T) {
 			}
 		}
 
-		gotAds := b.Ads()
+		gotAds := b.sessionManager.Ads()
 		sortAds(test.wantAds)
 		sortAds(gotAds)
 		if diff := cmp.Diff(test.wantAds, gotAds); diff != "" {