New Upstream Release - golang-github-insomniacslk-dhcp

Ready changes

Summary

Merged new upstream version: 0.0~git20230516.4980196 (was: 0.0~git20220915.043f172).

Diff

diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 2777592..1e39b34 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -7,12 +7,12 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        go: ['1.13', '1.14', '1.15', '1.16']
+        go: ['1.18', '1.19', '1.20']
     env:
       GO111MODULE: on
     steps:
-      - uses: actions/checkout@v2
-      - uses: actions/setup-go@v2
+      - uses: actions/checkout@v3
+      - uses: actions/setup-go@v3
         with:
           stable: false
           go-version: ${{ matrix.go }}
@@ -28,22 +28,17 @@ jobs:
               fi
           done
       - name: report coverage to codecov
-        uses: codecov/codecov-action@v1
-        with:
-          files: coverage.txt
-          flags: unittests
-          fail_ci_if_error: true
-          verbose: true
+        uses: codecov/codecov-action@v3
   integration-tests:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        go: ['1.13', '1.14', '1.15', '1.16']
+        go: ['1.18', '1.19', '1.20']
     env:
       GO111MODULE: on
     steps:
-      - uses: actions/checkout@v2
-      - uses: actions/setup-go@v2
+      - uses: actions/checkout@v3
+      - uses: actions/setup-go@v3
         with:
           stable: false
           go-version: ${{ matrix.go }}
@@ -62,9 +57,4 @@ jobs:
               fi
           done
       - name: report coverage to codecov
-        uses: codecov/codecov-action@v1
-        with:
-          files: coverage.txt
-          flags: integtests
-          fail_ci_if_error: true
-          verbose: true
+        uses: codecov/codecov-action@v3
diff --git a/README.md b/README.md
index 979bc7a..741cb84 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
 # dhcp
-[![Build Status](https://img.shields.io/github/workflow/status/insomniacslk/dhcp/Tests/master)](https://github.com/insomniacslk/dhcp/actions?query=branch%3Amaster)
+[![Build Status](https://img.shields.io/github/actions/workflow/status/insomniacslk/dhcp/tests.yml?branch=master)](https://github.com/insomniacslk/dhcp/actions?query=branch%3Amaster)
 [![GoDoc](https://godoc.org/github.com/insomniacslk/dhcp?status.svg)](https://godoc.org/github.com/insomniacslk/dhcp)
 [![codecov](https://codecov.io/gh/insomniacslk/dhcp/branch/master/graph/badge.svg)](https://codecov.io/gh/insomniacslk/dhcp)
 [![Go Report Card](https://goreportcard.com/badge/github.com/insomniacslk/dhcp)](https://goreportcard.com/report/github.com/insomniacslk/dhcp)
diff --git a/debian/changelog b/debian/changelog
index ffda288..791ccbb 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+golang-github-insomniacslk-dhcp (0.0~git20230516.4980196-1) UNRELEASED; urgency=low
+
+  * New upstream snapshot.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Mon, 22 May 2023 16:23:52 -0000
+
 golang-github-insomniacslk-dhcp (0.0~git20220915.043f172-2) unstable; urgency=medium
 
   [ Debian Janitor ]
diff --git a/dhcpv4/dhcpv4.go b/dhcpv4/dhcpv4.go
index e2fd190..6043cd9 100644
--- a/dhcpv4/dhcpv4.go
+++ b/dhcpv4/dhcpv4.go
@@ -222,8 +222,7 @@ func PrependModifiers(m []Modifier, other ...Modifier) []Modifier {
 // NewInform builds a new DHCPv4 Informational message with the specified
 // hardware address.
 func NewInform(hwaddr net.HardwareAddr, localIP net.IP, modifiers ...Modifier) (*DHCPv4, error) {
-	return New(PrependModifiers(
-		modifiers,
+	return New(PrependModifiers(modifiers,
 		WithHwAddr(hwaddr),
 		WithMessageType(MessageTypeInform),
 		WithClientIP(localIP),
@@ -249,18 +248,22 @@ func NewRequestFromOffer(offer *DHCPv4, modifiers ...Modifier) (*DHCPv4, error)
 	)...)
 }
 
-// NewRenewFromOffer builds a DHCPv4 RENEW-style request from an offer. RENEW requests have minor
-// changes to their options compared to SELECT requests as specified by RFC 2131, section 4.3.2.
-func NewRenewFromOffer(offer *DHCPv4, modifiers ...Modifier) (*DHCPv4, error) {
-	return NewRequestFromOffer(offer, PrependModifiers(modifiers,
-		// The server identifier option must not be filled in
-		WithoutOption(OptionServerIdentifier),
-		// The requested IP address must not be filled in
-		WithoutOption(OptionRequestedIPAddress),
+// NewRenewFromAck builds a DHCPv4 RENEW-style request from the ACK of a lease. RENEW requests have
+// minor changes to their options compared to SELECT requests as specified by RFC 2131, section 4.3.2.
+func NewRenewFromAck(ack *DHCPv4, modifiers ...Modifier) (*DHCPv4, error) {
+	return New(PrependModifiers(modifiers,
+		WithReply(ack),
+		WithMessageType(MessageTypeRequest),
 		// The client IP must be filled in with the IP offered to the client
-		WithClientIP(offer.YourIPAddr),
+		WithClientIP(ack.YourIPAddr),
 		// The renewal request must use unicast
 		WithBroadcast(false),
+		WithRequestedOptions(
+			OptionSubnetMask,
+			OptionRouter,
+			OptionDomainName,
+			OptionDomainNameServer,
+		),
 	)...)
 }
 
diff --git a/dhcpv4/dhcpv4_test.go b/dhcpv4/dhcpv4_test.go
index eff7a71..a961f78 100644
--- a/dhcpv4/dhcpv4_test.go
+++ b/dhcpv4/dhcpv4_test.go
@@ -266,23 +266,22 @@ func TestDHCPv4NewRequestFromOfferWithModifier(t *testing.T) {
 	require.Equal(t, MessageTypeRequest, req.MessageType())
 }
 
-func TestDHCPv4NewRenewFromOffer(t *testing.T) {
-	offer, err := New()
+func TestDHCPv4NewRenewFromAck(t *testing.T) {
+	ack, err := New()
 	require.NoError(t, err)
-	offer.SetBroadcast()
-	offer.UpdateOption(OptMessageType(MessageTypeOffer))
-	offer.UpdateOption(OptServerIdentifier(net.IPv4(192, 168, 0, 1)))
-	offer.UpdateOption(OptRequestedIPAddress(net.IPv4(192, 168, 0, 1)))
-	offer.YourIPAddr = net.IPv4(192, 168, 0, 1)
+	ack.SetBroadcast()
+	ack.UpdateOption(OptMessageType(MessageTypeAck))
+	ack.UpdateOption(OptServerIdentifier(net.IPv4(192, 168, 0, 1)))
+	ack.YourIPAddr = net.IPv4(192, 168, 0, 1)
 
-	// RFC 2131: RENEW-style requests will be unicast
 	var req *DHCPv4
-	req, err = NewRenewFromOffer(offer)
+	req, err = NewRenewFromAck(ack)
 	require.NoError(t, err)
 	require.Equal(t, MessageTypeRequest, req.MessageType())
 	require.Nil(t, req.GetOneOption(OptionServerIdentifier))
 	require.Nil(t, req.GetOneOption(OptionRequestedIPAddress))
-	require.Equal(t, offer.YourIPAddr, req.ClientIPAddr)
+	require.Equal(t, ack.YourIPAddr, req.ClientIPAddr)
+	// RFC 2131: RENEW-style requests will be unicast
 	require.True(t, req.IsUnicast())
 	require.False(t, req.IsBroadcast())
 	// Renewals should behave identically to initial requests regarding requested options
@@ -292,12 +291,12 @@ func TestDHCPv4NewRenewFromOffer(t *testing.T) {
 	require.True(t, req.IsOptionRequested(OptionDomainNameServer))
 }
 
-func TestDHCPv4NewRenewFromOfferWithModifier(t *testing.T) {
-	offer, err := New()
+func TestDHCPv4NewRenewFromAckWithModifier(t *testing.T) {
+	ack, err := New()
 	require.NoError(t, err)
-	offer.UpdateOption(OptMessageType(MessageTypeOffer))
+	ack.UpdateOption(OptMessageType(MessageTypeAck))
 	userClass := WithUserClass("linuxboot", false)
-	req, err := NewRenewFromOffer(offer, userClass)
+	req, err := NewRenewFromAck(ack, userClass)
 	require.NoError(t, err)
 	require.Equal(t, MessageTypeRequest, req.MessageType())
 	require.Contains(t, req.UserClass(), "linuxboot")
@@ -355,6 +354,16 @@ func TestNewInform(t *testing.T) {
 	require.True(t, m.ClientIPAddr.Equal(localIP))
 }
 
+func TestDHCPv4NewInformWithModifier(t *testing.T) {
+	hwAddr := net.HardwareAddr{1, 2, 3, 4, 5, 6}
+	localIP := net.IPv4(10, 10, 11, 11)
+	serverID := net.IPv4(192, 168, 0, 1)
+	m, err := NewInform(hwAddr, localIP, WithOption(OptServerIdentifier(serverID)))
+
+	require.NoError(t, err)
+	require.True(t, serverID.Equal(m.ServerIdentifier()))
+}
+
 func TestIsOptionRequested(t *testing.T) {
 	pkt, err := New()
 	require.NoError(t, err)
diff --git a/dhcpv4/nclient4/client.go b/dhcpv4/nclient4/client.go
index d40e1a9..b4e4b56 100644
--- a/dhcpv4/nclient4/client.go
+++ b/dhcpv4/nclient4/client.go
@@ -478,6 +478,25 @@ func (c *Client) Request(ctx context.Context, modifiers ...dhcpv4.Modifier) (lea
 	return c.RequestFromOffer(ctx, offer, modifiers...)
 }
 
+// Inform sends an INFORM request using the given local IP.
+// Returns the ACK response from the server on success.
+func (c *Client) Inform(ctx context.Context, localIP net.IP, modifiers ...dhcpv4.Modifier) (*dhcpv4.DHCPv4, error) {
+	request, err := dhcpv4.NewInform(c.ifaceHWAddr, localIP, modifiers...)
+	if err != nil {
+		return nil, err
+	}
+
+	// DHCP clients must not fill in the server identifier in an INFORM request as per RFC 2131 Section 4.4.1 Table 5,
+	// however, they may still unicast the request to the target server if the address is known (c.serverAddr), as per
+	// Section 4.4.3. The server must then respond with an ACK, as per Section 4.3.5.
+	response, err := c.SendAndRead(ctx, c.serverAddr, request, IsMessageType(dhcpv4.MessageTypeAck))
+	if err != nil {
+		return nil, fmt.Errorf("got an error while processing the request: %w", err)
+	}
+
+	return response, nil
+}
+
 // ErrNak is returned if a DHCP server rejected our Request.
 type ErrNak struct {
 	Offer *dhcpv4.DHCPv4
diff --git a/dhcpv4/nclient4/conn_unix.go b/dhcpv4/nclient4/conn_unix.go
index bece752..7f79f50 100644
--- a/dhcpv4/nclient4/conn_unix.go
+++ b/dhcpv4/nclient4/conn_unix.go
@@ -13,9 +13,9 @@ import (
 	"io"
 	"net"
 
-	"github.com/mdlayher/ethernet"
-	"github.com/mdlayher/raw"
+	"github.com/mdlayher/packet"
 	"github.com/u-root/uio/uio"
+	"golang.org/x/sys/unix"
 )
 
 var (
@@ -39,7 +39,7 @@ func NewRawUDPConn(iface string, port int) (net.PacketConn, error) {
 	if err != nil {
 		return nil, err
 	}
-	rawConn, err := raw.ListenPacket(ifc, uint16(ethernet.EtherTypeIPv4), &raw.Config{LinuxSockDGRAM: true})
+	rawConn, err := packet.Listen(ifc, packet.Datagram, unix.ETH_P_IP, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -152,8 +152,8 @@ func (upc *BroadcastRawUDPConn) WriteTo(b []byte, addr net.Addr) (int, error) {
 	}
 
 	// Using the boundAddr is not quite right here, but it works.
-	packet := udp4pkt(b, udpAddr, upc.boundAddr)
+	pkt := udp4pkt(b, udpAddr, upc.boundAddr)
 
 	// Broadcasting is not always right, but hell, what the ARP do I know.
-	return upc.PacketConn.WriteTo(packet, &raw.Addr{HardwareAddr: BroadcastMac})
+	return upc.PacketConn.WriteTo(pkt, &packet.Addr{HardwareAddr: BroadcastMac})
 }
diff --git a/dhcpv4/nclient4/lease.go b/dhcpv4/nclient4/lease.go
index 1895dd0..8b1d9e6 100644
--- a/dhcpv4/nclient4/lease.go
+++ b/dhcpv4/nclient4/lease.go
@@ -42,15 +42,15 @@ func (c *Client) Release(lease *Lease, modifiers ...dhcpv4.Modifier) error {
 // sourced from the initial offer in the lease, and the ACK of the lease is updated to the ACK of
 // the latest renewal. This avoids issues with DHCP servers that omit information needed to build a
 // completely new lease from their renewal ACK (such as the Windows DHCP Server).
-func (c *Client) Renew(ctx context.Context, lease *Lease, modifiers ...dhcpv4.Modifier) error {
+func (c *Client) Renew(ctx context.Context, lease *Lease, modifiers ...dhcpv4.Modifier) (*Lease, error) {
 	if lease == nil {
-		return fmt.Errorf("lease is nil")
+		return nil, fmt.Errorf("lease is nil")
 	}
 
-	request, err := dhcpv4.NewRenewFromOffer(lease.Offer, dhcpv4.PrependModifiers(modifiers,
+	request, err := dhcpv4.NewRenewFromAck(lease.ACK, dhcpv4.PrependModifiers(modifiers,
 		dhcpv4.WithOption(dhcpv4.OptMaxMessageSize(MaxMessageSize)))...)
 	if err != nil {
-		return fmt.Errorf("unable to create a request: %w", err)
+		return nil, fmt.Errorf("unable to create a request: %w", err)
 	}
 
 	// Servers are supposed to only respond to Requests containing their server identifier,
@@ -61,17 +61,19 @@ func (c *Client) Renew(ctx context.Context, lease *Lease, modifiers ...dhcpv4.Mo
 		IsCorrectServer(lease.Offer.ServerIdentifier()),
 		IsMessageType(dhcpv4.MessageTypeAck, dhcpv4.MessageTypeNak)))
 	if err != nil {
-		return fmt.Errorf("got an error while processing the request: %w", err)
+		return nil, fmt.Errorf("got an error while processing the request: %w", err)
 	}
 	if response.MessageType() == dhcpv4.MessageTypeNak {
-		return &ErrNak{
+		return nil, &ErrNak{
 			Offer: lease.Offer,
 			Nak:   response,
 		}
 	}
 
-	// Update the ACK of the lease with the ACK of the latest renewal
-	lease.ACK = response
-
-	return nil
+	// Return a new lease with the latest ACK and updated creation time
+	return &Lease{
+		Offer:        lease.Offer,
+		ACK:          response,
+		CreationTime: time.Now(),
+	}, nil
 }
diff --git a/dhcpv4/nclient4/lease_test.go b/dhcpv4/nclient4/lease_test.go
index d27eeca..d9377e7 100644
--- a/dhcpv4/nclient4/lease_test.go
+++ b/dhcpv4/nclient4/lease_test.go
@@ -238,7 +238,7 @@ func (sll *testServerLeaseList) runTest(t *testing.T) {
 		sll.lastTestSvrErrLock.RUnlock()
 
 		if keepgoing {
-			err = clnt.Renew(context.Background(), lease)
+			lease, err = clnt.Renew(context.Background(), lease)
 			sll.lastTestSvrErrLock.RLock()
 			keepgoing = chkerr(err, sll.lastTestSvrErr, l.ShouldFail, t)
 			sll.lastTestSvrErrLock.RUnlock()
diff --git a/dhcpv4/server4/server_test.go b/dhcpv4/server4/server_test.go
index 7be868c..9fc44a3 100644
--- a/dhcpv4/server4/server_test.go
+++ b/dhcpv4/server4/server_test.go
@@ -110,15 +110,17 @@ func TestServer(t *testing.T) {
 
 	lease, err := c.Request(context.Background(), modifiers...)
 	require.NoError(t, err)
-	require.NotNil(t, lease.Offer, lease.ACK)
+	require.NotNil(t, lease.Offer)
+	require.NotNil(t, lease.ACK)
 	for _, p := range []*dhcpv4.DHCPv4{lease.Offer, lease.ACK} {
 		require.Equal(t, xid, p.TransactionID)
 		require.Equal(t, ifaces[0].HardwareAddr, p.ClientHWAddr)
 	}
 
-	err = c.Renew(context.Background(), lease, modifiers...)
+	lease, err = c.Renew(context.Background(), lease, modifiers...)
 	require.NoError(t, err)
-	require.NotNil(t, lease.Offer, lease.ACK)
+	require.NotNil(t, lease.Offer)
+	require.NotNil(t, lease.ACK)
 	for _, p := range []*dhcpv4.DHCPv4{lease.Offer, lease.ACK} {
 		require.Equal(t, xid, p.TransactionID)
 		require.Equal(t, ifaces[0].HardwareAddr, p.ClientHWAddr)
diff --git a/dhcpv4/ztpv4/parse_circuitid.go b/dhcpv4/ztpv4/parse_circuitid.go
index 42f7647..63b34e3 100644
--- a/dhcpv4/ztpv4/parse_circuitid.go
+++ b/dhcpv4/ztpv4/parse_circuitid.go
@@ -37,6 +37,8 @@ var circuitRegexs = []*regexp.Regexp{
 	regexp.MustCompile("^Ethernet(?P<slot>[0-9]+)/(?P<port>[0-9]+)$"),
 	// Juniper bundle interface ae52.0
 	regexp.MustCompile("^ae(?P<port>[0-9]+).(?P<subport>[0-9])$"),
+	// Arista bundle interface Port-Channel1
+	regexp.MustCompile("^Port-Channel(?P<port>[0-9]+)$"),
 	// Ciena interface format
 	regexp.MustCompile(`\.OSC(-[0-9]+)?-(?P<slot>[0-9]+)-(?P<port>[0-9]+)$`),
 }
diff --git a/dhcpv4/ztpv4/parse_circuitid_test.go b/dhcpv4/ztpv4/parse_circuitid_test.go
index 695d331..b2f6412 100644
--- a/dhcpv4/ztpv4/parse_circuitid_test.go
+++ b/dhcpv4/ztpv4/parse_circuitid_test.go
@@ -24,6 +24,7 @@ func TestMatchCircuitID(t *testing.T) {
 		{name: "Arista Vlan pattern 2", circuit: "Ethernet10:2020", want: &CircuitID{Port: "10", Vlan: "2020"}},
 		{name: "Cisco pattern", circuit: "Gi1/10:2020", want: &CircuitID{Slot: "1", Port: "10", Vlan: "2020"}},
 		{name: "Cisco Nexus pattern", circuit: "Ethernet1/3", want: &CircuitID{Slot: "1", Port: "3"}},
+		{name: "Arista Portchannel Pattern", circuit: "Port-Channel10", want: &CircuitID{Port: "10"}},
 		{name: "Juniper Bundle Pattern", circuit: "ae52.0", want: &CircuitID{Port: "52", SubPort: "0"}},
 		{name: "Juniper EX device pattern", circuit: "ge-0/0/0.0:RANDOMCHAR", want: &CircuitID{Slot: "0", Module: "0", Port: "0", SubPort: "0"}},
 	}
@@ -58,6 +59,7 @@ func TestFormatCircuitID(t *testing.T) {
 		{name: "Arista Vlan pattern 2", circuit: &CircuitID{Port: "10", Vlan: "2020"}, want: ",,10,,2020"},
 		{name: "Cisco Nexus pattern", circuit: &CircuitID{Slot: "1", Port: "3"}, want: "1,,3,,"},
 		{name: "Juniper Bundle Pattern", circuit: &CircuitID{Port: "52", SubPort: "0"}, want: ",,52,0,"},
+		{name: "Arista Portchannel Pattern", circuit: &CircuitID{Port: "10"}, want: ",,10,,"},
 	}
 
 	for _, tc := range tt {
@@ -87,6 +89,7 @@ func TestParseCircuitID(t *testing.T) {
 		{name: "Cisco pattern", circuit: []byte("Gi1/10:2020"), want: &CircuitID{Slot: "1", Port: "10", Vlan: "2020"}},
 		{name: "Cisco Nexus pattern", circuit: []byte("Ethernet1/3"), want: &CircuitID{Slot: "1", Port: "3"}},
 		{name: "Juniper Bundle Pattern", circuit: []byte("ae52.0"), want: &CircuitID{Port: "52", SubPort: "0"}},
+		{name: "Arista Portchannel Pattern", circuit: []byte("Port-Channel10"), want: &CircuitID{Port: "10"}},
 		{name: "Arista Vlan pattern 1 with circuitid type and length", circuit: []byte("\x00\x0fEthernet14:2001"), want: &CircuitID{Port: "14", Vlan: "2001"}},
 		{name: "juniperEX pattern", circuit: []byte("ge-0/0/0.0:RANDOMCHAR"), want: &CircuitID{Slot: "0", Module: "0", Port: "0", SubPort: "0"}},
 		{name: "Ciena pattern 1", circuit: []byte("tt-tt-tttt-6-7.OSC-1-2"), want: &CircuitID{Slot: "1", Port: "2"}},
diff --git a/dhcpv4/ztpv4/ztp.go b/dhcpv4/ztpv4/ztp.go
index ecb63c6..8ab3a8e 100644
--- a/dhcpv4/ztpv4/ztp.go
+++ b/dhcpv4/ztpv4/ztp.go
@@ -68,6 +68,16 @@ func parseClassIdentifier(packet *dhcpv4.DHCPv4) (*VendorData, error) {
 
 		vd.VendorName = p[0]
 		return vd, nil
+	// Juniper:tttt-ttt:DN817
+	case strings.HasPrefix(vc, "Juniper:"):
+		p := strings.Split(vc, ":")
+		if len(p) == 3 {
+			vd.VendorName = p[0]
+			vd.Model = p[1]
+			vd.Serial = p[2]
+			return vd, nil
+		}
+		return nil, fmt.Errorf("%w got '%s'", errVendorOptionMalformed, vc)
 
 	// For Ciena the class identifier (opt 60) is written in the following format:
 	//    {vendor iana code}-{product}-{type}
diff --git a/dhcpv4/ztpv4/ztp_test.go b/dhcpv4/ztpv4/ztp_test.go
index 5d8ae8d..8210b12 100644
--- a/dhcpv4/ztpv4/ztp_test.go
+++ b/dhcpv4/ztpv4/ztp_test.go
@@ -41,6 +41,16 @@ func TestParseClassIdentifier(t *testing.T) {
 			want:     &VendorData{VendorName: "Juniper", Model: "qfx10008", Serial: "DE123"},
 		},
 		{name: "juniperNoSerial", vc: "Juniper-qfx10008", fail: true},
+		{
+			name: "juniperHostnameSerialv2",
+			vc:   "Juniper:ttttt-ttt:D12345",
+			want: &VendorData{VendorName: "Juniper", Model: "ttttt-ttt", Serial: "D12345"},
+		},
+		{
+			name: "juniperHostnameSerialv2Invalid",
+			vc:   "Juniper:1",
+			fail: true,
+		},
 		{
 			name: "zpe",
 			vc:   "ZPESystems:NSC:001234567",
diff --git a/dhcpv6/dhcpv6.go b/dhcpv6/dhcpv6.go
index 59d9733..45025ab 100644
--- a/dhcpv6/dhcpv6.go
+++ b/dhcpv6/dhcpv6.go
@@ -14,6 +14,7 @@ type DHCPv6 interface {
 	ToBytes() []byte
 	String() string
 	Summary() string
+	LongString(indent int) string
 	IsRelay() bool
 
 	// GetInnerMessage returns the innermost encapsulated DHCPv6 message.
@@ -46,7 +47,7 @@ func MessageFromBytes(data []byte) (*Message, error) {
 	}
 	buf.ReadBytes(d.TransactionID[:])
 	if buf.Error() != nil {
-		return nil, fmt.Errorf("Error parsing DHCPv6 header: %v", buf.Error())
+		return nil, fmt.Errorf("failed to parse DHCPv6 header: %w", buf.Error())
 	}
 	if err := d.Options.FromBytes(buf.Data()); err != nil {
 		return nil, err
diff --git a/dhcpv6/dhcpv6_test.go b/dhcpv6/dhcpv6_test.go
index b1f952d..210f19d 100644
--- a/dhcpv6/dhcpv6_test.go
+++ b/dhcpv6/dhcpv6_test.go
@@ -165,9 +165,9 @@ func TestNewAdvertiseFromSolicit(t *testing.T) {
 		MessageType:   MessageTypeSolicit,
 		TransactionID: TransactionID{0xa, 0xb, 0xc},
 	}
-	s.AddOption(OptClientID(Duid{}))
+	s.AddOption(OptClientID(&DUIDLLT{}))
 
-	a, err := NewAdvertiseFromSolicit(&s, WithServerID(Duid{}))
+	a, err := NewAdvertiseFromSolicit(&s, WithServerID(&DUIDLLT{}))
 	require.NoError(t, err)
 	require.Equal(t, a.TransactionID, s.TransactionID)
 	require.Equal(t, a.Type(), MessageTypeAdvertise)
@@ -178,35 +178,35 @@ func TestNewReplyFromMessage(t *testing.T) {
 		TransactionID: TransactionID{0xa, 0xb, 0xc},
 		MessageType:   MessageTypeConfirm,
 	}
-	var duid Duid
-	msg.AddOption(OptClientID(duid))
-	msg.AddOption(OptServerID(duid))
+	var duid DUIDLLT
+	msg.AddOption(OptClientID(&duid))
+	msg.AddOption(OptServerID(&duid))
 
-	rep, err := NewReplyFromMessage(&msg, WithServerID(duid))
+	rep, err := NewReplyFromMessage(&msg, WithServerID(&duid))
 	require.NoError(t, err)
 	require.Equal(t, rep.TransactionID, msg.TransactionID)
 	require.Equal(t, rep.Type(), MessageTypeReply)
 
 	msg.MessageType = MessageTypeRenew
-	rep, err = NewReplyFromMessage(&msg, WithServerID(duid))
+	rep, err = NewReplyFromMessage(&msg, WithServerID(&duid))
 	require.NoError(t, err)
 	require.Equal(t, rep.TransactionID, msg.TransactionID)
 	require.Equal(t, rep.Type(), MessageTypeReply)
 
 	msg.MessageType = MessageTypeRebind
-	rep, err = NewReplyFromMessage(&msg, WithServerID(duid))
+	rep, err = NewReplyFromMessage(&msg, WithServerID(&duid))
 	require.NoError(t, err)
 	require.Equal(t, rep.TransactionID, msg.TransactionID)
 	require.Equal(t, rep.Type(), MessageTypeReply)
 
 	msg.MessageType = MessageTypeRelease
-	rep, err = NewReplyFromMessage(&msg, WithServerID(duid))
+	rep, err = NewReplyFromMessage(&msg, WithServerID(&duid))
 	require.NoError(t, err)
 	require.Equal(t, rep.TransactionID, msg.TransactionID)
 	require.Equal(t, rep.Type(), MessageTypeReply)
 
 	msg.MessageType = MessageTypeInformationRequest
-	rep, err = NewReplyFromMessage(&msg, WithServerID(duid))
+	rep, err = NewReplyFromMessage(&msg, WithServerID(&duid))
 	require.NoError(t, err)
 	require.Equal(t, rep.TransactionID, msg.TransactionID)
 	require.Equal(t, rep.Type(), MessageTypeReply)
@@ -226,9 +226,8 @@ func TestNewMessageTypeSolicit(t *testing.T) {
 	hwAddr, err := net.ParseMAC("24:0A:9E:9F:EB:2B")
 	require.NoError(t, err)
 
-	duid := Duid{
-		Type:          DUID_LL,
-		HwType:        iana.HWTypeEthernet,
+	duid := &DUIDLL{
+		HWType:        iana.HWTypeEthernet,
 		LinkLayerAddr: hwAddr,
 	}
 
@@ -239,7 +238,7 @@ func TestNewMessageTypeSolicit(t *testing.T) {
 	// Check CID
 	cduid := s.Options.ClientID()
 	require.NotNil(t, cduid)
-	require.Equal(t, cduid, &duid)
+	require.Equal(t, cduid, duid)
 
 	// Check ORO
 	oro := s.Options.RequestedOptions()
diff --git a/dhcpv6/dhcpv6message.go b/dhcpv6/dhcpv6message.go
index e2210fd..9bb83bb 100644
--- a/dhcpv6/dhcpv6message.go
+++ b/dhcpv6/dhcpv6message.go
@@ -4,6 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"net"
+	"strings"
 	"time"
 
 	"github.com/insomniacslk/dhcp/iana"
@@ -31,21 +32,21 @@ func (mo MessageOptions) ArchTypes() iana.Archs {
 }
 
 // ClientID returns the client identifier option.
-func (mo MessageOptions) ClientID() *Duid {
+func (mo MessageOptions) ClientID() DUID {
 	opt := mo.GetOne(OptionClientID)
 	if opt == nil {
 		return nil
 	}
-	return &opt.(*optClientID).Duid
+	return opt.(*optClientID).DUID
 }
 
 // ServerID returns the server identifier option.
-func (mo MessageOptions) ServerID() *Duid {
+func (mo MessageOptions) ServerID() DUID {
 	opt := mo.GetOne(OptionServerID)
 	if opt == nil {
 		return nil
 	}
-	return &opt.(*optServerID).Duid
+	return opt.(*optServerID).DUID
 }
 
 // IANA returns all Identity Association for Non-temporary Address options.
@@ -105,6 +106,18 @@ func (mo MessageOptions) OneIAPD() *OptIAPD {
 	return iapds[0]
 }
 
+// FourRD returns all 4RD options.
+func (mo MessageOptions) FourRD() []*Opt4RD {
+	opts := mo.Get(Option4RD)
+	var frds []*Opt4RD
+	for _, o := range opts {
+		if m, ok := o.(*Opt4RD); ok {
+			frds = append(frds, m)
+		}
+	}
+	return frds
+}
+
 // Status returns the status code associated with this option.
 func (mo MessageOptions) Status() *OptStatusCode {
 	opt := mo.Options.GetOne(OptionStatusCode)
@@ -168,8 +181,8 @@ func (mo MessageOptions) BootFileURL() string {
 	if opt == nil {
 		return ""
 	}
-	if u, ok := opt.(optBootFileURL); ok {
-		return string(u)
+	if u, ok := opt.(*optBootFileURL); ok {
+		return u.url
 	}
 	return ""
 }
@@ -180,8 +193,8 @@ func (mo MessageOptions) BootFileParam() []string {
 	if opt == nil {
 		return nil
 	}
-	if u, ok := opt.(optBootFileParam); ok {
-		return []string(u)
+	if u, ok := opt.(*optBootFileParam); ok {
+		return u.params
 	}
 	return nil
 }
@@ -198,12 +211,39 @@ func (mo MessageOptions) UserClasses() [][]byte {
 	return nil
 }
 
+// VendorClasses returns the all vendor class options.
+func (mo MessageOptions) VendorClasses() []*OptVendorClass {
+	opt := mo.Options.Get(OptionVendorClass)
+	if opt == nil {
+		return nil
+	}
+	var vo []*OptVendorClass
+	for _, o := range opt {
+		if t, ok := o.(*OptVendorClass); ok {
+			vo = append(vo, t)
+		}
+	}
+	return vo
+}
+
+// VendorClass returns the vendor class options matching the given enterprise
+// number.
+func (mo MessageOptions) VendorClass(enterpriseNumber uint32) [][]byte {
+	vo := mo.VendorClasses()
+	for _, v := range vo {
+		if v.EnterpriseNumber == enterpriseNumber {
+			return v.Data
+		}
+	}
+	return nil
+}
+
 // VendorOpts returns the all vendor-specific options.
 //
 // RFC 8415 Section 21.17:
 //
-//   Multiple instances of the Vendor-specific Information option may appear in
-//   a DHCP message.
+//	Multiple instances of the Vendor-specific Information option may appear in
+//	a DHCP message.
 func (mo MessageOptions) VendorOpts() []*OptVendorOpts {
 	opt := mo.Options.Get(OptionVendorOpts)
 	if opt == nil {
@@ -222,8 +262,8 @@ func (mo MessageOptions) VendorOpts() []*OptVendorOpts {
 //
 // RFC 8415 Section 21.17:
 //
-//   Servers and clients MUST NOT send more than one instance of the
-//   Vendor-specific Information option with the same Enterprise Number.
+//	Servers and clients MUST NOT send more than one instance of the
+//	Vendor-specific Information option with the same Enterprise Number.
 func (mo MessageOptions) VendorOpt(enterpriseNumber uint32) Options {
 	vo := mo.VendorOpts()
 	for _, v := range vo {
@@ -346,9 +386,8 @@ func GetTime() uint32 {
 // NewSolicit creates a new SOLICIT message, using the given hardware address to
 // derive the IAID in the IA_NA option.
 func NewSolicit(hwaddr net.HardwareAddr, modifiers ...Modifier) (*Message, error) {
-	duid := Duid{
-		Type:          DUID_LLT,
-		HwType:        iana.HWTypeEthernet,
+	duid := &DUIDLLT{
+		HWType:        iana.HWTypeEthernet,
 		Time:          GetTime(),
 		LinkLayerAddr: hwaddr,
 	}
@@ -543,28 +582,33 @@ func (m *Message) IsOptionRequested(requested OptionCode) bool {
 
 // String returns a short human-readable string for this message.
 func (m *Message) String() string {
-	return fmt.Sprintf("Message(messageType=%s transactionID=%s, %d options)",
+	return fmt.Sprintf("Message(MessageType=%s, TransactionID=%#x, %d options)",
 		m.MessageType, m.TransactionID, len(m.Options.Options))
 }
 
 // Summary prints all options associated with this message.
 func (m *Message) Summary() string {
-	ret := fmt.Sprintf(
-		"Message\n"+
-			"  messageType=%s\n"+
-			"  transactionid=%s\n",
-		m.MessageType,
-		m.TransactionID,
-	)
-	ret += "  options=["
-	if len(m.Options.Options) > 0 {
-		ret += "\n"
-	}
-	for _, opt := range m.Options.Options {
-		ret += fmt.Sprintf("    %v\n", opt.String())
-	}
-	ret += "  ]\n"
-	return ret
+	return m.LongString(0)
+}
+
+// LongString prints all options associated with this message.
+func (m *Message) LongString(spaceIndent int) string {
+	indent := strings.Repeat(" ", spaceIndent)
+
+	var s strings.Builder
+	s.WriteString("Message{\n")
+	s.WriteString(indent)
+	s.WriteString(fmt.Sprintf("  MessageType=%s\n", m.MessageType))
+	s.WriteString(indent)
+	s.WriteString(fmt.Sprintf("  TransactionID=%s\n", m.TransactionID))
+	s.WriteString(indent)
+	s.WriteString("  Options: ")
+	s.WriteString(m.Options.Options.LongString(spaceIndent + 2))
+	s.WriteString("\n")
+	s.WriteString(indent)
+	s.WriteString("}")
+
+	return s.String()
 }
 
 // ToBytes returns the serialized version of this message as defined by RFC
diff --git a/dhcpv6/dhcpv6relay.go b/dhcpv6/dhcpv6relay.go
index 6245b81..5a29c87 100644
--- a/dhcpv6/dhcpv6relay.go
+++ b/dhcpv6/dhcpv6relay.go
@@ -4,6 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"net"
+	"strings"
 
 	"github.com/insomniacslk/dhcp/iana"
 	"github.com/u-root/uio/uio"
@@ -94,29 +95,36 @@ func (r *RelayMessage) Type() MessageType {
 
 // String prints a short human-readable relay message.
 func (r *RelayMessage) String() string {
-	ret := fmt.Sprintf(
-		"RelayMessage(messageType=%s hopcount=%d, linkaddr=%s, peeraddr=%s, %d options)",
-		r.Type(), r.HopCount, r.LinkAddr, r.PeerAddr, len(r.Options.Options),
-	)
-	return ret
+	return fmt.Sprintf("RelayMessage(MessageType=%s, HopCount=%d, LinkAddr=%s, PeerAddr=%s, %d options)",
+		r.Type(), r.HopCount, r.LinkAddr, r.PeerAddr, len(r.Options.Options))
 }
 
 // Summary prints all options associated with this relay message.
 func (r *RelayMessage) Summary() string {
-	ret := fmt.Sprintf(
-		"RelayMessage\n"+
-			"  messageType=%v\n"+
-			"  hopcount=%v\n"+
-			"  linkaddr=%v\n"+
-			"  peeraddr=%v\n"+
-			"  options=%v\n",
-		r.Type(),
-		r.HopCount,
-		r.LinkAddr,
-		r.PeerAddr,
-		r.Options,
-	)
-	return ret
+	return r.LongString(0)
+}
+
+// LongString prints all options associated with this message.
+func (r *RelayMessage) LongString(spaceIndent int) string {
+	indent := strings.Repeat(" ", spaceIndent)
+
+	var s strings.Builder
+	s.WriteString(indent)
+	s.WriteString("RelayMessage{\n")
+	s.WriteString(indent)
+	s.WriteString(fmt.Sprintf("  MessageType=%s\n", r.MessageType))
+	s.WriteString(indent)
+	s.WriteString(fmt.Sprintf("  HopCount=%d\n", r.HopCount))
+	s.WriteString(indent)
+	s.WriteString(fmt.Sprintf("  LinkAddr=%s\n", r.LinkAddr))
+	s.WriteString(indent)
+	s.WriteString(fmt.Sprintf("  PeerAddr=%s\n", r.PeerAddr))
+	s.WriteString(indent)
+	s.WriteString("  Options: ")
+	s.WriteString(r.Options.Options.LongString(spaceIndent + 2))
+	s.WriteString("\n}")
+
+	return s.String()
 }
 
 // ToBytes returns the serialized version of this relay message as defined by
diff --git a/dhcpv6/dhcpv6relay_test.go b/dhcpv6/dhcpv6relay_test.go
index 113842c..1d38855 100644
--- a/dhcpv6/dhcpv6relay_test.go
+++ b/dhcpv6/dhcpv6relay_test.go
@@ -96,7 +96,7 @@ func TestNewRelayRepFromRelayForw(t *testing.T) {
 	// create the inner message
 	s, err := NewMessage()
 	require.NoError(t, err)
-	s.AddOption(OptClientID(Duid{}))
+	s.AddOption(OptClientID(&DUIDLL{}))
 	rf.AddOption(OptRelayMessage(s))
 
 	a, err := NewAdvertiseFromSolicit(s)
diff --git a/dhcpv6/duid.go b/dhcpv6/duid.go
index 2ae8e60..0470f9f 100644
--- a/dhcpv6/duid.go
+++ b/dhcpv6/duid.go
@@ -2,156 +2,294 @@ package dhcpv6
 
 import (
 	"bytes"
-	"encoding/binary"
 	"fmt"
 	"net"
 
 	"github.com/insomniacslk/dhcp/iana"
+	"github.com/u-root/uio/uio"
 )
 
-// DuidType is the DUID type as defined in rfc3315.
-type DuidType uint16
+// DUID is the interface that all DUIDs adhere to.
+type DUID interface {
+	fmt.Stringer
 
-// DUID types
-const (
-	DUID_LLT  DuidType = 1
-	DUID_EN   DuidType = 2
-	DUID_LL   DuidType = 3
-	DUID_UUID DuidType = 4
-)
+	ToBytes() []byte
+	FromBytes(p []byte) error
+	DUIDType() DUIDType
+	Equal(d DUID) bool
+}
 
-// DuidTypeToString maps a DuidType to a name.
-var DuidTypeToString = map[DuidType]string{
-	DUID_LL:   "DUID-LL",
-	DUID_LLT:  "DUID-LLT",
-	DUID_EN:   "DUID-EN",
-	DUID_UUID: "DUID-UUID",
+// DUIDLLT is a DUID based on link-layer address plus time (RFC 8415 Section 11.2).
+type DUIDLLT struct {
+	HWType        iana.HWType
+	Time          uint32
+	LinkLayerAddr net.HardwareAddr
 }
 
-func (d DuidType) String() string {
-	if dtype, ok := DuidTypeToString[d]; ok {
-		return dtype
+// String pretty-prints DUIDLLT information.
+func (d DUIDLLT) String() string {
+	return fmt.Sprintf("DUID-LLT{HWType=%s HWAddr=%s Time=%d}", d.HWType, d.LinkLayerAddr, d.Time)
+}
+
+// DUIDType returns the DUID_LLT type.
+func (d DUIDLLT) DUIDType() DUIDType {
+	return DUID_LLT
+}
+
+// ToBytes serializes the option out to bytes.
+func (d DUIDLLT) ToBytes() []byte {
+	buf := uio.NewBigEndianBuffer(nil)
+	buf.Write16(uint16(d.DUIDType()))
+	buf.Write16(uint16(d.HWType))
+	buf.Write32(d.Time)
+	buf.WriteBytes(d.LinkLayerAddr)
+	return buf.Data()
+}
+
+// FromBytes reads the option.
+func (d *DUIDLLT) FromBytes(p []byte) error {
+	buf := uio.NewBigEndianBuffer(p)
+	d.HWType = iana.HWType(buf.Read16())
+	d.Time = buf.Read32()
+	d.LinkLayerAddr = buf.ReadAll()
+	return buf.FinError()
+}
+
+// Equal returns true if e is a DUID-LLT with the same values as d.
+func (d *DUIDLLT) Equal(e DUID) bool {
+	ellt, ok := e.(*DUIDLLT)
+	if !ok {
+		return false
 	}
-	return "Unknown"
-}
-
-// Duid is a DHCP Unique Identifier.
-type Duid struct {
-	Type                 DuidType
-	HwType               iana.HWType // for DUID-LLT and DUID-LL. Ignored otherwise. RFC 826
-	Time                 uint32      // for DUID-LLT. Ignored otherwise
-	LinkLayerAddr        net.HardwareAddr
-	EnterpriseNumber     uint32 // for DUID-EN. Ignored otherwise
-	EnterpriseIdentifier []byte // for DUID-EN. Ignored otherwise
-	Uuid                 []byte // for DUID-UUID. Ignored otherwise
-	Opaque               []byte // for unknown DUIDs
-}
-
-// Length returns the DUID length in bytes.
-func (d *Duid) Length() int {
-	if d.Type == DUID_LLT {
-		return 8 + len(d.LinkLayerAddr)
-	} else if d.Type == DUID_LL {
-		return 4 + len(d.LinkLayerAddr)
-	} else if d.Type == DUID_EN {
-		return 6 + len(d.EnterpriseIdentifier)
-	} else if d.Type == DUID_UUID {
-		return 18
-	} else {
-		return 2 + len(d.Opaque)
+	if d == nil {
+		return d == ellt
 	}
+	return d.HWType == ellt.HWType && d.Time == ellt.Time && bytes.Equal(d.LinkLayerAddr, ellt.LinkLayerAddr)
+}
+
+// DUIDLL is a DUID based on link-layer (RFC 8415 Section 11.4).
+type DUIDLL struct {
+	HWType        iana.HWType
+	LinkLayerAddr net.HardwareAddr
+}
+
+// String pretty-prints DUIDLL information.
+func (d DUIDLL) String() string {
+	return fmt.Sprintf("DUID-LL{HWType=%s HWAddr=%s}", d.HWType, d.LinkLayerAddr)
 }
 
-// Equal compares two Duid objects.
-func (d Duid) Equal(o Duid) bool {
-	if d.Type != o.Type ||
-		d.HwType != o.HwType ||
-		d.Time != o.Time ||
-		!bytes.Equal(d.LinkLayerAddr, o.LinkLayerAddr) ||
-		d.EnterpriseNumber != o.EnterpriseNumber ||
-		!bytes.Equal(d.EnterpriseIdentifier, o.EnterpriseIdentifier) ||
-		!bytes.Equal(d.Uuid, o.Uuid) ||
-		!bytes.Equal(d.Opaque, o.Opaque) {
+// DUIDType returns the DUID_LL type.
+func (d DUIDLL) DUIDType() DUIDType {
+	return DUID_LL
+}
+
+// ToBytes serializes the option out to bytes.
+func (d DUIDLL) ToBytes() []byte {
+	buf := uio.NewBigEndianBuffer(nil)
+	buf.Write16(uint16(d.DUIDType()))
+	buf.Write16(uint16(d.HWType))
+	buf.WriteBytes(d.LinkLayerAddr)
+	return buf.Data()
+}
+
+// FromBytes reads the option.
+func (d *DUIDLL) FromBytes(p []byte) error {
+	buf := uio.NewBigEndianBuffer(p)
+	d.HWType = iana.HWType(buf.Read16())
+	d.LinkLayerAddr = buf.ReadAll()
+	return buf.FinError()
+}
+
+// Equal returns true if e is a DUID-LL with the same values as d.
+func (d *DUIDLL) Equal(e DUID) bool {
+	ell, ok := e.(*DUIDLL)
+	if !ok {
 		return false
 	}
-	return true
-}
-
-// ToBytes serializes a Duid object.
-func (d *Duid) ToBytes() []byte {
-	if d.Type == DUID_LLT {
-		buf := make([]byte, 8)
-		binary.BigEndian.PutUint16(buf[0:2], uint16(d.Type))
-		binary.BigEndian.PutUint16(buf[2:4], uint16(d.HwType))
-		binary.BigEndian.PutUint32(buf[4:8], d.Time)
-		return append(buf, d.LinkLayerAddr...)
-	} else if d.Type == DUID_LL {
-		buf := make([]byte, 4)
-		binary.BigEndian.PutUint16(buf[0:2], uint16(d.Type))
-		binary.BigEndian.PutUint16(buf[2:4], uint16(d.HwType))
-		return append(buf, d.LinkLayerAddr...)
-	} else if d.Type == DUID_EN {
-		buf := make([]byte, 6)
-		binary.BigEndian.PutUint16(buf[0:2], uint16(d.Type))
-		binary.BigEndian.PutUint32(buf[2:6], d.EnterpriseNumber)
-		return append(buf, d.EnterpriseIdentifier...)
-	} else if d.Type == DUID_UUID {
-		buf := make([]byte, 2)
-		binary.BigEndian.PutUint16(buf[0:2], uint16(d.Type))
-		return append(buf, d.Uuid...)
-	} else {
-		buf := make([]byte, 2)
-		binary.BigEndian.PutUint16(buf[0:2], uint16(d.Type))
-		return append(buf, d.Opaque...)
+	if d == nil {
+		return d == ell
 	}
+	return d.HWType == ell.HWType && bytes.Equal(d.LinkLayerAddr, ell.LinkLayerAddr)
+}
+
+// DUIDEN is a DUID based on enterprise number (RFC 8415 Section 11.3).
+type DUIDEN struct {
+	EnterpriseNumber     uint32
+	EnterpriseIdentifier []byte
+}
+
+// String pretty-prints DUIDEN information.
+func (d DUIDEN) String() string {
+	return fmt.Sprintf("DUID-EN{EnterpriseNumber=%d EnterpriseIdentifier=%s}", d.EnterpriseNumber, d.EnterpriseIdentifier)
+}
+
+// DUIDType returns the DUID_EN type.
+func (d DUIDEN) DUIDType() DUIDType {
+	return DUID_EN
+}
+
+// ToBytes serializes the option out to bytes.
+func (d DUIDEN) ToBytes() []byte {
+	buf := uio.NewBigEndianBuffer(nil)
+	buf.Write16(uint16(d.DUIDType()))
+	buf.Write32(d.EnterpriseNumber)
+	buf.WriteBytes(d.EnterpriseIdentifier)
+	return buf.Data()
 }
 
-func (d *Duid) String() string {
-	var hwaddr string
-	if d.HwType == iana.HWTypeEthernet {
-		for _, b := range d.LinkLayerAddr {
-			hwaddr += fmt.Sprintf("%02x:", b)
-		}
-		if len(hwaddr) > 0 && hwaddr[len(hwaddr)-1] == ':' {
-			hwaddr = hwaddr[:len(hwaddr)-1]
-		}
+// FromBytes reads the option.
+func (d *DUIDEN) FromBytes(p []byte) error {
+	buf := uio.NewBigEndianBuffer(p)
+	d.EnterpriseNumber = buf.Read32()
+	d.EnterpriseIdentifier = buf.ReadAll()
+	return buf.FinError()
+}
+
+// Equal returns true if e is a DUID-EN with the same values as d.
+func (d *DUIDEN) Equal(e DUID) bool {
+	en, ok := e.(*DUIDEN)
+	if !ok {
+		return false
 	}
-	return fmt.Sprintf("DUID{type=%v hwtype=%v hwaddr=%v}", d.Type.String(), d.HwType.String(), hwaddr)
+	if d == nil {
+		return d == en
+	}
+	return d.EnterpriseNumber == en.EnterpriseNumber && bytes.Equal(d.EnterpriseIdentifier, en.EnterpriseIdentifier)
+}
+
+// DUIDUUID is a DUID based on UUID (RFC 8415 Section 11.5).
+type DUIDUUID struct {
+	// Defined by RFC 6355.
+	UUID [16]byte
+}
+
+// String pretty-prints DUIDUUID information.
+func (d DUIDUUID) String() string {
+	return fmt.Sprintf("DUID-UUID{%#x}", d.UUID[:])
 }
 
-// DuidFromBytes parses a Duid from a byte slice.
-func DuidFromBytes(data []byte) (*Duid, error) {
-	if len(data) < 2 {
-		return nil, fmt.Errorf("Invalid DUID: shorter than 2 bytes")
+// DUIDType returns the DUID_UUID type.
+func (d DUIDUUID) DUIDType() DUIDType {
+	return DUID_UUID
+}
+
+// ToBytes serializes the option out to bytes.
+func (d DUIDUUID) ToBytes() []byte {
+	buf := uio.NewBigEndianBuffer(nil)
+	buf.Write16(uint16(d.DUIDType()))
+	buf.WriteData(d.UUID[:])
+	return buf.Data()
+}
+
+// FromBytes reads the option.
+func (d *DUIDUUID) FromBytes(p []byte) error {
+	if len(p) != 16 {
+		return fmt.Errorf("buffer is length %d, DUID-UUID must be exactly 16 bytes", len(p))
 	}
-	d := Duid{}
-	d.Type = DuidType(binary.BigEndian.Uint16(data[0:2]))
-	if d.Type == DUID_LLT {
-		if len(data) < 8 {
-			return nil, fmt.Errorf("Invalid DUID-LLT: shorter than 8 bytes")
-		}
-		d.HwType = iana.HWType(binary.BigEndian.Uint16(data[2:4]))
-		d.Time = binary.BigEndian.Uint32(data[4:8])
-		d.LinkLayerAddr = data[8:]
-	} else if d.Type == DUID_LL {
-		if len(data) < 4 {
-			return nil, fmt.Errorf("Invalid DUID-LL: shorter than 4 bytes")
-		}
-		d.HwType = iana.HWType(binary.BigEndian.Uint16(data[2:4]))
-		d.LinkLayerAddr = data[4:]
-	} else if d.Type == DUID_EN {
-		if len(data) < 6 {
-			return nil, fmt.Errorf("Invalid DUID-EN: shorter than 6 bytes")
-		}
-		d.EnterpriseNumber = binary.BigEndian.Uint32(data[2:6])
-		d.EnterpriseIdentifier = data[6:]
-	} else if d.Type == DUID_UUID {
-		if len(data) != 18 {
-			return nil, fmt.Errorf("Invalid DUID-UUID length. Expected 18, got %v", len(data))
-		}
-		d.Uuid = data[2:18]
-	} else {
-		d.Opaque = data[2:]
+	copy(d.UUID[:], p)
+	return nil
+}
+
+// Equal returns true if e is a DUID-UUID with the same values as d.
+func (d *DUIDUUID) Equal(e DUID) bool {
+	euuid, ok := e.(*DUIDUUID)
+	if !ok {
+		return false
+	}
+	if d == nil {
+		return d == euuid
+	}
+	return d.UUID == euuid.UUID
+}
+
+// DUIDOpaque is a DUID of unknown type.
+type DUIDOpaque struct {
+	Type DUIDType
+	Data []byte
+}
+
+// String pretty-prints opaque DUID information.
+func (d DUIDOpaque) String() string {
+	return fmt.Sprintf("DUID-Opaque{Type=%d Data=%#x}", d.Type, d.Data)
+}
+
+// DUIDType returns the opaque DUID type.
+func (d DUIDOpaque) DUIDType() DUIDType {
+	return d.Type
+}
+
+// ToBytes serializes the option out to bytes.
+func (d DUIDOpaque) ToBytes() []byte {
+	buf := uio.NewBigEndianBuffer(nil)
+	buf.Write16(uint16(d.Type))
+	buf.WriteData(d.Data)
+	return buf.Data()
+}
+
+// FromBytes reads the option.
+func (d *DUIDOpaque) FromBytes(p []byte) error {
+	d.Data = append([]byte(nil), p...)
+	return nil
+}
+
+// Equal returns true if e is an opaque DUID with the same values as d.
+func (d *DUIDOpaque) Equal(e DUID) bool {
+	eopaque, ok := e.(*DUIDOpaque)
+	if !ok {
+		return false
+	}
+	if d == nil {
+		return d == eopaque
+	}
+	return d.Type == eopaque.Type && bytes.Equal(d.Data, eopaque.Data)
+}
+
+// DUIDType is the DUID type as defined in RFC 3315.
+type DUIDType uint16
+
+// DUID types
+const (
+	DUID_LLT  DUIDType = 1
+	DUID_EN   DUIDType = 2
+	DUID_LL   DUIDType = 3
+	DUID_UUID DUIDType = 4
+)
+
+// duidTypeToString maps a DUIDType to a name.
+var duidTypeToString = map[DUIDType]string{
+	DUID_LL:   "DUID-LL",
+	DUID_LLT:  "DUID-LLT",
+	DUID_EN:   "DUID-EN",
+	DUID_UUID: "DUID-UUID",
+}
+
+func (d DUIDType) String() string {
+	if dtype, ok := duidTypeToString[d]; ok {
+		return dtype
+	}
+	return "unknown"
+}
+
+// DUIDFromBytes parses a DUID from a byte slice.
+func DUIDFromBytes(data []byte) (DUID, error) {
+	buf := uio.NewBigEndianBuffer(data)
+	if !buf.Has(2) {
+		return nil, fmt.Errorf("%w: have %d bytes, want 2 bytes", uio.ErrBufferTooShort, buf.Len())
+	}
+
+	typ := DUIDType(buf.Read16())
+	var d DUID
+	switch typ {
+	case DUID_LLT:
+		d = &DUIDLLT{}
+	case DUID_LL:
+		d = &DUIDLL{}
+	case DUID_EN:
+		d = &DUIDEN{}
+	case DUID_UUID:
+		d = &DUIDUUID{}
+	default:
+		d = &DUIDOpaque{Type: typ}
 	}
-	return &d, nil
+	return d, d.FromBytes(buf.Data())
 }
diff --git a/dhcpv6/duid_test.go b/dhcpv6/duid_test.go
index 5efa0e1..7174dc5 100644
--- a/dhcpv6/duid_test.go
+++ b/dhcpv6/duid_test.go
@@ -3,6 +3,7 @@ package dhcpv6
 import (
 	"bytes"
 	"net"
+	"reflect"
 	"testing"
 
 	"github.com/insomniacslk/dhcp/iana"
@@ -11,138 +12,225 @@ import (
 
 func TestDuidInvalidTooShort(t *testing.T) {
 	// too short DUID at all (must be at least 2 bytes)
-	_, err := DuidFromBytes([]byte{0})
+	_, err := DUIDFromBytes([]byte{0})
 	require.Error(t, err)
 
 	// too short DUID_LL (must be at least 4 bytes)
-	_, err = DuidFromBytes([]byte{0, 3, 0xa})
+	_, err = DUIDFromBytes([]byte{0, 3, 0xa})
 	require.Error(t, err)
 
 	// too short DUID_EN (must be at least 6 bytes)
-	_, err = DuidFromBytes([]byte{0, 2, 0xa, 0xb, 0xc})
+	_, err = DUIDFromBytes([]byte{0, 2, 0xa, 0xb, 0xc})
 	require.Error(t, err)
 
 	// too short DUID_LLT (must be at least 8 bytes)
-	_, err = DuidFromBytes([]byte{0, 1, 0xa, 0xb, 0xc, 0xd, 0xe})
+	_, err = DUIDFromBytes([]byte{0, 1, 0xa, 0xb, 0xc, 0xd, 0xe})
 	require.Error(t, err)
 
 	// too short DUID_UUID (must be at least 18 bytes)
-	_, err = DuidFromBytes([]byte{0, 4, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf})
+	_, err = DUIDFromBytes([]byte{0, 4, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf})
 	require.Error(t, err)
 }
 
-func TestDuidLLTFromBytes(t *testing.T) {
-	buf := []byte{
-		0, 1, // DUID_LLT
-		0, 1, // HwTypeEthernet
-		0x01, 0x02, 0x03, 0x04, // time
-		0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, // link-layer addr
-	}
-	duid, err := DuidFromBytes(buf)
-	require.NoError(t, err)
-	require.Equal(t, 14, duid.Length())
-	require.Equal(t, DUID_LLT, duid.Type)
-	require.Equal(t, uint32(0x01020304), duid.Time)
-	require.Equal(t, iana.HWTypeEthernet, duid.HwType)
-	require.Equal(t, net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}, duid.LinkLayerAddr)
-}
+func TestFromBytes(t *testing.T) {
+	for _, tt := range []struct {
+		name     string
+		buf      []byte
+		want     DUID
+		stringer string
+	}{
+		{
+			name: "DUID-LLT",
+			buf: []byte{
+				0, 1, // DUID_LLT
+				0, 1, // HwTypeEthernet
+				0x01, 0x02, 0x03, 0x04, // time
+				0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, // link-layer addr
+			},
+			want: &DUIDLLT{
+				Time:          0x01020304,
+				HWType:        iana.HWTypeEthernet,
+				LinkLayerAddr: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff},
+			},
+			stringer: "DUID-LLT{HWType=Ethernet HWAddr=aa:bb:cc:dd:ee:ff Time=16909060}",
+		},
+		{
+			name: "DUID-LL",
+			buf: []byte{
+				0, 3, // DUID_LL
+				0, 1, // HwTypeEthernet
+				0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, // link-layer addr
+			},
+			want: &DUIDLL{
+				HWType:        iana.HWTypeEthernet,
+				LinkLayerAddr: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff},
+			},
+			stringer: "DUID-LL{HWType=Ethernet HWAddr=aa:bb:cc:dd:ee:ff}",
+		},
+		{
+			name: "DUID-EN",
+			buf: []byte{
+				0, 2, // DUID_EN
+				0, 0, 0, 1, // EnterpriseNumber
+				0x66, 0x6f, 0x6f, // "foo"
+			},
+			want: &DUIDEN{
+				EnterpriseNumber:     0x1,
+				EnterpriseIdentifier: []byte("foo"),
+			},
+			stringer: "DUID-EN{EnterpriseNumber=1 EnterpriseIdentifier=foo}",
+		},
+		{
+			name: "DUID-UUID",
+			buf: []byte{
+				0x00, 0x04, // DUID_UUID
+				0x01, 0x02, 0x03, 0x04, // UUID
+				0x01, 0x02, 0x03, 0x04, // UUID
+				0x01, 0x02, 0x03, 0x04, // UUID
+				0x01, 0x02, 0x03, 0x04, // UUID
+			},
+			want: &DUIDUUID{
+				UUID: [16]byte{
+					0x01, 0x02, 0x03, 0x04,
+					0x01, 0x02, 0x03, 0x04,
+					0x01, 0x02, 0x03, 0x04,
+					0x01, 0x02, 0x03, 0x04,
+				},
+			},
+			stringer: "DUID-UUID{0x01020304010203040102030401020304}",
+		},
+		{
+			name: "DUIDOpaque",
+			buf: []byte{
+				0x00, 0x05, // unknown DUID
+				0x01, 0x02, 0x03, // Opaque
+			},
+			want: &DUIDOpaque{
+				Type: 0x5,
+				Data: []byte{0x01, 0x02, 0x03},
+			},
+			stringer: "DUID-Opaque{Type=5 Data=0x010203}",
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			// FromBytes
+			got, err := DUIDFromBytes(tt.buf)
+			if err != nil {
+				t.Errorf("DUIDFromBytes = %v", err)
+			}
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("DUIDFromBytes = %v, want %v", got, tt.want)
+			}
 
-func TestDuidLLFromBytes(t *testing.T) {
-	buf := []byte{
-		0, 3, // DUID_LL
-		0, 1, // HwTypeEthernet
-		0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, // link-layer addr
-	}
-	duid, err := DuidFromBytes(buf)
-	require.NoError(t, err)
-	require.Equal(t, 10, duid.Length())
-	require.Equal(t, DUID_LL, duid.Type)
-	require.Equal(t, iana.HWTypeEthernet, duid.HwType)
-	require.Equal(t, net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}, duid.LinkLayerAddr)
-}
+			// ToBytes
+			buf := tt.want.ToBytes()
+			if !bytes.Equal(buf, tt.buf) {
+				t.Errorf("ToBytes() = %#x, want %#x", buf, tt.buf)
+			}
 
-func TestDuidUuidFromBytes(t *testing.T) {
-	buf := []byte{
-		0x00, 0x04, // DUID_UUID
+			// Stringer
+			s := tt.want.String()
+			if s != tt.stringer {
+				t.Errorf("String() = %s, want %s", s, tt.stringer)
+			}
+		})
 	}
-	uuid := []byte{0x00, 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x04, 0x00, 0x05, 0x00, 0x06, 0x00, 0x07, 0x00, 0x08}
-	buf = append(buf, uuid...)
-	duid, err := DuidFromBytes(buf)
-	require.NoError(t, err)
-	require.Equal(t, 18, duid.Length())
-	require.Equal(t, DUID_UUID, duid.Type)
-	require.Equal(t, uuid, duid.Uuid)
 }
 
-func TestDuidLLTToBytes(t *testing.T) {
-	expected := []byte{
-		0, 1, // DUID_LLT
-		0, 1, // HwTypeEthernet
-		0x01, 0x02, 0x03, 0x04, // time
-		0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, // link-layer addr
-	}
-	duid := Duid{
-		Type:          DUID_LLT,
-		HwType:        iana.HWTypeEthernet,
-		Time:          uint32(0x01020304),
-		LinkLayerAddr: []byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff},
-	}
-	toBytes := duid.ToBytes()
-	require.Equal(t, expected, toBytes)
-}
-
-func TestDuidUuidToBytes(t *testing.T) {
-	uuid := []byte{0x00, 0x02, 0x00, 0x03, 0x00, 0x04, 0x00, 0x05, 0x00, 0x06, 0x00, 0x07, 0x00, 0x08, 0x00, 0x09}
-	expected := []byte{00, 04}
-	expected = append(expected, uuid...)
-	duid := Duid{
-		Type: DUID_UUID,
-		Uuid: uuid,
-	}
-	toBytes := duid.ToBytes()
-	if !bytes.Equal(toBytes, expected) {
-		t.Fatalf("Invalid ToBytes result. Expected %v, got %v", expected, toBytes)
-	}
-}
+func TestEqual(t *testing.T) {
+	for _, tt := range []struct {
+		name string
+		a    DUID
+		b    DUID
+		want bool
+	}{
+		{
+			name: "DUID-LL-equal",
+			a:    &DUIDLL{HWType: iana.HWTypeEthernet, LinkLayerAddr: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}},
+			b:    &DUIDLL{HWType: iana.HWTypeEthernet, LinkLayerAddr: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}},
+			want: true,
+		},
+		{
+			name: "DUID-LL-not-equal",
+			a:    &DUIDLL{HWType: iana.HWTypeEthernet, LinkLayerAddr: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}},
+			b:    &DUIDLL{HWType: iana.HWTypeEthernet, LinkLayerAddr: net.HardwareAddr{0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa}},
+			want: false,
+		},
+		{
+			name: "DUID-LL-and-DUID-EN",
+			a:    &DUIDLL{HWType: iana.HWTypeEthernet, LinkLayerAddr: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}},
+			b:    &DUIDEN{EnterpriseNumber: 5, EnterpriseIdentifier: []byte("foo")},
+			want: false,
+		},
+		{
+			name: "DUID-EN-equal",
+			a:    &DUIDEN{EnterpriseNumber: 5, EnterpriseIdentifier: []byte("foo")},
+			b:    &DUIDEN{EnterpriseNumber: 5, EnterpriseIdentifier: []byte("foo")},
+			want: true,
+		},
+		{
+			name: "DUID-EN-not-equal",
+			a:    &DUIDEN{EnterpriseNumber: 5, EnterpriseIdentifier: []byte("foo")},
+			b:    &DUIDEN{EnterpriseNumber: 5, EnterpriseIdentifier: []byte("bar")},
+			want: false,
+		},
+		{
+			name: "DUID-LLT-equal",
+			a:    &DUIDLLT{HWType: iana.HWTypeEthernet, Time: 10, LinkLayerAddr: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}},
+			b:    &DUIDLLT{HWType: iana.HWTypeEthernet, Time: 10, LinkLayerAddr: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}},
+			want: true,
+		},
+		{
+			name: "DUID-LLT-not-equal",
+			a:    &DUIDLLT{HWType: iana.HWTypeEthernet, Time: 10, LinkLayerAddr: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}},
+			b:    &DUIDLLT{HWType: iana.HWTypeEthernet, Time: 10, LinkLayerAddr: net.HardwareAddr{0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa}},
+			want: false,
+		},
+		{
+			name: "DUID-LLT-and-DUID-UUID",
+			a:    &DUIDLLT{HWType: iana.HWTypeEthernet, Time: 10, LinkLayerAddr: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}},
+			b:    &DUIDUUID{UUID: [16]byte{0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04}},
+			want: false,
+		},
+		{
+			name: "DUID-UUID-equal",
+			a:    &DUIDUUID{UUID: [16]byte{0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04}},
+			b:    &DUIDUUID{UUID: [16]byte{0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04}},
+			want: true,
+		},
+		{
+			name: "DUID-UUID-not-equal",
+			a:    &DUIDUUID{UUID: [16]byte{0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04}},
+			b:    &DUIDUUID{UUID: [16]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}},
+			want: false,
+		},
+		{
+			name: "DUID-UUID-and-DUID-Opaque",
+			a:    &DUIDOpaque{Type: 5, Data: []byte{0x1}},
+			b:    &DUIDUUID{UUID: [16]byte{0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04}},
+			want: false,
+		},
+		{
+			name: "DUID-Opaque-equal",
+			a:    &DUIDOpaque{Type: 5, Data: []byte{0x1}},
+			b:    &DUIDOpaque{Type: 5, Data: []byte{0x1}},
+			want: true,
+		},
+		{
+			name: "DUID-Opaque-not-equal",
+			a:    &DUIDOpaque{Type: 5, Data: []byte{0x1}},
+			b:    &DUIDOpaque{Type: 5, Data: []byte{0x2}},
+			want: false,
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := tt.a.Equal(tt.b); got != tt.want {
+				t.Errorf("%s.Equal(%s) = %v, want %v", tt.a, tt.b, got, tt.want)
+			}
 
-func TestOpaqueDuid(t *testing.T) {
-	duid := []byte("\x00\x0a\x00\x03\x00\x01\x4c\x5e\x0c\x43\xbf\x39")
-	d, err := DuidFromBytes(duid)
-	if err != nil {
-		t.Fatalf("DuidFromBytes: unexpected error: %v", err)
-	}
-	if got, want := d.Length(), len(duid); got != want {
-		t.Errorf("Length: unexpected result: got %d, want %d", got, want)
-	}
-	if got, want := d.ToBytes(), duid; !bytes.Equal(got, want) {
-		t.Fatalf("ToBytes: unexpected result: got %x, want %x", got, want)
-	}
-}
-
-func TestDuidEqual(t *testing.T) {
-	d := Duid{
-		Type:          DUID_LL,
-		HwType:        iana.HWTypeEthernet,
-		LinkLayerAddr: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff},
-	}
-	o := Duid{
-		Type:          DUID_LL,
-		HwType:        iana.HWTypeEthernet,
-		LinkLayerAddr: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff},
-	}
-	require.True(t, d.Equal(o))
-}
-
-func TestDuidEqualNotEqual(t *testing.T) {
-	d := Duid{
-		Type:          DUID_LL,
-		HwType:        iana.HWTypeEthernet,
-		LinkLayerAddr: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff},
-	}
-	o := Duid{
-		Type:          DUID_LL,
-		HwType:        iana.HWTypeEthernet,
-		LinkLayerAddr: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0x00},
+			if got := tt.b.Equal(tt.a); got != tt.want {
+				t.Errorf("%s.Equal(%s) = %v, want %v", tt.b, tt.a, got, tt.want)
+			}
+		})
 	}
-	require.False(t, d.Equal(o))
 }
diff --git a/dhcpv6/iputils.go b/dhcpv6/iputils.go
index d2cac47..c4926e1 100644
--- a/dhcpv6/iputils.go
+++ b/dhcpv6/iputils.go
@@ -90,8 +90,15 @@ func ExtractMAC(packet DHCPv6) (net.HardwareAddr, error) {
 	if duid == nil {
 		return nil, fmt.Errorf("client ID not found in packet")
 	}
-	if duid.LinkLayerAddr == nil {
-		return nil, fmt.Errorf("failed to extract MAC")
+	switch d := duid.(type) {
+	case *DUIDLL:
+		if d.LinkLayerAddr != nil {
+			return d.LinkLayerAddr, nil
+		}
+	case *DUIDLLT:
+		if d.LinkLayerAddr != nil {
+			return d.LinkLayerAddr, nil
+		}
 	}
-	return duid.LinkLayerAddr, nil
+	return nil, fmt.Errorf("failed to extract MAC")
 }
diff --git a/dhcpv6/iputils_test.go b/dhcpv6/iputils_test.go
index e206aa1..601e96e 100644
--- a/dhcpv6/iputils_test.go
+++ b/dhcpv6/iputils_test.go
@@ -131,10 +131,9 @@ func Test_ExtractMAC(t *testing.T) {
 	require.NoError(t, err)
 	require.Equal(t, mac.String(), "24:8a:07:56:dc:a4")
 
-	// MAC extracted from DUID
-	duid := Duid{
-		Type:          DUID_LL,
-		HwType:        iana.HWTypeEthernet,
+	// MAC extracted from DUID-LL
+	duid := &DUIDLL{
+		HWType:        iana.HWTypeEthernet,
 		LinkLayerAddr: []byte{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa},
 	}
 	solicit, err := NewMessage(WithClientID(duid))
@@ -145,6 +144,18 @@ func Test_ExtractMAC(t *testing.T) {
 	require.NoError(t, err)
 	require.Equal(t, mac.String(), "aa:aa:aa:aa:aa:aa")
 
+	// MAC extracted from DUID-LLT
+	solicit, err = NewMessage(WithClientID(&DUIDLLT{
+		HWType:        iana.HWTypeEthernet,
+		LinkLayerAddr: []byte{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa},
+	}))
+	require.NoError(t, err)
+	relay, err = EncapsulateRelay(solicit, MessageTypeRelayForward, net.IPv6zero, net.IPv6zero)
+	require.NoError(t, err)
+	mac, err = ExtractMAC(relay)
+	require.NoError(t, err)
+	require.Equal(t, mac.String(), "aa:aa:aa:aa:aa:aa")
+
 	// no client ID
 	solicit, err = NewMessage()
 	require.NoError(t, err)
@@ -152,8 +163,8 @@ func Test_ExtractMAC(t *testing.T) {
 	require.Error(t, err)
 
 	// DUID is not DuidLL or DuidLLT
-	duid = Duid{}
-	solicit, err = NewMessage(WithClientID(duid))
+	duiden := &DUIDEN{}
+	solicit, err = NewMessage(WithClientID(duiden))
 	require.NoError(t, err)
 	_, err = ExtractMAC(solicit)
 	require.Error(t, err)
diff --git a/dhcpv6/modifiers.go b/dhcpv6/modifiers.go
index 1d8c49f..b0d22c5 100644
--- a/dhcpv6/modifiers.go
+++ b/dhcpv6/modifiers.go
@@ -16,12 +16,12 @@ func WithOption(o Option) Modifier {
 }
 
 // WithClientID adds a client ID option to a DHCPv6 packet
-func WithClientID(duid Duid) Modifier {
+func WithClientID(duid DUID) Modifier {
 	return WithOption(OptClientID(duid))
 }
 
 // WithServerID adds a client ID option to a DHCPv6 packet
-func WithServerID(duid Duid) Modifier {
+func WithServerID(duid DUID) Modifier {
 	return WithOption(OptServerID(duid))
 }
 
diff --git a/dhcpv6/modifiers_test.go b/dhcpv6/modifiers_test.go
index 1d48086..322d4e3 100644
--- a/dhcpv6/modifiers_test.go
+++ b/dhcpv6/modifiers_test.go
@@ -1,7 +1,6 @@
 package dhcpv6
 
 import (
-	"log"
 	"net"
 	"testing"
 	"time"
@@ -11,27 +10,25 @@ import (
 )
 
 func TestWithClientID(t *testing.T) {
-	duid := Duid{
-		Type:          DUID_LL,
-		HwType:        iana.HWTypeEthernet,
+	duid := &DUIDLL{
+		HWType:        iana.HWTypeEthernet,
 		LinkLayerAddr: net.HardwareAddr([]byte{0xfa, 0xce, 0xb0, 0x00, 0x00, 0x0c}),
 	}
 	m, err := NewMessage(WithClientID(duid))
 	require.NoError(t, err)
 	cid := m.Options.ClientID()
-	require.Equal(t, cid, &duid)
+	require.Equal(t, cid, duid)
 }
 
 func TestWithServerID(t *testing.T) {
-	duid := Duid{
-		Type:          DUID_LL,
-		HwType:        iana.HWTypeEthernet,
+	duid := &DUIDLL{
+		HWType:        iana.HWTypeEthernet,
 		LinkLayerAddr: net.HardwareAddr([]byte{0xfa, 0xce, 0xb0, 0x00, 0x00, 0x0c}),
 	}
 	m, err := NewMessage(WithServerID(duid))
 	require.NoError(t, err)
 	sid := m.Options.ServerID()
-	require.Equal(t, sid, &duid)
+	require.Equal(t, sid, duid)
 }
 
 func TestWithRequestedOptions(t *testing.T) {
@@ -65,7 +62,6 @@ func TestWithDNS(t *testing.T) {
 	)(&d)
 	require.Equal(t, 1, len(d.Options.Options))
 	dns := d.Options.DNS()
-	log.Printf("DNS %+v", dns)
 	require.Equal(t, 2, len(dns))
 	require.Equal(t, net.ParseIP("fe80::1"), dns[0])
 	require.Equal(t, net.ParseIP("fe80::2"), dns[1])
diff --git a/dhcpv6/option_4rd.go b/dhcpv6/option_4rd.go
index 13d672a..3b074f4 100644
--- a/dhcpv6/option_4rd.go
+++ b/dhcpv6/option_4rd.go
@@ -8,7 +8,9 @@ import (
 )
 
 // Opt4RD represents a 4RD option. It is only a container for 4RD_*_RULE options
-type Opt4RD Options
+type Opt4RD struct {
+	FourRDOptions
+}
 
 // Code returns the Option Code for this option
 func (op *Opt4RD) Code() OptionCode {
@@ -17,32 +19,75 @@ func (op *Opt4RD) Code() OptionCode {
 
 // ToBytes serializes this option
 func (op *Opt4RD) ToBytes() []byte {
-	return (*Options)(op).ToBytes()
+	return op.Options.ToBytes()
 }
 
 // String returns a human-readable representation of the option
 func (op *Opt4RD) String() string {
-	return fmt.Sprintf("Opt4RD{%v}", (*Options)(op))
+	return fmt.Sprintf("%s: {Options=%v}", op.Code(), op.Options)
+}
+
+// LongString returns a multi-line human-readable representation of the option
+func (op *Opt4RD) LongString(indentSpace int) string {
+	return fmt.Sprintf("%s: Options=%v", op.Code(), op.Options.LongString(indentSpace))
 }
 
-// ParseOpt4RD builds an Opt4RD structure from a sequence of bytes.
+// FromBytes builds an Opt4RD structure from a sequence of bytes.
 // The input data does not include option code and length bytes
-func ParseOpt4RD(data []byte) (*Opt4RD, error) {
-	var opt Options
-	err := opt.FromBytes(data)
-	return (*Opt4RD)(&opt), err
+func (op *Opt4RD) FromBytes(data []byte) error {
+	return op.Options.FromBytes(data)
+}
+
+// FourRDOptions are options that can be encapsulated with the 4RD option.
+type FourRDOptions struct {
+	Options
 }
 
-// Opt4RDMapRule represents a 4RD Mapping Rule option
-// The option is described in https://tools.ietf.org/html/rfc7600#section-4.9
-// The 4RD mapping rules are described in https://tools.ietf.org/html/rfc7600#section-4.2
+// MapRules returns the map rules associated with the 4RD option.
+//
+//	"The OPTION_4RD DHCPv6 option contains at least one encapsulated
+//	OPTION_4RD_MAP_RULE option." (RFC 7600 Section 4.9)
+func (frdo FourRDOptions) MapRules() []*Opt4RDMapRule {
+	opts := frdo.Options.Get(Option4RDMapRule)
+	var mrs []*Opt4RDMapRule
+	for _, o := range opts {
+		if m, ok := o.(*Opt4RDMapRule); ok {
+			mrs = append(mrs, m)
+		}
+	}
+	return mrs
+}
+
+// NonMapRule returns the non-map-rule associated with this option.
+//
+//	"The OPTION_4RD DHCPv6 option contains ... a maximum of one
+//	encapsulated OPTION_4RD_NON_MAP_RULE option." (RFC 7600 Section 4.9)
+func (frdo FourRDOptions) NonMapRule() *Opt4RDNonMapRule {
+	opt := frdo.Options.GetOne(Option4RDNonMapRule)
+	if opt == nil {
+		return nil
+	}
+	nmr, ok := opt.(*Opt4RDNonMapRule)
+	if !ok {
+		return nil
+	}
+	return nmr
+}
+
+// Opt4RDMapRule represents a 4RD Mapping Rule option.
+//
+// The option is described in RFC 7600 Section 4.9. The 4RD mapping rules are
+// described in RFC 7600 Section 4.2.
 type Opt4RDMapRule struct {
 	// Prefix4 is the IPv4 prefix mapped by this rule
 	Prefix4 net.IPNet
+
 	// Prefix6 is the IPv6 prefix mapped by this rule
 	Prefix6 net.IPNet
+
 	// EABitsLength is the number of bits of an address used in constructing the mapped address
 	EABitsLength uint8
+
 	// WKPAuthorized determines if well-known ports are assigned to addresses in an A+P mapping
 	// It can only be set if the length of Prefix4 + EABits > 32
 	WKPAuthorized bool
@@ -94,30 +139,31 @@ func (op *Opt4RDMapRule) ToBytes() []byte {
 
 // String returns a human-readable description of this option
 func (op *Opt4RDMapRule) String() string {
-	return fmt.Sprintf("Opt4RDMapRule{Prefix4=%s, Prefix6=%s, EA-Bits=%d, WKPAuthorized=%t}",
-		op.Prefix4.String(), op.Prefix6.String(), op.EABitsLength, op.WKPAuthorized)
+	return fmt.Sprintf("%s: {Prefix4=%s, Prefix6=%s, EA-Bits=%d, WKPAuthorized=%t}",
+		op.Code(), op.Prefix4.String(), op.Prefix6.String(), op.EABitsLength, op.WKPAuthorized)
 }
 
-// ParseOpt4RDMapRule builds an Opt4RDMapRule structure from a sequence of bytes.
+// FromBytes builds an Opt4RDMapRule structure from a sequence of bytes.
 // The input data does not include option code and length bytes.
-func ParseOpt4RDMapRule(data []byte) (*Opt4RDMapRule, error) {
-	var opt Opt4RDMapRule
+func (op *Opt4RDMapRule) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
-	opt.Prefix4.Mask = net.CIDRMask(int(buf.Read8()), 32)
-	opt.Prefix6.Mask = net.CIDRMask(int(buf.Read8()), 128)
-	opt.EABitsLength = buf.Read8()
-	opt.WKPAuthorized = (buf.Read8() & opt4RDWKPAuthorizedMask) != 0
-	opt.Prefix4.IP = net.IP(buf.CopyN(net.IPv4len))
-	opt.Prefix6.IP = net.IP(buf.CopyN(net.IPv6len))
-	return &opt, buf.FinError()
+	op.Prefix4.Mask = net.CIDRMask(int(buf.Read8()), 32)
+	op.Prefix6.Mask = net.CIDRMask(int(buf.Read8()), 128)
+	op.EABitsLength = buf.Read8()
+	op.WKPAuthorized = (buf.Read8() & opt4RDWKPAuthorizedMask) != 0
+	op.Prefix4.IP = net.IP(buf.CopyN(net.IPv4len))
+	op.Prefix6.IP = net.IP(buf.CopyN(net.IPv6len))
+	return buf.FinError()
 }
 
 // Opt4RDNonMapRule represents 4RD parameters other than mapping rules
 type Opt4RDNonMapRule struct {
 	// HubAndSpoke is whether the network topology is hub-and-spoke or meshed
 	HubAndSpoke bool
+
 	// TrafficClass is an optional 8-bit tunnel traffic class identifier
 	TrafficClass *uint8
+
 	// DomainPMTU is the Path MTU for this 4RD domain
 	DomainPMTU uint16
 }
@@ -154,25 +200,23 @@ func (op *Opt4RDNonMapRule) String() string {
 		tClass = *op.TrafficClass
 	}
 
-	return fmt.Sprintf("Opt4RDNonMapRule{HubAndSpoke=%t, TrafficClass=%v, DomainPMTU=%d}",
-		op.HubAndSpoke, tClass, op.DomainPMTU)
+	return fmt.Sprintf("%s: {HubAndSpoke=%t, TrafficClass=%v, DomainPMTU=%d}", op.Code(), op.HubAndSpoke, tClass, op.DomainPMTU)
 }
 
-// ParseOpt4RDNonMapRule builds an Opt4RDNonMapRule structure from a sequence of bytes.
+// FromBytes builds an Opt4RDNonMapRule structure from a sequence of bytes.
 // The input data does not include option code and length bytes
-func ParseOpt4RDNonMapRule(data []byte) (*Opt4RDNonMapRule, error) {
-	var opt Opt4RDNonMapRule
+func (op *Opt4RDNonMapRule) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
 	flags := buf.Read8()
 
-	opt.HubAndSpoke = flags&opt4RDHubAndSpokeMask != 0
+	op.HubAndSpoke = flags&opt4RDHubAndSpokeMask != 0
 
 	tClass := buf.Read8()
 	if flags&opt4RDTrafficClassMask != 0 {
-		opt.TrafficClass = &tClass
+		op.TrafficClass = &tClass
 	}
 
-	opt.DomainPMTU = buf.Read16()
+	op.DomainPMTU = buf.Read16()
 
-	return &opt, buf.FinError()
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_4rd_test.go b/dhcpv6/option_4rd_test.go
index 1251b13..ca85bdd 100644
--- a/dhcpv6/option_4rd_test.go
+++ b/dhcpv6/option_4rd_test.go
@@ -1,46 +1,314 @@
 package dhcpv6
 
 import (
+	"bytes"
+	"errors"
+	"fmt"
 	"net"
+	"reflect"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestOpt4RDNonMapRuleParse(t *testing.T) {
-	data := []byte{0x81, 0xaa, 0x05, 0xd4}
-	opt, err := ParseOpt4RDNonMapRule(data)
-	require.NoError(t, err)
-	require.True(t, opt.HubAndSpoke)
-	require.NotNil(t, opt.TrafficClass)
-	require.EqualValues(t, 0xaa, *opt.TrafficClass)
-	require.EqualValues(t, 1492, opt.DomainPMTU)
+func Test4RDParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want []*Opt4RD
+	}{
+		{
+			buf: []byte{
+				0, 97, // 4RD option code
+				0, 28, // length
+				0, 98, // 4RD Map Rule option
+				0, 24, // length
+				16,             // prefix4-length
+				16,             // prefix6-length
+				8,              // ea-len
+				0,              // WKPAuthorized
+				192, 168, 0, 1, // rule-ipv4-prefix
+				0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // rule-ipv6-prefix
+			},
+			want: []*Opt4RD{
+				&Opt4RD{
+					FourRDOptions: FourRDOptions{Options: Options{
+						&Opt4RDMapRule{
+							Prefix4: net.IPNet{
+								IP:   net.IP{192, 168, 0, 1},
+								Mask: net.CIDRMask(16, 32),
+							},
+							Prefix6: net.IPNet{
+								IP:   net.ParseIP("fe80::"),
+								Mask: net.CIDRMask(16, 128),
+							},
+							EABitsLength: 8,
+						},
+					}},
+				},
+			},
+		},
+		{
+			buf: []byte{
+				0, 97, // 4RD option code
+				0, 28, // length
+				0, 98, // 4RD Map Rule option
+				0, 24, // length
+				16,             // prefix4-length
+				16,             // prefix6-length
+				8,              // ea-len
+				0,              // WKPAuthorized
+				192, 168, 0, 1, // rule-ipv4-prefix
+				0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // rule-ipv6-prefix
 
-	// Remove the TrafficClass flag and check value is ignored
-	data[0] = 0x80
-	opt, err = ParseOpt4RDNonMapRule(data)
-	require.NoError(t, err)
-	require.True(t, opt.HubAndSpoke)
-	require.Nil(t, opt.TrafficClass)
-	require.EqualValues(t, 1492, opt.DomainPMTU)
+				0, 97, // 4RD
+				0, 8, // length
+				0, 99, // 4RD non map rule
+				0, 4, // length
+				0x80, 0x00, 0x05, 0xd4,
+			},
+			want: []*Opt4RD{
+				&Opt4RD{
+					FourRDOptions: FourRDOptions{Options: Options{
+						&Opt4RDMapRule{
+							Prefix4: net.IPNet{
+								IP:   net.IP{192, 168, 0, 1},
+								Mask: net.CIDRMask(16, 32),
+							},
+							Prefix6: net.IPNet{
+								IP:   net.ParseIP("fe80::"),
+								Mask: net.CIDRMask(16, 128),
+							},
+							EABitsLength: 8,
+						},
+					}},
+				},
+				&Opt4RD{
+					FourRDOptions: FourRDOptions{Options: Options{
+						&Opt4RDNonMapRule{
+							HubAndSpoke: true,
+							DomainPMTU:  1492,
+						},
+					}},
+				},
+			},
+		},
+		{
+			buf:  []byte{0, 97, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+		{
+			// Allowed, because the RFC doesn't really specify that
+			// it can't be empty. RFC doesn't really specify
+			// anything, frustratingly.
+			buf: []byte{
+				0, 97, // 4RD option code
+				0, 0, // length
+			},
+			want: []*Opt4RD{&Opt4RD{FourRDOptions: FourRDOptions{Options: Options{}}}},
+		},
+		{
+			buf: []byte{
+				0, 97, // 4RD option code
+				0, 6, // length
+				0, 98, // 4RD Map Rule option
+				0, 4, // length
+				16, // prefix4-length
+				16, // prefix6-length
+				8,  // ea-len
+				0,  // WKPAuthorized
+				// Missing
+			},
+			want: nil,
+			err:  uio.ErrBufferTooShort,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.FourRD(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("FourRD = %v, want %v", got, tt.want)
+			}
+			if len(tt.want) >= 1 {
+				var b MessageOptions
+				for _, frd := range tt.want {
+					b.Add(frd)
+				}
+				got := b.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
+	}
 }
 
-func TestOpt4RDNonMapRuleToBytes(t *testing.T) {
-	var tClass uint8 = 0xaa
-	opt := Opt4RDNonMapRule{
-		HubAndSpoke:  true,
-		TrafficClass: &tClass,
-		DomainPMTU:   1492,
+func Test4RDMapRuleParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want []*Opt4RDMapRule
+	}{
+		{
+			buf: []byte{
+				0, 98, // 4RD Map Rule option
+				0, 24, // length
+				16,             // prefix4-length
+				16,             // prefix6-length
+				8,              // ea-len
+				0,              // WKPAuthorized
+				192, 168, 0, 1, // rule-ipv4-prefix
+				0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // rule-ipv6-prefix
+			},
+			want: []*Opt4RDMapRule{
+				&Opt4RDMapRule{
+					Prefix4: net.IPNet{
+						IP:   net.IP{192, 168, 0, 1},
+						Mask: net.CIDRMask(16, 32),
+					},
+					Prefix6: net.IPNet{
+						IP:   net.ParseIP("fe80::"),
+						Mask: net.CIDRMask(16, 128),
+					},
+					EABitsLength: 8,
+				},
+			},
+		},
+		{
+			buf: []byte{
+				0, 98, // 4RD Map Rule option
+				0, 24, // length
+				16,             // prefix4-length
+				16,             // prefix6-length
+				8,              // ea-len
+				1 << 7,         // WKPAuthorized
+				192, 168, 0, 1, // rule-ipv4-prefix
+				0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // rule-ipv6-prefix
+			},
+			want: []*Opt4RDMapRule{
+				&Opt4RDMapRule{
+					Prefix4: net.IPNet{
+						IP:   net.IP{192, 168, 0, 1},
+						Mask: net.CIDRMask(16, 32),
+					},
+					Prefix6: net.IPNet{
+						IP:   net.ParseIP("fe80::"),
+						Mask: net.CIDRMask(16, 128),
+					},
+					EABitsLength:  8,
+					WKPAuthorized: true,
+				},
+			},
+		},
+		{
+			buf:  []byte{0, 98, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrBufferTooShort,
+		},
+		{
+			buf: []byte{
+				0, 98, // 4RD Map Rule option
+				0, 4, // length
+				16, // prefix4-length
+				16, // prefix6-length
+				8,  // ea-len
+				0,  // WKPAuthorized
+				// Missing
+			},
+			want: nil,
+			err:  uio.ErrBufferTooShort,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var frdo FourRDOptions
+			if err := frdo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := frdo.MapRules(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("MapRules = %v, want %v", got, tt.want)
+			}
+			if len(tt.want) >= 1 {
+				var b FourRDOptions
+				for _, frd := range tt.want {
+					b.Add(frd)
+				}
+				got := b.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
-	expected := []byte{0x81, 0xaa, 0x05, 0xd4}
-
-	require.Equal(t, expected, opt.ToBytes())
-
-	// Unsetting TrafficClass should zero the corresponding bytes in the output
-	opt.TrafficClass = nil
-	expected[0], expected[1] = 0x80, 0x00
+}
 
-	require.Equal(t, expected, opt.ToBytes())
+func Test4RDNonMapRuleParseAndGetter(t *testing.T) {
+	trafficClassOne := uint8(1)
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want *Opt4RDNonMapRule
+	}{
+		{
+			buf: []byte{
+				0, 99, // 4RD Non Map Rule option
+				0, 4, // length
+				0x80, 0, 0x05, 0xd4,
+			},
+			want: &Opt4RDNonMapRule{
+				HubAndSpoke: true,
+				DomainPMTU:  1492,
+			},
+		},
+		{
+			buf: []byte{
+				0, 99, // 4RD Non Map Rule option
+				0, 4, // length
+				0, 0, 0x05, 0xd4,
+			},
+			want: &Opt4RDNonMapRule{
+				DomainPMTU: 1492,
+			},
+		},
+		{
+			buf: []byte{
+				0, 99, // 4RD Non Map Rule option
+				0, 4, // length
+				0x1, 0x01, 0x05, 0xd4,
+			},
+			want: &Opt4RDNonMapRule{
+				TrafficClass: &trafficClassOne,
+				DomainPMTU:   1492,
+			},
+		},
+		{
+			buf:  []byte{0, 99, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrBufferTooShort,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var frdo FourRDOptions
+			if err := frdo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := frdo.NonMapRule(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("NonMapRule = %v, want %v", got, tt.want)
+			}
+			if tt.want != nil {
+				var b FourRDOptions
+				b.Add(tt.want)
+				got := b.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
+	}
 }
 
 func TestOpt4RDNonMapRuleString(t *testing.T) {
@@ -61,58 +329,21 @@ func TestOpt4RDNonMapRuleString(t *testing.T) {
 		"String() should contain the domain PMTU")
 }
 
-func TestOpt4RDMapRuleParse(t *testing.T) {
-	ip6addr, ip6net, err := net.ParseCIDR("2001:db8::1234:5678:0:aabb/64")
-	ip6net.IP = ip6addr // We want to keep the entire address however, not apply the mask
-	require.NoError(t, err)
-	ip4addr, ip4net, err := net.ParseCIDR("100.64.0.234/10")
-	ip4net.IP = ip4addr.To4()
-	require.NoError(t, err)
-	data := append([]byte{
-		10,   // IPv4 prefix length
-		64,   // IPv6 prefix length
-		32,   // EA-bits
-		0x80, // WKPs authorized
-	},
-		append(ip4addr.To4(), ip6addr...)...,
-	)
-
-	opt, err := ParseOpt4RDMapRule(data)
-	require.NoError(t, err)
-	require.EqualValues(t, *ip6net, opt.Prefix6)
-	require.EqualValues(t, *ip4net, opt.Prefix4)
-	require.EqualValues(t, 32, opt.EABitsLength)
-	require.True(t, opt.WKPAuthorized)
-}
-
 func TestOpt4RDMapRuleToBytes(t *testing.T) {
 	opt := Opt4RDMapRule{
-		Prefix4: net.IPNet{
-			IP:   net.IPv4(100, 64, 0, 238),
-			Mask: net.CIDRMask(24, 32),
-		},
-		Prefix6: net.IPNet{
-			IP:   net.ParseIP("2001:db8::1234:5678:0:aabb"),
-			Mask: net.CIDRMask(80, 128),
-		},
 		EABitsLength:  32,
 		WKPAuthorized: true,
 	}
 
 	expected := append([]byte{
-		24,   // v4 prefix length
-		80,   // v6 prefix length
+		0,    // v4 prefix length
+		0,    // v6 prefix length
 		32,   // EA-bits
 		0x80, // WKPs authorized
-	},
-		append(opt.Prefix4.IP.To4(), opt.Prefix6.IP.To16()...)...,
-	)
-
+	}, bytes.Repeat([]byte{0x00}, 4+16)...)
 	require.Equal(t, expected, opt.ToBytes())
 }
 
-// FIXME: Invalid packets are serialized without error
-
 func TestOpt4RDMapRuleString(t *testing.T) {
 	opt := Opt4RDMapRule{
 		Prefix4: net.IPNet{
@@ -135,34 +366,3 @@ func TestOpt4RDMapRuleString(t *testing.T) {
 		"String() should include the IPv4 prefix")
 	require.Contains(t, str, "EA-Bits=32", "String() should include the value for EA-Bits")
 }
-
-// This test round-trip serialization/deserialization of both kinds of 4RD
-// options, and the container option
-func TestOpt4RDRoundTrip(t *testing.T) {
-	var tClass uint8 = 0xaa
-	opt := Opt4RD{
-		&Opt4RDMapRule{
-			Prefix4: net.IPNet{
-				IP:   net.IPv4(100, 64, 0, 238).To4(),
-				Mask: net.CIDRMask(24, 32),
-			},
-			Prefix6: net.IPNet{
-				IP:   net.ParseIP("2001:db8::1234:5678:0:aabb"),
-				Mask: net.CIDRMask(80, 128),
-			},
-			EABitsLength:  32,
-			WKPAuthorized: true,
-		},
-		&Opt4RDNonMapRule{
-			HubAndSpoke:  true,
-			TrafficClass: &tClass,
-			DomainPMTU:   9000,
-		},
-	}
-
-	rtOpt, err := ParseOpt4RD(opt.ToBytes())
-
-	require.NoError(t, err)
-	require.NotNil(t, rtOpt)
-	require.Equal(t, opt, *rtOpt)
-}
diff --git a/dhcpv6/option_archtype.go b/dhcpv6/option_archtype.go
index 1461056..2b778d8 100644
--- a/dhcpv6/option_archtype.go
+++ b/dhcpv6/option_archtype.go
@@ -23,13 +23,9 @@ func (op *optClientArchType) Code() OptionCode {
 }
 
 func (op optClientArchType) String() string {
-	return fmt.Sprintf("ClientArchType: %s", op.Archs.String())
+	return fmt.Sprintf("%s: %s", op.Code(), op.Archs)
 }
 
-// parseOptClientArchType builds an OptClientArchType structure from
-// a sequence of bytes The input data does not include option code and
-// length bytes.
-func parseOptClientArchType(data []byte) (*optClientArchType, error) {
-	var opt optClientArchType
-	return &opt, opt.FromBytes(data)
+func (op *optClientArchType) FromBytes(p []byte) error {
+	return op.Archs.FromBytes(p)
 }
diff --git a/dhcpv6/option_archtype_test.go b/dhcpv6/option_archtype_test.go
index 0481c1a..b8a39fd 100644
--- a/dhcpv6/option_archtype_test.go
+++ b/dhcpv6/option_archtype_test.go
@@ -1,34 +1,74 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
+	"reflect"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/insomniacslk/dhcp/iana"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestParseOptClientArchType(t *testing.T) {
-	data := []byte{
-		0, 6, // EFI_IA32
-	}
-	opt, err := parseOptClientArchType(data)
-	require.NoError(t, err)
-	require.Equal(t, iana.EFI_IA32, opt.Archs[0])
-}
-
-func TestParseOptClientArchTypeInvalid(t *testing.T) {
-	data := []byte{42}
-	_, err := parseOptClientArchType(data)
-	require.Error(t, err)
-}
+func TestArchTypeParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want iana.Archs
+	}{
+		{
+			buf: []byte{
+				0, 61, // Client Arch Types option
+				0, 2, // length
+				0, 7, // EFI_X86_64
+			},
+			want: iana.Archs{iana.EFI_X86_64},
+		},
+		{
+			buf: []byte{
+				0, 61, // Client Arch Types option
+				0, 4, // length
+				0, 7, // EFI_X86_64
+				0, 8, // EFI_XSCALE
+			},
+			want: iana.Archs{iana.EFI_X86_64, iana.EFI_XSCALE},
+		},
+		{
+			buf:  nil,
+			want: nil,
+		},
+		{
+			buf:  []byte{0, 61, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+		{
+			buf:  []byte{0, 61, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.ArchTypes(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("ArchTypes = %v, want %v", got, tt.want)
+			}
 
-func TestOptClientArchTypeParseAndToBytes(t *testing.T) {
-	data := []byte{
-		0, 8, // EFI_XSCALE
+			if tt.want != nil {
+				var m MessageOptions
+				m.Add(OptClientArchType(tt.want...))
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
-	opt, err := parseOptClientArchType(data)
-	require.NoError(t, err)
-	require.Equal(t, data, opt.ToBytes())
 }
 
 func TestOptClientArchType(t *testing.T) {
diff --git a/dhcpv6/option_bootfileparam.go b/dhcpv6/option_bootfileparam.go
index 1bd54bc..ba09ca0 100644
--- a/dhcpv6/option_bootfileparam.go
+++ b/dhcpv6/option_bootfileparam.go
@@ -9,10 +9,12 @@ import (
 // OptBootFileParam returns a BootfileParam option as defined in RFC 5970
 // Section 3.2.
 func OptBootFileParam(args ...string) Option {
-	return optBootFileParam(args)
+	return &optBootFileParam{args}
 }
 
-type optBootFileParam []string
+type optBootFileParam struct {
+	params []string
+}
 
 // Code returns the option code
 func (optBootFileParam) Code() OptionCode {
@@ -22,7 +24,7 @@ func (optBootFileParam) Code() OptionCode {
 // ToBytes serializes the option and returns it as a sequence of bytes
 func (op optBootFileParam) ToBytes() []byte {
 	buf := uio.NewBigEndianBuffer(nil)
-	for _, param := range op {
+	for _, param := range op.params {
 		if len(param) >= 1<<16 {
 			// TODO: say something here instead of silently ignoring a parameter
 			continue
@@ -42,20 +44,16 @@ func (op optBootFileParam) ToBytes() []byte {
 }
 
 func (op optBootFileParam) String() string {
-	return fmt.Sprintf("BootFileParam: %v", ([]string)(op))
+	return fmt.Sprintf("%s: %v", op.Code(), op.params)
 }
 
-// parseOptBootFileParam builds an OptBootFileParam structure from a sequence
+// FromBytes builds an OptBootFileParam structure from a sequence
 // of bytes. The input data does not include option code and length bytes.
-func parseOptBootFileParam(data []byte) (optBootFileParam, error) {
+func (op *optBootFileParam) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
-	var result optBootFileParam
 	for buf.Has(2) {
 		length := buf.Read16()
-		result = append(result, string(buf.CopyN(int(length))))
-	}
-	if err := buf.FinError(); err != nil {
-		return nil, err
+		op.params = append(op.params, string(buf.CopyN(int(length))))
 	}
-	return result, nil
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_bootfileparam_test.go b/dhcpv6/option_bootfileparam_test.go
index 467f245..d2e92a7 100644
--- a/dhcpv6/option_bootfileparam_test.go
+++ b/dhcpv6/option_bootfileparam_test.go
@@ -2,70 +2,78 @@ package dhcpv6
 
 import (
 	"bytes"
-	"encoding/binary"
+	"errors"
 	"fmt"
+	"reflect"
+	"strings"
 	"testing"
 
-	"github.com/stretchr/testify/require"
+	"github.com/google/go-cmp/cmp"
+	"github.com/u-root/uio/uio"
 )
 
-var (
-	testBootfileParams0Compiled = "\x00\x0eroot=/dev/sda1\x00\x00\x00\x02rw"
-	testBootfileParams1         = []string{
-		"initrd=http://myserver.mycompany.local/initrd.xz",
-		"",
-		"root=/dev/sda1",
-		"rw",
-		"netconsole=..:\000:.something\000here.::..",
-		string(make([]byte, (1<<16)-1)),
+func TestBootFileParamLargeParameter(t *testing.T) {
+	param := []string{
+		"foo=bar",
+		strings.Repeat("a", 1<<16),
 	}
-)
+	var m MessageOptions
+	m.Add(OptBootFileParam(param...))
+	want := append([]byte{
+		0, 60, // Boot File Param
+		0, 9, // length
+		0, 7,
+	}, []byte("foo=bar")...)
 
-// compileTestBootfileParams is an independent implementation of bootfile param encoder
-func compileTestBootfileParams(t *testing.T, params []string) []byte {
-	var length [2]byte
-	buf := bytes.Buffer{}
-	for _, param := range params {
-		if len(param) >= 1<<16 {
-			panic("a too long parameter")
-		}
-		binary.BigEndian.PutUint16(length[:], uint16(len(param)))
-		_, err := buf.Write(length[:])
-		require.NoError(t, err)
-		_, err = buf.WriteString(param)
-		require.NoError(t, err)
+	got := m.ToBytes()
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
 	}
-
-	return buf.Bytes()
 }
 
-func TestOptBootFileParam(t *testing.T) {
-	expected := string(compileTestBootfileParams(t, testBootfileParams1))
-	opt, err := parseOptBootFileParam([]byte(expected))
-	if err != nil {
-		t.Fatal(err)
-	}
-	if string(opt.ToBytes()) != expected {
-		t.Fatalf("Invalid boot file parameter. Expected %v, got %v", expected, opt)
-	}
+func joinBytes(p ...[]byte) []byte {
+	return bytes.Join(p, nil)
 }
 
-func TestParsedTypeOptBootFileParam(t *testing.T) {
-	tryParse := func(compiled []byte, expected []string) {
-		opt, err := ParseOption(OptionBootfileParam, compiled)
-		require.NoError(t, err)
-		bootfileParamOpt, ok := opt.(optBootFileParam)
-		require.True(t, ok, fmt.Sprintf("invalid type: %T instead of %T", opt, bootfileParamOpt))
-		require.Equal(t, compiled, bootfileParamOpt.ToBytes())
-		require.Equal(t, expected, ([]string)(bootfileParamOpt))
-	}
+func TestBootFileParamParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want []string
+	}{
+		{
+			buf: joinBytes([]byte{
+				0, 60, // Boot File Param
+				0, 25, // length
+				0, 14,
+			}, []byte("root=/dev/sda1"), []byte{0, 7}, []byte("foo=bar")),
+			want: []string{"root=/dev/sda1", "foo=bar"},
+		},
+		{
+			buf: nil,
+		},
+		{
+			buf: []byte{0, 60, 0},
+			err: uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.BootFileParam(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("BootFileParam = %v, want %v", got, tt.want)
+			}
 
-	tryParse(
-		[]byte(testBootfileParams0Compiled),
-		[]string{"root=/dev/sda1", "", "rw"},
-	)
-	tryParse(
-		compileTestBootfileParams(t, testBootfileParams1),
-		testBootfileParams1,
-	)
+			if tt.want != nil {
+				var m MessageOptions
+				m.Add(OptBootFileParam(tt.want...))
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
+	}
 }
diff --git a/dhcpv6/option_bootfileurl.go b/dhcpv6/option_bootfileurl.go
index 695f164..7a8e54a 100644
--- a/dhcpv6/option_bootfileurl.go
+++ b/dhcpv6/option_bootfileurl.go
@@ -6,10 +6,12 @@ import (
 
 // OptBootFileURL returns a OptionBootfileURL as defined by RFC 5970.
 func OptBootFileURL(url string) Option {
-	return optBootFileURL(url)
+	return &optBootFileURL{url}
 }
 
-type optBootFileURL string
+type optBootFileURL struct {
+	url string
+}
 
 // Code returns the option code
 func (op optBootFileURL) Code() OptionCode {
@@ -18,15 +20,16 @@ func (op optBootFileURL) Code() OptionCode {
 
 // ToBytes serializes the option and returns it as a sequence of bytes
 func (op optBootFileURL) ToBytes() []byte {
-	return []byte(op)
+	return []byte(op.url)
 }
 
 func (op optBootFileURL) String() string {
-	return fmt.Sprintf("BootFileURL: %s", string(op))
+	return fmt.Sprintf("%s: %s", op.Code(), op.url)
 }
 
-// parseOptBootFileURL builds an optBootFileURL structure from a sequence
+// FromBytes builds an optBootFileURL structure from a sequence
 // of bytes. The input data does not include option code and length bytes.
-func parseOptBootFileURL(data []byte) (optBootFileURL, error) {
-	return optBootFileURL(string(data)), nil
+func (op *optBootFileURL) FromBytes(data []byte) error {
+	op.url = string(data)
+	return nil
 }
diff --git a/dhcpv6/option_bootfileurl_test.go b/dhcpv6/option_bootfileurl_test.go
index ac45ef5..726fde8 100644
--- a/dhcpv6/option_bootfileurl_test.go
+++ b/dhcpv6/option_bootfileurl_test.go
@@ -1,29 +1,59 @@
 package dhcpv6
 
 import (
-	"bytes"
+	"errors"
+	"fmt"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestOptBootFileURL(t *testing.T) {
-	expected := "https://insomniac.slackware.it"
-	opt, err := parseOptBootFileURL([]byte(expected))
-	if err != nil {
-		t.Fatal(err)
-	}
-	if string(opt) != expected {
-		t.Fatalf("Invalid boot file URL. Expected %v, got %v", expected, opt)
+func TestBootFileURLParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want string
+	}{
+		{
+			buf: []byte{
+				0, 59, // Boot File URL
+				0, 17, // length
+				'h', 't', 't', 'p', ':', '/', '/', 'u', '-', 'r', 'o', 'o', 't', '.', 'o', 'r', 'g',
+			},
+			want: "http://u-root.org",
+		},
+		{
+			buf: nil,
+		},
+		{
+			buf: []byte{0, 59, 0},
+			err: uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.BootFileURL(); got != tt.want {
+				t.Errorf("BootFileURL = %v, want %v", got, tt.want)
+			}
+
+			if tt.want != "" {
+				var m MessageOptions
+				m.Add(OptBootFileURL(tt.want))
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
-	require.Contains(t, opt.String(), "https://insomniac.slackware.it", "String() should contain the correct BootFileUrl output")
 }
 
-func TestOptBootFileURLToBytes(t *testing.T) {
-	urlString := "https://insomniac.slackware.it"
-	opt := OptBootFileURL(urlString)
-	toBytes := opt.ToBytes()
-	if !bytes.Equal(toBytes, []byte(urlString)) {
-		t.Fatalf("Invalid ToBytes result. Expected %v, got %v", urlString, toBytes)
-	}
+func TestOptBootFileURL(t *testing.T) {
+	opt := OptBootFileURL("https://insomniac.slackware.it")
+	require.Contains(t, opt.String(), "https://insomniac.slackware.it", "String() should contain the correct BootFileUrl output")
 }
diff --git a/dhcpv6/option_clientid.go b/dhcpv6/option_clientid.go
index 0941bcc..eea0d01 100644
--- a/dhcpv6/option_clientid.go
+++ b/dhcpv6/option_clientid.go
@@ -6,12 +6,12 @@ import (
 
 // OptClientID represents a Client Identifier option as defined by RFC 3315
 // Section 22.2.
-func OptClientID(d Duid) Option {
+func OptClientID(d DUID) Option {
 	return &optClientID{d}
 }
 
 type optClientID struct {
-	Duid
+	DUID
 }
 
 func (*optClientID) Code() OptionCode {
@@ -19,16 +19,14 @@ func (*optClientID) Code() OptionCode {
 }
 
 func (op *optClientID) String() string {
-	return fmt.Sprintf("ClientID: %v", op.Duid.String())
+	return fmt.Sprintf("%s: %s", op.Code(), op.DUID)
 }
 
-// parseOptClientID builds an OptClientId structure from a sequence
+// FromBytes builds an optClientID structure from a sequence
 // of bytes. The input data does not include option code and length
 // bytes.
-func parseOptClientID(data []byte) (*optClientID, error) {
-	cid, err := DuidFromBytes(data)
-	if err != nil {
-		return nil, err
-	}
-	return &optClientID{*cid}, nil
+func (op *optClientID) FromBytes(data []byte) error {
+	var err error
+	op.DUID, err = DUIDFromBytes(data)
+	return err
 }
diff --git a/dhcpv6/option_clientid_test.go b/dhcpv6/option_clientid_test.go
index 2f1f1f0..a80403a 100644
--- a/dhcpv6/option_clientid_test.go
+++ b/dhcpv6/option_clientid_test.go
@@ -1,58 +1,111 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
 	"net"
+	"reflect"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/insomniacslk/dhcp/iana"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestParseOptClientID(t *testing.T) {
-	data := []byte{
-		0, 3, // DUID_LL
-		0, 1, // hwtype ethernet
-		0, 1, 2, 3, 4, 5, // hw addr
+func TestClientIDParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want DUID
+	}{
+		{
+			buf: []byte{
+				0, 1, // Client ID option
+				0, 10, // length
+				0, 3, // DUID_LL
+				0, 1, // hwtype ethernet
+				0, 1, 2, 3, 4, 5, // HW addr
+			},
+			want: &DUIDLL{HWType: iana.HWTypeEthernet, LinkLayerAddr: net.HardwareAddr{0, 1, 2, 3, 4, 5}},
+		},
+		{
+			buf:  nil,
+			want: nil,
+		},
+		{
+			buf:  []byte{0, 1, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrBufferTooShort,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.ClientID(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("ClientID = %v, want %v", got, tt.want)
+			}
+		})
 	}
-	opt, err := parseOptClientID(data)
-	require.NoError(t, err)
-	require.Equal(t, DUID_LL, opt.Type)
-	require.Equal(t, iana.HWTypeEthernet, opt.HwType)
-	require.Equal(t, net.HardwareAddr([]byte{0, 1, 2, 3, 4, 5}), opt.LinkLayerAddr)
 }
 
-func TestOptClientIdToBytes(t *testing.T) {
-	opt := OptClientID(
-		Duid{
-			Type:          DUID_LL,
-			HwType:        iana.HWTypeEthernet,
-			LinkLayerAddr: net.HardwareAddr([]byte{5, 4, 3, 2, 1, 0}),
+func TestClientID(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		want optClientID
+		err  error
+	}{
+		{
+			buf: []byte{
+				0, 3, // DUID_LL
+				0, 1, // hwtype ethernet
+				0, 1, 2, 3, 4, 5, // hw addr
+			},
+			want: optClientID{
+				&DUIDLL{
+					HWType:        iana.HWTypeEthernet,
+					LinkLayerAddr: net.HardwareAddr([]byte{0, 1, 2, 3, 4, 5}),
+				},
+			},
 		},
-	)
-	expected := []byte{
-		0, 3, // DUID_LL
-		0, 1, // hwtype ethernet
-		5, 4, 3, 2, 1, 0, // hw addr
-	}
-	require.Equal(t, expected, opt.ToBytes())
-}
+		{
+			buf: []byte{0},
+			err: uio.ErrBufferTooShort,
+		},
+		{
+			buf: []byte{0, 3, 0},
+			err: uio.ErrBufferTooShort,
+		},
+		{
+			buf: nil,
+			err: uio.ErrBufferTooShort,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var opt optClientID
+			if err := opt.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if tt.err == nil {
+				if !reflect.DeepEqual(tt.want, opt) {
+					t.Errorf("FromBytes = %v, want %v", opt, tt.want)
+				}
 
-func TestOptClientIdDecodeEncode(t *testing.T) {
-	data := []byte{
-		0, 3, // DUID_LL
-		0, 1, // hwtype ethernet
-		5, 4, 3, 2, 1, 0, // hw addr
+				out := tt.want.ToBytes()
+				if diff := cmp.Diff(tt.buf, out); diff != "" {
+					t.Errorf("ToBytes mismatch: (-want, +got):\n%s", diff)
+				}
+			}
+		})
 	}
-	opt, err := parseOptClientID(data)
-	require.NoError(t, err)
-	require.Equal(t, data, opt.ToBytes())
 }
 
-func TestOptionClientId(t *testing.T) {
+func TestOptionClientIDString(t *testing.T) {
 	opt := OptClientID(
-		Duid{
-			Type:          DUID_LL,
-			HwType:        iana.HWTypeEthernet,
+		&DUIDLL{
+			HWType:        iana.HWTypeEthernet,
 			LinkLayerAddr: net.HardwareAddr([]byte{0xde, 0xad, 0, 0, 0xbe, 0xef}),
 		},
 	)
@@ -60,25 +113,7 @@ func TestOptionClientId(t *testing.T) {
 	require.Contains(
 		t,
 		opt.String(),
-		"ClientID: DUID{type=DUID-LL hwtype=Ethernet hwaddr=de:ad:00:00:be:ef}",
+		"Client ID: DUID-LL{HWType=Ethernet HWAddr=de:ad:00:00:be:ef}",
 		"String() should contain the correct cid output",
 	)
 }
-
-func TestOptClientIdparseOptClientIDBogusDUID(t *testing.T) {
-	data := []byte{
-		0, 4, // DUID_UUID
-		1, 2, 3, 4, 5, 6, 7, 8, 9, // a UUID should be 18 bytes not 17
-		10, 11, 12, 13, 14, 15, 16, 17,
-	}
-	_, err := parseOptClientID(data)
-	require.Error(t, err, "A truncated OptClientId DUID should return an error")
-}
-
-func TestOptClientIdparseOptClientIDInvalidTooShort(t *testing.T) {
-	data := []byte{
-		0, // truncated: DUIDs are at least 2 bytes
-	}
-	_, err := parseOptClientID(data)
-	require.Error(t, err, "A truncated OptClientId should return an error")
-}
diff --git a/dhcpv6/option_clientlinklayeraddress.go b/dhcpv6/option_clientlinklayeraddress.go
index 7fe5bee..878a576 100644
--- a/dhcpv6/option_clientlinklayeraddress.go
+++ b/dhcpv6/option_clientlinklayeraddress.go
@@ -33,15 +33,14 @@ func (op *optClientLinkLayerAddress) ToBytes() []byte {
 }
 
 func (op *optClientLinkLayerAddress) String() string {
-	return fmt.Sprintf("ClientLinkLayerAddress: Type=%s LinkLayerAddress=%s", op.LinkLayerType, op.LinkLayerAddress)
+	return fmt.Sprintf("%s: Type=%s LinkLayerAddress=%s", op.Code(), op.LinkLayerType, op.LinkLayerAddress)
 }
 
-// parseOptClientLinkLayerAddress deserializes from bytes
-// to build an optClientLinkLayerAddress structure.
-func parseOptClientLinkLayerAddress(data []byte) (*optClientLinkLayerAddress, error) {
-	var opt optClientLinkLayerAddress
+// FromBytes deserializes from bytes to build an optClientLinkLayerAddress
+// structure.
+func (op *optClientLinkLayerAddress) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
-	opt.LinkLayerType = iana.HWType(buf.Read16())
-	opt.LinkLayerAddress = buf.ReadAll()
-	return &opt, buf.FinError()
+	op.LinkLayerType = iana.HWType(buf.Read16())
+	op.LinkLayerAddress = buf.ReadAll()
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_clientlinklayeraddress_test.go b/dhcpv6/option_clientlinklayeraddress_test.go
index a25e882..bd603e7 100644
--- a/dhcpv6/option_clientlinklayeraddress_test.go
+++ b/dhcpv6/option_clientlinklayeraddress_test.go
@@ -2,39 +2,65 @@ package dhcpv6
 
 import (
 	"bytes"
+	"errors"
+	"fmt"
 	"net"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/insomniacslk/dhcp/iana"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestParseOptClientLinkLayerAddress(t *testing.T) {
-	data := []byte{
-		0, 1, // LinkLayerType
-		164, 131, 231, 227, 223, 136,
-	}
-	opt, err := parseOptClientLinkLayerAddress(data)
+func TestClientLinkLayerAddressParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf        []byte
+		err        error
+		wantHWType iana.HWType
+		wantHWAddr net.HardwareAddr
+	}{
+		{
+			buf: []byte{
+				0, 79, // Client Link Layer Address option
+				0, 8, // length
+				0, 1, // Ethernet
+				1, 2, 3, 4, 5, 6,
+			},
+			wantHWType: iana.HWTypeEthernet,
+			wantHWAddr: net.HardwareAddr{1, 2, 3, 4, 5, 6},
+		},
+		{
+			buf: []byte{0, 79, 0, 1, 0},
+			err: uio.ErrBufferTooShort,
+		},
+		{
+			buf: []byte{0, 79, 0},
+			err: uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var ro RelayOptions
+			if err := ro.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if gotHWType, gotHWAddr := ro.ClientLinkLayerAddress(); gotHWType != tt.wantHWType || !bytes.Equal(gotHWAddr, tt.wantHWAddr) {
+				t.Errorf("ClientLinkLayerAddress = (%s, %v), want (%s, %v)", gotHWType, tt.wantHWType, gotHWAddr, tt.wantHWAddr)
+			}
 
-	require.NoError(t, err)
-	require.Equal(t, OptionClientLinkLayerAddr, opt.Code())
-	require.Equal(t, iana.HWTypeEthernet, opt.LinkLayerType)
-	require.Equal(t, net.HardwareAddr(data[2:]), opt.LinkLayerAddress)
-	require.Equal(t, "ClientLinkLayerAddress: Type=Ethernet LinkLayerAddress=a4:83:e7:e3:df:88", opt.String())
+			if tt.err == nil {
+				var m MessageOptions
+				m.Add(OptClientLinkLayerAddress(tt.wantHWType, tt.wantHWAddr))
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
+	}
 }
 
-func TestOptClientLinkLayerAddressToBytes(t *testing.T) {
-	mac, _ := net.ParseMAC("a4:83:e7:e3:df:88")
-	opt := optClientLinkLayerAddress{
-		LinkLayerType:    iana.HWTypeEthernet,
-		LinkLayerAddress: mac,
-	}
-	want := []byte{
-		0, 1, // LinkLayerType
-		164, 131, 231, 227, 223, 136,
-	}
-	b := opt.ToBytes()
-	if !bytes.Equal(b, want) {
-		t.Fatalf("opt.ToBytes()=%v, want %v", b, want)
-	}
+func TestOptClientLinkLayerAddressString(t *testing.T) {
+	opt := OptClientLinkLayerAddress(iana.HWTypeEthernet, net.HardwareAddr{0xa4, 0x83, 0xe7, 0xe3, 0xdf, 0x88})
+	require.Equal(t, "Client Link-Layer Address: Type=Ethernet LinkLayerAddress=a4:83:e7:e3:df:88", opt.String())
 }
diff --git a/dhcpv6/option_dhcpv4_msg.go b/dhcpv6/option_dhcpv4_msg.go
index 0a1a2b3..825ea8a 100644
--- a/dhcpv6/option_dhcpv4_msg.go
+++ b/dhcpv6/option_dhcpv4_msg.go
@@ -2,6 +2,7 @@ package dhcpv6
 
 import (
 	"fmt"
+	"strings"
 
 	"github.com/insomniacslk/dhcp/dhcpv4"
 )
@@ -25,15 +26,24 @@ func (op *OptDHCPv4Msg) ToBytes() []byte {
 }
 
 func (op *OptDHCPv4Msg) String() string {
-	return fmt.Sprintf("OptDHCPv4Msg{%v}", op.Msg)
+	return fmt.Sprintf("%s: %v", op.Code(), op.Msg)
 }
 
-// ParseOptDHCPv4Msg builds an OptDHCPv4Msg structure
-// from a sequence of bytes. The input data does not include option code and length
-// bytes.
-func ParseOptDHCPv4Msg(data []byte) (*OptDHCPv4Msg, error) {
-	var opt OptDHCPv4Msg
+// LongString returns a multi-line string representation of DHCPv4 data.
+func (op *OptDHCPv4Msg) LongString(indent int) string {
+	summary := op.Msg.Summary()
+	ind := strings.Repeat(" ", indent+2)
+	if strings.Contains(summary, "\n") {
+		summary = strings.Replace(summary, "\n  ", "\n"+ind, -1)
+	}
+	ind = strings.Repeat(" ", indent)
+	return fmt.Sprintf("%s: {%v%s}", op.Code(), summary, ind)
+}
+
+// FromBytes builds an OptDHCPv4Msg structure from a sequence of bytes. The
+// input data does not include option code and length bytes.
+func (op *OptDHCPv4Msg) FromBytes(data []byte) error {
 	var err error
-	opt.Msg, err = dhcpv4.FromBytes(data)
-	return &opt, err
+	op.Msg, err = dhcpv4.FromBytes(data)
+	return err
 }
diff --git a/dhcpv6/option_dhcpv4_msg_test.go b/dhcpv6/option_dhcpv4_msg_test.go
index d8d3f6d..e7bb1f6 100644
--- a/dhcpv6/option_dhcpv4_msg_test.go
+++ b/dhcpv6/option_dhcpv4_msg_test.go
@@ -43,7 +43,8 @@ func TestParseOptDHCPv4Msg(t *testing.T) {
 	// magic cookie, then no options
 	data = append(data, magicCookie[:]...)
 
-	opt, err := ParseOptDHCPv4Msg(data)
+	var opt OptDHCPv4Msg
+	err := opt.FromBytes(data)
 	d := opt.Msg
 	require.NoError(t, err)
 	require.Equal(t, d.OpCode, dhcpv4.OpcodeBootRequest)
diff --git a/dhcpv6/option_dhcpv4_o_dhcpv6_server.go b/dhcpv6/option_dhcpv4_o_dhcpv6_server.go
index 27a0079..1bd60af 100644
--- a/dhcpv6/option_dhcpv4_o_dhcpv6_server.go
+++ b/dhcpv6/option_dhcpv4_o_dhcpv6_server.go
@@ -30,17 +30,15 @@ func (op *OptDHCP4oDHCP6Server) ToBytes() []byte {
 }
 
 func (op *OptDHCP4oDHCP6Server) String() string {
-	return fmt.Sprintf("OptDHCP4oDHCP6Server{4o6-servers=%v}", op.DHCP4oDHCP6Servers)
+	return fmt.Sprintf("%s: %v", op.Code(), op.DHCP4oDHCP6Servers)
 }
 
-// ParseOptDHCP4oDHCP6Server builds an OptDHCP4oDHCP6Server structure
-// from a sequence of bytes. The input data does not include option code and length
-// bytes.
-func ParseOptDHCP4oDHCP6Server(data []byte) (*OptDHCP4oDHCP6Server, error) {
-	var opt OptDHCP4oDHCP6Server
+// FromBytes builds an OptDHCP4oDHCP6Server structure from a sequence of bytes.
+// The input data does not include option code and length bytes.
+func (op *OptDHCP4oDHCP6Server) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
 	for buf.Has(net.IPv6len) {
-		opt.DHCP4oDHCP6Servers = append(opt.DHCP4oDHCP6Servers, buf.CopyN(net.IPv6len))
+		op.DHCP4oDHCP6Servers = append(op.DHCP4oDHCP6Servers, buf.CopyN(net.IPv6len))
 	}
-	return &opt, buf.FinError()
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_dhcpv4_o_dhcpv6_server_test.go b/dhcpv6/option_dhcpv4_o_dhcpv6_server_test.go
index de86594..ecde1dc 100644
--- a/dhcpv6/option_dhcpv4_o_dhcpv6_server_test.go
+++ b/dhcpv6/option_dhcpv4_o_dhcpv6_server_test.go
@@ -1,55 +1,80 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
 	"net"
+	"reflect"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestParseOptDHCP4oDHCP6Server(t *testing.T) {
-	data := []byte{
-		0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe, 0x00, 0x0c, 0xfa, 0xce, 0xb0, 0x0c, 0x00, 0x00, 0x00, 0x35,
-	}
-	expected := []net.IP{
-		net.IP(data),
-	}
-	opt, err := ParseOptDHCP4oDHCP6Server(data)
-	require.NoError(t, err)
-	require.Equal(t, expected, opt.DHCP4oDHCP6Servers)
-	require.Equal(t, OptionDHCP4oDHCP6Server, opt.Code())
-	require.Contains(t, opt.String(), "4o6-servers=[2a03:2880:fffe:c:face:b00c:0:35]", "String() should contain the correct DHCP4-over-DHCP6 server output")
-}
-
-func TestOptDHCP4oDHCP6ServerToBytes(t *testing.T) {
-	ip1 := net.ParseIP("2a03:2880:fffe:c:face:b00c:0:35")
-	ip2 := net.ParseIP("2001:4860:4860::8888")
-	servers := []net.IP{ip1, ip2}
-	expected := append([]byte{}, []byte(ip1)...)
-	expected = append(expected, []byte(ip2)...)
-	opt := OptDHCP4oDHCP6Server{DHCP4oDHCP6Servers: servers}
-	require.Equal(t, expected, opt.ToBytes())
-}
+func TestDHCP4oDHCP6ParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want *OptDHCP4oDHCP6Server
+	}{
+		{
+			buf: []byte{
+				0, 88, // DHCP4oDHCP6 option.
+				0, 32, // length
+				0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe, 0x00, 0x0c, 0xfa, 0xce, 0xb0, 0x0c, 0x00, 0x00, 0x00, 0x35,
+				0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe, 0x00, 0x0c, 0xfa, 0xce, 0xb0, 0x0c, 0x00, 0x00, 0x00, 0x35,
+			},
+			want: &OptDHCP4oDHCP6Server{
+				DHCP4oDHCP6Servers: []net.IP{
+					net.IP{0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe, 0x00, 0x0c, 0xfa, 0xce, 0xb0, 0x0c, 0x00, 0x00, 0x00, 0x35},
+					net.IP{0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe, 0x00, 0x0c, 0xfa, 0xce, 0xb0, 0x0c, 0x00, 0x00, 0x00, 0x35},
+				},
+			},
+		},
+		{
+			buf: []byte{
+				0, 88, // DHCP4oDHCP6 option.
+				0, 6, // length
+				0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe,
+			},
+			err: uio.ErrUnreadBytes,
+		},
+		{
+			buf: []byte{
+				0, 88, // DHCP4oDHCP6 option.
+				0, 0, // length
+			},
+			want: &OptDHCP4oDHCP6Server{},
+		},
+		{
+			buf:  []byte{0, 88, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.DHCP4oDHCP6Server(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("DHCP4oDHCP6Server = %v, want %v", got, tt.want)
+			}
 
-func TestParseOptDHCP4oDHCP6ServerParseNoAddr(t *testing.T) {
-	data := []byte{
+			if tt.want != nil {
+				var m MessageOptions
+				m.Add(tt.want)
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
-	var expected []net.IP
-	opt, err := ParseOptDHCP4oDHCP6Server(data)
-	require.NoError(t, err)
-	require.Equal(t, expected, opt.DHCP4oDHCP6Servers)
-}
-
-func TestOptDHCP4oDHCP6ServerToBytesNoAddr(t *testing.T) {
-	expected := []byte(nil)
-	opt := OptDHCP4oDHCP6Server{}
-	require.Equal(t, expected, opt.ToBytes())
 }
 
-func TestParseOptDHCP4oDHCP6ServerParseBogus(t *testing.T) {
-	data := []byte{
-		0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe, 0x00, 0x0c, // invalid IPv6 address
-	}
-	_, err := ParseOptDHCP4oDHCP6Server(data)
-	require.Error(t, err, "An invalid IPv6 address should return an error")
+func TestParseOptDHCP4oDHCP6Server(t *testing.T) {
+	opt := OptDHCP4oDHCP6Server{DHCP4oDHCP6Servers: []net.IP{net.ParseIP("2a03:2880:fffe:c:face:b00c:0:35")}}
+	require.Contains(t, opt.String(), "[2a03:2880:fffe:c:face:b00c:0:35]", "String() should contain the correct DHCP4-over-DHCP6 server output")
 }
diff --git a/dhcpv6/option_dns.go b/dhcpv6/option_dns.go
index c7bbf83..af9bafe 100644
--- a/dhcpv6/option_dns.go
+++ b/dhcpv6/option_dns.go
@@ -31,17 +31,15 @@ func (op *optDNS) ToBytes() []byte {
 }
 
 func (op *optDNS) String() string {
-	return fmt.Sprintf("DNS: %v", op.NameServers)
+	return fmt.Sprintf("%s: %v", op.Code(), op.NameServers)
 }
 
-// parseOptDNS builds an optDNS structure
-// from a sequence of bytes. The input data does not include option code and length
-// bytes.
-func parseOptDNS(data []byte) (*optDNS, error) {
-	var opt optDNS
+// FromBytes builds an optDNS structure from a sequence of bytes. The input
+// data does not include option code and length bytes.
+func (op *optDNS) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
 	for buf.Has(net.IPv6len) {
-		opt.NameServers = append(opt.NameServers, buf.CopyN(net.IPv6len))
+		op.NameServers = append(op.NameServers, buf.CopyN(net.IPv6len))
 	}
-	return &opt, buf.FinError()
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_dns_test.go b/dhcpv6/option_dns_test.go
index 34f22f7..6f836a3 100644
--- a/dhcpv6/option_dns_test.go
+++ b/dhcpv6/option_dns_test.go
@@ -1,40 +1,85 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
 	"net"
+	"reflect"
 	"testing"
 
-	"github.com/stretchr/testify/require"
+	"github.com/google/go-cmp/cmp"
+	"github.com/u-root/uio/uio"
 )
 
-func TestParseOptDNS(t *testing.T) {
-	data := []byte{
-		0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe, 0x00, 0x0c, 0xfa, 0xce, 0xb0, 0x0c, 0x00, 0x00, 0x00, 0x35,
-	}
-	expected := []net.IP{
-		net.IP(data),
-	}
-	opt, err := parseOptDNS(data)
-	require.NoError(t, err)
-	require.Equal(t, expected, opt.NameServers)
-	require.Equal(t, OptionDNSRecursiveNameServer, opt.Code())
-	require.Contains(t, opt.String(), "DNS: [2a03:2880:fffe:c:face:b00c:0:35]", "String() should contain the correct nameservers output")
-}
-
-func TestOptDNSRecursiveNameServerToBytes(t *testing.T) {
-	ns1 := net.ParseIP("2a03:2880:fffe:c:face:b00c:0:35")
-	ns2 := net.ParseIP("2001:4860:4860::8888")
-	nameservers := []net.IP{ns1, ns2}
-	expected := append([]byte{}, []byte(ns1)...)
-	expected = append(expected, []byte(ns2)...)
-	opt := OptDNS(nameservers...)
-	require.Equal(t, expected, opt.ToBytes())
-}
+func TestDNSParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want []net.IP
+	}{
+		{
+			buf: []byte{
+				0, 23, // DNS
+				0, 16, // length
+				0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe, 0x00, 0x0c, 0xfa, 0xce, 0xb0, 0x0c, 0x00, 0x00, 0x00, 0x35,
+			},
+			want: []net.IP{
+				net.IP{0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe, 0x00, 0x0c, 0xfa, 0xce, 0xb0, 0x0c, 0x00, 0x00, 0x00, 0x35},
+			},
+		},
+		{
+			buf: []byte{
+				0, 23, // DNS
+				0, 32, // length
+				0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe, 0x00, 0x0c, 0xfa, 0xce, 0xb0, 0x0c, 0x00, 0x00, 0x00, 0x35,
+				0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe, 0x00, 0x0c, 0xfa, 0xce, 0xb0, 0x0c, 0x00, 0x00, 0x00, 0x35,
+			},
+			want: []net.IP{
+				net.IP{0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe, 0x00, 0x0c, 0xfa, 0xce, 0xb0, 0x0c, 0x00, 0x00, 0x00, 0x35},
+				net.IP{0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe, 0x00, 0x0c, 0xfa, 0xce, 0xb0, 0x0c, 0x00, 0x00, 0x00, 0x35},
+			},
+		},
+		{
+			buf:  nil,
+			want: nil,
+		},
+		{
+			buf: []byte{
+				0, 23,
+				0, 8,
+				0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe, 0x00, 0x0c, // invalid IPv6 address
+			},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+		{
+			buf:  []byte{0, 23, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+		{
+			buf:  []byte{0, 23, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.DNS(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("DNS = %v, want %v", got, tt.want)
+			}
 
-func TestParseOptDNSBogus(t *testing.T) {
-	data := []byte{
-		0x2a, 0x03, 0x28, 0x80, 0xff, 0xfe, 0x00, 0x0c, // invalid IPv6 address
+			if tt.want != nil {
+				var m MessageOptions
+				m.Add(OptDNS(tt.want...))
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
-	_, err := parseOptDNS(data)
-	require.Error(t, err, "An invalid nameserver IPv6 address should return an error")
 }
diff --git a/dhcpv6/option_domainsearchlist.go b/dhcpv6/option_domainsearchlist.go
index e71a8d5..fd8aa01 100644
--- a/dhcpv6/option_domainsearchlist.go
+++ b/dhcpv6/option_domainsearchlist.go
@@ -25,17 +25,13 @@ func (op *optDomainSearchList) ToBytes() []byte {
 }
 
 func (op *optDomainSearchList) String() string {
-	return fmt.Sprintf("DomainSearchList: %s", op.DomainSearchList)
+	return fmt.Sprintf("%s: %s", op.Code(), op.DomainSearchList)
 }
 
-// ParseOptDomainSearchList builds an OptDomainSearchList structure from a sequence
-// of bytes. The input data does not include option code and length bytes.
-func parseOptDomainSearchList(data []byte) (*optDomainSearchList, error) {
-	var opt optDomainSearchList
+// FromBytes builds an OptDomainSearchList structure from a sequence of bytes.
+// The input data does not include option code and length bytes.
+func (op *optDomainSearchList) FromBytes(data []byte) error {
 	var err error
-	opt.DomainSearchList, err = rfc1035label.FromBytes(data)
-	if err != nil {
-		return nil, err
-	}
-	return &opt, nil
+	op.DomainSearchList, err = rfc1035label.FromBytes(data)
+	return err
 }
diff --git a/dhcpv6/option_domainsearchlist_test.go b/dhcpv6/option_domainsearchlist_test.go
index 433f710..3881523 100644
--- a/dhcpv6/option_domainsearchlist_test.go
+++ b/dhcpv6/option_domainsearchlist_test.go
@@ -1,47 +1,84 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
 	"github.com/insomniacslk/dhcp/rfc1035label"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestParseOptDomainSearchList(t *testing.T) {
-	data := []byte{
-		7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0,
-		6, 's', 'u', 'b', 'n', 'e', 't', 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'o', 'r', 'g', 0,
-	}
-	opt, err := parseOptDomainSearchList(data)
-	require.NoError(t, err)
-	require.Equal(t, OptionDomainSearchList, opt.Code())
-	require.Equal(t, 2, len(opt.DomainSearchList.Labels))
-	require.Equal(t, "example.com", opt.DomainSearchList.Labels[0])
-	require.Equal(t, "subnet.example.org", opt.DomainSearchList.Labels[1])
-	require.Contains(t, opt.String(), "example.com subnet.example.org", "String() should contain the correct domain search output")
-}
-
-func TestOptDomainSearchListToBytes(t *testing.T) {
-	expected := []byte{
-		7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0,
-		6, 's', 'u', 'b', 'n', 'e', 't', 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'o', 'r', 'g', 0,
-	}
-	opt := OptDomainSearchList(
-		&rfc1035label.Labels{
-			Labels: []string{
-				"example.com",
-				"subnet.example.org",
+func TestDomainSearchListParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want *rfc1035label.Labels
+	}{
+		{
+			buf: []byte{
+				0, 24, // Domain Search List option
+				0, 33, // length
+				7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0,
+				6, 's', 'u', 'b', 'n', 'e', 't', 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'o', 'r', 'g', 0,
+			},
+			want: &rfc1035label.Labels{
+				Labels: []string{
+					"example.com",
+					"subnet.example.org",
+				},
 			},
 		},
-	)
-	require.Equal(t, expected, opt.ToBytes())
-}
+		{
+			buf: []byte{
+				0, 24, // Domain Search List option
+				0, 22, // length
+				7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0,
+				6, 's', 'u', 'b', 'n', 'e', 't', 7, 'e', // truncated
+			},
+			err: rfc1035label.ErrBufferTooShort,
+		},
+		{
+			buf:  nil,
+			want: nil,
+		},
+		{
+			buf:  []byte{0, 24, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			got := mo.DomainSearchList()
+			if !cmp.Equal(got, tt.want, cmpopts.IgnoreUnexported(rfc1035label.Labels{})) {
+				t.Errorf("DomainSearchList = %v, want %v", got, tt.want)
+			}
 
-func TestParseOptDomainSearchListInvalidLength(t *testing.T) {
-	data := []byte{
-		7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0,
-		6, 's', 'u', 'b', 'n', 'e', 't', 7, 'e', // truncated
+			if tt.want != nil {
+				var m MessageOptions
+				m.Add(OptDomainSearchList(tt.want))
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
-	_, err := parseOptDomainSearchList(data)
-	require.Error(t, err, "A truncated OptDomainSearchList should return an error")
+}
+
+func TestOptDomainSearchListString(t *testing.T) {
+	opt := OptDomainSearchList(&rfc1035label.Labels{
+		Labels: []string{
+			"example.com",
+			"subnet.example.org",
+		},
+	})
+	require.Contains(t, opt.String(), "example.com subnet.example.org", "String() should contain the correct domain search output")
 }
diff --git a/dhcpv6/option_elapsedtime.go b/dhcpv6/option_elapsedtime.go
index 93e43b9..1441428 100644
--- a/dhcpv6/option_elapsedtime.go
+++ b/dhcpv6/option_elapsedtime.go
@@ -29,14 +29,13 @@ func (op *optElapsedTime) ToBytes() []byte {
 }
 
 func (op *optElapsedTime) String() string {
-	return fmt.Sprintf("ElapsedTime: %s", op.ElapsedTime)
+	return fmt.Sprintf("%s: %s", op.Code(), op.ElapsedTime)
 }
 
-// build an optElapsedTime structure from a sequence of bytes.
+// FromBytes builds an optElapsedTime structure from a sequence of bytes.
 // The input data does not include option code and length bytes.
-func parseOptElapsedTime(data []byte) (*optElapsedTime, error) {
-	var opt optElapsedTime
+func (op *optElapsedTime) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
-	opt.ElapsedTime = time.Duration(buf.Read16()) * 10 * time.Millisecond
-	return &opt, buf.FinError()
+	op.ElapsedTime = time.Duration(buf.Read16()) * 10 * time.Millisecond
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_elapsedtime_test.go b/dhcpv6/option_elapsedtime_test.go
index 91fab45..5e95cda 100644
--- a/dhcpv6/option_elapsedtime_test.go
+++ b/dhcpv6/option_elapsedtime_test.go
@@ -1,43 +1,76 @@
 package dhcpv6
 
 import (
-	"bytes"
+	"errors"
+	"fmt"
+	"reflect"
 	"testing"
 	"time"
 
-	"github.com/stretchr/testify/require"
+	"github.com/google/go-cmp/cmp"
+	"github.com/u-root/uio/uio"
 )
 
-func TestOptElapsedTime(t *testing.T) {
-	opt, err := parseOptElapsedTime([]byte{0xaa, 0xbb})
-	if err != nil {
-		t.Fatal(err)
-	}
-	if elapsedTime := opt.ElapsedTime; elapsedTime != 0xaabb*10*time.Millisecond {
-		t.Fatalf("Invalid elapsed time. Expected 0xaabb, got %v", elapsedTime)
-	}
-}
+func TestElapsedTimeParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want time.Duration
+	}{
+		{
+			buf: []byte{
+				0, 8, // Elapsed Time option
+				0, 2, // length
+				0, 2,
+			},
+			want: 20 * time.Millisecond,
+		},
+		{
+			buf: []byte{
+				0, 8, // Elapsed Time option
+				0, 1, // length
+				0,
+			},
+			err: uio.ErrBufferTooShort,
+		},
+		{
+			buf: []byte{
+				0, 8, // Elapsed Time option
+				0, 3, // length
+				0, 2, 2,
+			},
+			err: uio.ErrUnreadBytes,
+		},
+		{
+			buf: []byte{0, 8, 0},
+			err: uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.ElapsedTime(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("ElapsedTime = %v, want %v", got, tt.want)
+			}
 
-func TestOptElapsedTimeToBytes(t *testing.T) {
-	opt := OptElapsedTime(0)
-	expected := []byte{0, 0}
-	if toBytes := opt.ToBytes(); !bytes.Equal(expected, toBytes) {
-		t.Fatalf("Invalid ToBytes output. Expected %v, got %v", expected, toBytes)
+			if tt.err == nil {
+				var m MessageOptions
+				m.Add(OptElapsedTime(tt.want))
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
 }
 
 func TestOptElapsedTimeString(t *testing.T) {
 	opt := OptElapsedTime(100 * time.Millisecond)
-	expected := "ElapsedTime: 100ms"
+	expected := "Elapsed Time: 100ms"
 	if optString := opt.String(); optString != expected {
 		t.Fatalf("Invalid elapsed time string. Expected %v, got %v", expected, optString)
 	}
 }
-
-func TestOptElapsedTimeParseInvalidOption(t *testing.T) {
-	_, err := parseOptElapsedTime([]byte{0xaa})
-	require.Error(t, err, "A short option should return an error")
-
-	_, err = parseOptElapsedTime([]byte{0xaa, 0xbb, 0xcc})
-	require.Error(t, err, "An option with too many bytes should return an error")
-}
diff --git a/dhcpv6/option_fqdn.go b/dhcpv6/option_fqdn.go
index 62affc7..adb47fb 100644
--- a/dhcpv6/option_fqdn.go
+++ b/dhcpv6/option_fqdn.go
@@ -29,18 +29,17 @@ func (op *OptFQDN) ToBytes() []byte {
 }
 
 func (op *OptFQDN) String() string {
-	return fmt.Sprintf("OptFQDN{flags=%d, domainname=%s}", op.Flags, op.DomainName)
+	return fmt.Sprintf("%s: {Flags=%d DomainName=%s}", op.Code(), op.Flags, op.DomainName)
 }
 
-// ParseOptFQDN deserializes from bytes to build a OptFQDN structure.
-func ParseOptFQDN(data []byte) (*OptFQDN, error) {
-	var opt OptFQDN
+// FromBytes deserializes from bytes to build a OptFQDN structure.
+func (op *OptFQDN) FromBytes(data []byte) error {
 	var err error
 	buf := uio.NewBigEndianBuffer(data)
-	opt.Flags = buf.Read8()
-	opt.DomainName, err = rfc1035label.FromBytes(buf.ReadAll())
+	op.Flags = buf.Read8()
+	op.DomainName, err = rfc1035label.FromBytes(buf.ReadAll())
 	if err != nil {
-		return nil, err
+		return err
 	}
-	return &opt, buf.FinError()
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_fqdn_test.go b/dhcpv6/option_fqdn_test.go
index f698c93..6fca08d 100644
--- a/dhcpv6/option_fqdn_test.go
+++ b/dhcpv6/option_fqdn_test.go
@@ -1,42 +1,88 @@
 package dhcpv6
 
 import (
-	"bytes"
+	"errors"
+	"fmt"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
 	"github.com/insomniacslk/dhcp/rfc1035label"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestParseOptFQDN(t *testing.T) {
-	data := []byte{
-		0, // Flags
-		4, 'c', 'n', 'o', 's', 9, 'l', 'o', 'c', 'a', 'l',
-		'h', 'o', 's', 't', 0,
-	}
-	opt, err := ParseOptFQDN(data)
+func TestFQDNParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want *OptFQDN
+	}{
+		{
+			buf: []byte{
+				0, 39, // FQDN option
+				0, 34, // length
+				0, // flags
+				7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0,
+				6, 's', 'u', 'b', 'n', 'e', 't', 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'o', 'r', 'g', 0,
+			},
+			want: &OptFQDN{
+				Flags: 0,
+				DomainName: &rfc1035label.Labels{
+					Labels: []string{
+						"example.com",
+						"subnet.example.org",
+					},
+				},
+			},
+		},
+		{
+			buf: []byte{
+				0, 39, // FQDN
+				0, 23, // length
+				0, // flags
+				7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0,
+				6, 's', 'u', 'b', 'n', 'e', 't', 7, 'e', // truncated
+			},
+			err: rfc1035label.ErrBufferTooShort,
+		},
+		{
+			buf:  nil,
+			want: nil,
+		},
+		{
+			buf:  []byte{0, 39, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			got := mo.FQDN()
+			if !cmp.Equal(got, tt.want, cmpopts.IgnoreUnexported(rfc1035label.Labels{})) {
+				t.Errorf("FQDN = %v, want %v", got, tt.want)
+			}
 
-	require.NoError(t, err)
-	require.Equal(t, OptionFQDN, opt.Code())
-	require.Equal(t, uint8(0), opt.Flags)
-	require.Equal(t, "cnos.localhost", opt.DomainName.Labels[0])
-	require.Equal(t, "OptFQDN{flags=0, domainname=[cnos.localhost]}", opt.String())
+			if tt.want != nil {
+				var m MessageOptions
+				m.Add(tt.want)
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
+	}
 }
 
-func TestOptFQDNToBytes(t *testing.T) {
-	opt := OptFQDN{
-		Flags:      0,
+func TestOptFQDNString(t *testing.T) {
+	opt := &OptFQDN{
 		DomainName: &rfc1035label.Labels{
 			Labels: []string{"cnos.localhost"},
 		},
 	}
-	want := []byte{
-		0, // Flags
-		4, 'c', 'n', 'o', 's', 9, 'l', 'o', 'c', 'a', 'l',
-		'h', 'o', 's', 't', 0,
-	}
-	b := opt.ToBytes()
-	if !bytes.Equal(b, want) {
-		t.Fatalf("opt.ToBytes()=%v, want %v", b, want)
-	}
+	require.Equal(t, "FQDN: {Flags=0 DomainName=[cnos.localhost]}", opt.String())
 }
diff --git a/dhcpv6/option_iaaddress.go b/dhcpv6/option_iaaddress.go
index a701f8d..bc56254 100644
--- a/dhcpv6/option_iaaddress.go
+++ b/dhcpv6/option_iaaddress.go
@@ -59,26 +59,30 @@ func (op *OptIAAddress) ToBytes() []byte {
 }
 
 func (op *OptIAAddress) String() string {
-	return fmt.Sprintf("IAAddress: IP=%v PreferredLifetime=%v ValidLifetime=%v Options=%v",
-		op.IPv6Addr, op.PreferredLifetime, op.ValidLifetime, op.Options)
+	return fmt.Sprintf("%s: {IP=%v PreferredLifetime=%v ValidLifetime=%v Options=%v}",
+		op.Code(), op.IPv6Addr, op.PreferredLifetime, op.ValidLifetime, op.Options)
 }
 
-// ParseOptIAAddress builds an OptIAAddress structure from a sequence
-// of bytes. The input data does not include option code and length
-// bytes.
-func ParseOptIAAddress(data []byte) (*OptIAAddress, error) {
-	var opt OptIAAddress
+// LongString returns a multi-line string representation of the OptIAAddress data.
+func (op *OptIAAddress) LongString(indent int) string {
+	return fmt.Sprintf("%s: {IP=%v PreferredLifetime=%v ValidLifetime=%v Options=%v}",
+		op.Code(), op.IPv6Addr, op.PreferredLifetime, op.ValidLifetime, op.Options.LongString(indent))
+}
+
+// FromBytes builds an OptIAAddress structure from a sequence of bytes. The
+// input data does not include option code and length bytes.
+func (op *OptIAAddress) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
-	opt.IPv6Addr = net.IP(buf.CopyN(net.IPv6len))
+	op.IPv6Addr = net.IP(buf.CopyN(net.IPv6len))
 
 	var t1, t2 Duration
 	t1.Unmarshal(buf)
 	t2.Unmarshal(buf)
-	opt.PreferredLifetime = t1.Duration
-	opt.ValidLifetime = t2.Duration
+	op.PreferredLifetime = t1.Duration
+	op.ValidLifetime = t2.Duration
 
-	if err := opt.Options.FromBytes(buf.ReadAll()); err != nil {
-		return nil, err
+	if err := op.Options.FromBytes(buf.ReadAll()); err != nil {
+		return err
 	}
-	return &opt, buf.FinError()
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_iaaddress_test.go b/dhcpv6/option_iaaddress_test.go
index 26f1732..d77d3ff 100644
--- a/dhcpv6/option_iaaddress_test.go
+++ b/dhcpv6/option_iaaddress_test.go
@@ -1,74 +1,151 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
 	"net"
+	"reflect"
 	"testing"
 	"time"
 
+	"github.com/google/go-cmp/cmp"
+	"github.com/insomniacslk/dhcp/iana"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestOptIAAddressParse(t *testing.T) {
-	ipaddr := []byte{0x24, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
-	data := append(ipaddr, []byte{
-		0xa, 0xb, 0xc, 0xd, // preferred lifetime
-		0xe, 0xf, 0x1, 0x2, // valid lifetime
-		0, 8, 0, 2, 0xaa, 0xbb, // options
-	}...)
-	opt, err := ParseOptIAAddress(data)
-	require.NoError(t, err)
-	require.Equal(t, net.IP(ipaddr), opt.IPv6Addr)
-	require.Equal(t, 0x0a0b0c0d*time.Second, opt.PreferredLifetime)
-	require.Equal(t, 0x0e0f0102*time.Second, opt.ValidLifetime)
-}
-
-func TestOptIAAddressParseInvalidTooShort(t *testing.T) {
-	data := []byte{
-		0x24, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
-		0xa, 0xb, 0xc, 0xd, // preferred lifetime
-		// truncated here
-	}
-	_, err := ParseOptIAAddress(data)
-	require.Error(t, err)
-}
+func TestIAAddressParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want []*OptIAAddress
+	}{
+		{
+			buf: []byte{
+				0, 5, // IAAddr option
+				0, 0x18, // length
+				0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // IPv6
+				0, 0, 0, 2, // PreferredLifetime
+				0, 0, 0, 4, // ValidLifetime
+			},
+			want: []*OptIAAddress{
+				&OptIAAddress{
+					IPv6Addr:          net.IP{0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0},
+					PreferredLifetime: 2 * time.Second,
+					ValidLifetime:     4 * time.Second,
+					Options:           AddressOptions{Options: Options{}},
+				},
+			},
+		},
+		{
+			buf: []byte{
+				0, 5, // IAAddr option
+				0, 32, // length
+				0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // IPv6
+				0, 0, 0, 2, // PreferredLifetime
+				0, 0, 0, 4, // ValidLifetime
+				0, 13, // option status code
+				0, 4, // length
+				0, 0, // StatusSuccess,
+				'O', 'K', // OK
+			},
+			want: []*OptIAAddress{
+				&OptIAAddress{
+					IPv6Addr:          net.IP{0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0},
+					PreferredLifetime: 2 * time.Second,
+					ValidLifetime:     4 * time.Second,
+					Options: AddressOptions{Options: Options{
+						&OptStatusCode{StatusCode: iana.StatusSuccess, StatusMessage: "OK"},
+					}},
+				},
+			},
+		},
+		{
+			buf: []byte{
+				0, 5, // IAAddr option
+				0, 0x18, // length
+				0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // IPv6
+				0, 0, 0, 2, // PreferredLifetime
+				0, 0, 0, 4, // ValidLifetime
 
-func TestOptIAAddressParseInvalidBrokenOptions(t *testing.T) {
-	data := []byte{
-		0x24, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
-		0xa, 0xb, 0xc, 0xd, // preferred lifetime
-		0xe, 0xf, 0x1, 0x2, // valid lifetime
-		0, 8, 0, 2, 0xaa, // broken options
-	}
-	_, err := ParseOptIAAddress(data)
-	require.Error(t, err)
-}
+				0, 5, // IAAddr option
+				0, 0x18, // length
+				0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // IPv6
+				0, 0, 0, 2, // PreferredLifetime
+				0, 0, 0, 4, // ValidLifetime
+			},
+			want: []*OptIAAddress{
+				&OptIAAddress{
+					IPv6Addr:          net.IP{0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0},
+					PreferredLifetime: 2 * time.Second,
+					ValidLifetime:     4 * time.Second,
+					Options:           AddressOptions{Options: Options{}},
+				},
+				&OptIAAddress{
+					IPv6Addr:          net.IP{0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0},
+					PreferredLifetime: 2 * time.Second,
+					ValidLifetime:     4 * time.Second,
+					Options:           AddressOptions{Options: Options{}},
+				},
+			},
+		},
+		{
+			buf:  []byte{0, 3, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+		{
+			buf: []byte{
+				0, 5, // IAAddr option code
+				0, 4, // length
+				0, 0, 0, 1, // truncated IP
+			},
+			want: nil,
+			err:  uio.ErrBufferTooShort,
+		},
+		{
+			buf: []byte{
+				0, 5, // IAAddr option
+				0, 28, // length
+				0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // IPv6
+				0, 0, 0, 2, // PreferredLifetime
+				0, 0, 0, 4, // ValidLifetime
+				0, 13, // option status code
+				0, 1, // length
+				// option too short
+			},
+			err: uio.ErrBufferTooShort,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var io IdentityOptions
+			if err := io.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := io.Addresses(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("Addresses = %v, want %v", got, tt.want)
+			}
 
-func TestOptIAAddressToBytesDefault(t *testing.T) {
-	want := []byte{
-		0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // IP
-		0, 0, 0, 0, // preferred lifetime
-		0, 0, 0, 0, // valid lifetime
-	}
-	opt := OptIAAddress{}
-	require.Equal(t, opt.ToBytes(), want)
-}
+			var wantOneAddr *OptIAAddress
+			if len(tt.want) >= 1 {
+				wantOneAddr = tt.want[0]
+			}
+			if got := io.OneAddress(); !reflect.DeepEqual(got, wantOneAddr) {
+				t.Errorf("OneAddress = %v, want %v", got, wantOneAddr)
+			}
 
-func TestOptIAAddressToBytes(t *testing.T) {
-	ipBytes := []byte{0x24, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
-	expected := append(ipBytes, []byte{
-		0xa, 0xb, 0xc, 0xd, // preferred lifetime
-		0xe, 0xf, 0x1, 0x2, // valid lifetime
-		0, 8, 0, 2, 0x00, 0x01, // options
-	}...)
-	opt := OptIAAddress{
-		IPv6Addr:          net.IP(ipBytes),
-		PreferredLifetime: 0x0a0b0c0d * time.Second,
-		ValidLifetime:     0x0e0f0102 * time.Second,
-		Options: AddressOptions{[]Option{
-			OptElapsedTime(10 * time.Millisecond),
-		}},
+			if len(tt.want) >= 1 {
+				var b IdentityOptions
+				for _, iana := range tt.want {
+					b.Add(iana)
+				}
+				got := b.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
-	require.Equal(t, expected, opt.ToBytes())
 }
 
 func TestOptIAAddressString(t *testing.T) {
@@ -78,7 +155,8 @@ func TestOptIAAddressString(t *testing.T) {
 		0x00, 0x00, 0x00, 50, // valid lifetime
 		0, 8, 0, 2, 0xaa, 0xbb, // options
 	}...)
-	opt, err := ParseOptIAAddress(data)
+	var opt OptIAAddress
+	err := opt.FromBytes(data)
 	require.NoError(t, err)
 
 	str := opt.String()
diff --git a/dhcpv6/option_iapd.go b/dhcpv6/option_iapd.go
index 00bc424..fbf40b4 100644
--- a/dhcpv6/option_iapd.go
+++ b/dhcpv6/option_iapd.go
@@ -18,6 +18,9 @@ type PDOptions struct {
 // Prefixes are the prefixes associated with this delegation.
 func (po PDOptions) Prefixes() []*OptIAPrefix {
 	opts := po.Options.Get(OptionIAPrefix)
+	if len(opts) == 0 {
+		return nil
+	}
 	pre := make([]*OptIAPrefix, 0, len(opts))
 	for _, o := range opts {
 		if iap, ok := o.(*OptIAPrefix); ok {
@@ -70,25 +73,29 @@ func (op *OptIAPD) ToBytes() []byte {
 
 // String returns a string representation of the OptIAPD data
 func (op *OptIAPD) String() string {
-	return fmt.Sprintf("IAPD: {IAID=%v, t1=%v, t2=%v, Options=[%v]}",
-		op.IaId, op.T1, op.T2, op.Options)
+	return fmt.Sprintf("%s: {IAID=%#x T1=%v T2=%v Options=%v}",
+		op.Code(), op.IaId, op.T1, op.T2, op.Options)
+}
+
+// LongString returns a multi-line string representation of the OptIAPD data
+func (op *OptIAPD) LongString(indentSpace int) string {
+	return fmt.Sprintf("%s: IAID=%#x T1=%v T2=%v Options=%v", op.Code(), op.IaId, op.T1, op.T2, op.Options.LongString(indentSpace))
 }
 
-// ParseOptIAPD builds an OptIAPD structure from a sequence of bytes.
-// The input data does not include option code and length bytes.
-func ParseOptIAPD(data []byte) (*OptIAPD, error) {
-	var opt OptIAPD
+// FromBytes builds an OptIAPD structure from a sequence of bytes. The input
+// data does not include option code and length bytes.
+func (op *OptIAPD) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
-	buf.ReadBytes(opt.IaId[:])
+	buf.ReadBytes(op.IaId[:])
 
 	var t1, t2 Duration
 	t1.Unmarshal(buf)
 	t2.Unmarshal(buf)
-	opt.T1 = t1.Duration
-	opt.T2 = t2.Duration
+	op.T1 = t1.Duration
+	op.T2 = t2.Duration
 
-	if err := opt.Options.FromBytes(buf.ReadAll()); err != nil {
-		return nil, err
+	if err := op.Options.FromBytes(buf.ReadAll()); err != nil {
+		return err
 	}
-	return &opt, buf.FinError()
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_iapd_test.go b/dhcpv6/option_iapd_test.go
index b2b9d3f..37a835a 100644
--- a/dhcpv6/option_iapd_test.go
+++ b/dhcpv6/option_iapd_test.go
@@ -1,84 +1,172 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
 	"net"
+	"reflect"
 	"testing"
 	"time"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestOptIAPDParseOptIAPD(t *testing.T) {
-	data := []byte{
-		1, 0, 0, 0, // IAID
-		0, 0, 0, 1, // T1
-		0, 0, 0, 2, // T2
-		0, 26, 0, 25, // 26 = IAPrefix Option, 25 = length
-		0xaa, 0xbb, 0xcc, 0xdd, // IAPrefix preferredLifetime
-		0xee, 0xff, 0x00, 0x11, // IAPrefix validLifetime
-		36,                                             // IAPrefix prefixLength
-		0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, // IAPrefix ipv6Prefix
-	}
-	opt, err := ParseOptIAPD(data)
-	require.NoError(t, err)
-	require.Equal(t, OptionIAPD, opt.Code())
-	require.Equal(t, [4]byte{1, 0, 0, 0}, opt.IaId)
-	require.Equal(t, time.Second, opt.T1)
-	require.Equal(t, 2*time.Second, opt.T2)
-}
-
-func TestOptIAPDParseOptIAPDInvalidLength(t *testing.T) {
-	data := []byte{
-		1, 0, 0, 0, // IAID
-		0, 0, 0, 1, // T1
-		// truncated from here
-	}
-	_, err := ParseOptIAPD(data)
-	require.Error(t, err)
-}
-
-func TestOptIAPDParseOptIAPDInvalidOptions(t *testing.T) {
-	data := []byte{
-		1, 0, 0, 0, // IAID
-		0, 0, 0, 1, // T1
-		0, 0, 0, 2, // T2
-		0, 26, 0, 25, // 26 = IAPrefix Option, 25 = length
-		0xaa, 0xbb, 0xcc, 0xdd, // IAPrefix preferredLifetime
-		0xee, 0xff, 0x00, 0x11, // IAPrefix validLifetime
-		36,                                          // IAPrefix prefixLength
-		0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // IAPrefix ipv6Prefix missing last byte
-	}
-	_, err := ParseOptIAPD(data)
-	require.Error(t, err)
-}
+func TestIAPDParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want []*OptIAPD
+	}{
+		{
+			buf: []byte{
+				0, 25, // IAPD option code
+				0, 41, // length
+				1, 0, 0, 0, // IAID
+				0, 0, 0, 1, // T1
+				0, 0, 0, 2, // T2
+				0, 26, 0, 25, // 26 = IAPrefix Option, 25 = length
+				0, 0, 0, 2, // IAPrefix preferredLifetime
+				0, 0, 0, 4, // IAPrefix validLifetime
+				36,                                             // IAPrefix prefixLength
+				0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, // IAPrefix ipv6Prefix
+			},
+			want: []*OptIAPD{
+				&OptIAPD{
+					IaId: [4]byte{1, 0, 0, 0},
+					T1:   1 * time.Second,
+					T2:   2 * time.Second,
+					Options: PDOptions{Options: Options{&OptIAPrefix{
+						PreferredLifetime: 2 * time.Second,
+						ValidLifetime:     4 * time.Second,
+						Prefix: &net.IPNet{
+							Mask: net.CIDRMask(36, 128),
+							IP:   net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1},
+						},
+						Options: PrefixOptions{Options: Options{}},
+					}}},
+				},
+			},
+		},
+		{
+			buf: []byte{
+				0, 25, // IAPD option code
+				0, 41, // length
+				1, 0, 0, 0, // IAID
+				0, 0, 0, 1, // T1
+				0, 0, 0, 2, // T2
+				0, 26, 0, 25, // 26 = IAPrefix Option, 25 = length
+				0, 0, 0, 2, // IAPrefix preferredLifetime
+				0, 0, 0, 4, // IAPrefix validLifetime
+				36,                                             // IAPrefix prefixLength
+				0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, // IAPrefix ipv6Prefix
 
-func TestOptIAPDToBytes(t *testing.T) {
-	oaddr := OptIAPrefix{
-		PreferredLifetime: 0xaabbccdd * time.Second,
-		ValidLifetime:     0xeeff0011 * time.Second,
-		Prefix: &net.IPNet{
-			Mask: net.CIDRMask(36, 128),
-			IP:   net.IPv6loopback,
+				0, 25, // IAPD option code
+				0, 41, // length
+				1, 2, 3, 4, // IAID
+				0, 0, 0, 5, // T1
+				0, 0, 0, 6, // T2
+				0, 26, 0, 25, // 26 = IAPrefix Option, 25 = length
+				0, 0, 0, 2, // IAPrefix preferredLifetime
+				0, 0, 0, 4, // IAPrefix validLifetime
+				36,                                             // IAPrefix prefixLength
+				0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, // IAPrefix ipv6Prefix
+			},
+			want: []*OptIAPD{
+				&OptIAPD{
+					IaId: [4]byte{1, 0, 0, 0},
+					T1:   1 * time.Second,
+					T2:   2 * time.Second,
+					Options: PDOptions{Options: Options{&OptIAPrefix{
+						PreferredLifetime: 2 * time.Second,
+						ValidLifetime:     4 * time.Second,
+						Prefix: &net.IPNet{
+							Mask: net.CIDRMask(36, 128),
+							IP:   net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1},
+						},
+						Options: PrefixOptions{Options: Options{}},
+					}}},
+				},
+				&OptIAPD{
+					IaId: [4]byte{1, 2, 3, 4},
+					T1:   5 * time.Second,
+					T2:   6 * time.Second,
+					Options: PDOptions{Options: Options{&OptIAPrefix{
+						PreferredLifetime: 2 * time.Second,
+						ValidLifetime:     4 * time.Second,
+						Prefix: &net.IPNet{
+							Mask: net.CIDRMask(36, 128),
+							IP:   net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1},
+						},
+						Options: PrefixOptions{Options: Options{}},
+					}}},
+				},
+			},
 		},
-	}
-	opt := OptIAPD{
-		IaId:    [4]byte{1, 2, 3, 4},
-		T1:      12345 * time.Second,
-		T2:      54321 * time.Second,
-		Options: PDOptions{[]Option{&oaddr}},
-	}
+		{
+			buf:  nil,
+			want: nil,
+		},
+		{
+			buf:  []byte{0, 25, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+		{
+			buf: []byte{
+				0, 25, // IAPD option code
+				0, 8, // length
+				1, 0, 0, 0, // IAID
+				0, 0, 0, 1, // T1
+				// truncated from here
+			},
+			want: nil,
+			err:  uio.ErrBufferTooShort,
+		},
+		{
+			buf: []byte{
+				0, 25, // IAPD option code
+				0, 36, // length
+				1, 0, 0, 0, // IAID
+				0, 0, 0, 1, // T1
+				0, 0, 0, 2, // T2
+				0, 26, 0, 4, // 26 = IAPrefix Option, 4 = length
+				0, 0, 0, 2, // IAPrefix preferredLifetime
+				// Missing stuff
+			},
+			want: nil,
+			err:  uio.ErrBufferTooShort,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.IAPD(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("IAPD = %v, want %v", got, tt.want)
+			}
+			var wantOne *OptIAPD
+			if len(tt.want) >= 1 {
+				wantOne = tt.want[0]
+			}
+			if got := mo.OneIAPD(); !reflect.DeepEqual(got, wantOne) {
+				t.Errorf("OneIAPD = %v, want %v", got, wantOne)
+			}
 
-	expected := []byte{
-		1, 2, 3, 4, // IA ID
-		0, 0, 0x30, 0x39, // T1 = 12345
-		0, 0, 0xd4, 0x31, // T2 = 54321
-		0, 26, 0, 25, // 26 = IAPrefix Option, 25 = length
-		0xaa, 0xbb, 0xcc, 0xdd, // IAPrefix preferredLifetime
-		0xee, 0xff, 0x00, 0x11, // IAPrefix validLifetime
-		36,                                             // IAPrefix prefixLength
-		0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, // IAPrefix ipv6Prefix
+			if len(tt.want) >= 1 {
+				var b MessageOptions
+				for _, iana := range tt.want {
+					b.Add(iana)
+				}
+				got := b.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
-	require.Equal(t, expected, opt.ToBytes())
 }
 
 func TestOptIAPDString(t *testing.T) {
@@ -92,23 +180,24 @@ func TestOptIAPDString(t *testing.T) {
 		36,                                             // IAPrefix prefixLength
 		0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, // IAPrefix ipv6Prefix
 	}
-	opt, err := ParseOptIAPD(data)
+	var opt OptIAPD
+	err := opt.FromBytes(data)
 	require.NoError(t, err)
 
 	str := opt.String()
 	require.Contains(
 		t, str,
-		"IAID=[1 0 0 0]",
+		"IAID=0x01000000",
 		"String() should return the IAID",
 	)
 	require.Contains(
 		t, str,
-		"t1=1s, t2=2s",
+		"T1=1s T2=2s",
 		"String() should return the T1/T2 options",
 	)
 	require.Contains(
 		t, str,
-		"Options=[",
+		"Options={",
 		"String() should return a list of options",
 	)
 }
diff --git a/dhcpv6/option_iaprefix.go b/dhcpv6/option_iaprefix.go
index 6edacf3..f7d3e76 100644
--- a/dhcpv6/option_iaprefix.go
+++ b/dhcpv6/option_iaprefix.go
@@ -70,35 +70,34 @@ func (op *OptIAPrefix) ToBytes() []byte {
 }
 
 func (op *OptIAPrefix) String() string {
-	return fmt.Sprintf("IAPrefix: {PreferredLifetime=%v, ValidLifetime=%v, Prefix=%s, Options=%v}",
-		op.PreferredLifetime, op.ValidLifetime, op.Prefix, op.Options)
+	return fmt.Sprintf("%s: {PreferredLifetime=%v, ValidLifetime=%v, Prefix=%s, Options=%v}",
+		op.Code(), op.PreferredLifetime, op.ValidLifetime, op.Prefix, op.Options)
 }
 
-// ParseOptIAPrefix an OptIAPrefix structure from a sequence of bytes. The
-// input data does not include option code and length bytes.
-func ParseOptIAPrefix(data []byte) (*OptIAPrefix, error) {
+// FromBytes an OptIAPrefix structure from a sequence of bytes. The input data
+// does not include option code and length bytes.
+func (op *OptIAPrefix) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
-	var opt OptIAPrefix
 
 	var t1, t2 Duration
 	t1.Unmarshal(buf)
 	t2.Unmarshal(buf)
-	opt.PreferredLifetime = t1.Duration
-	opt.ValidLifetime = t2.Duration
+	op.PreferredLifetime = t1.Duration
+	op.ValidLifetime = t2.Duration
 
 	length := buf.Read8()
 	ip := net.IP(buf.CopyN(net.IPv6len))
 
 	if length == 0 {
-		opt.Prefix = nil
+		op.Prefix = nil
 	} else {
-		opt.Prefix = &net.IPNet{
+		op.Prefix = &net.IPNet{
 			Mask: net.CIDRMask(int(length), 128),
 			IP:   ip,
 		}
 	}
-	if err := opt.Options.FromBytes(buf.ReadAll()); err != nil {
-		return nil, err
+	if err := op.Options.FromBytes(buf.ReadAll()); err != nil {
+		return err
 	}
-	return &opt, buf.FinError()
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_iaprefix_test.go b/dhcpv6/option_iaprefix_test.go
index 27d0c95..de1960c 100644
--- a/dhcpv6/option_iaprefix_test.go
+++ b/dhcpv6/option_iaprefix_test.go
@@ -1,86 +1,150 @@
 package dhcpv6
 
 import (
-	"bytes"
+	"errors"
+	"fmt"
 	"net"
 	"reflect"
 	"testing"
 	"time"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestOptIAPrefix(t *testing.T) {
-	buf := []byte{
-		0xaa, 0xbb, 0xcc, 0xdd, // preferredLifetime
-		0xee, 0xff, 0x00, 0x11, // validLifetime
-		36,                                             // prefixLength
-		0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, // ipv6Prefix
-	}
-	opt, err := ParseOptIAPrefix(buf)
-	if err != nil {
-		t.Fatal(err)
-	}
-	want := &OptIAPrefix{
-		PreferredLifetime: 0xaabbccdd * time.Second,
-		ValidLifetime:     0xeeff0011 * time.Second,
-		Prefix: &net.IPNet{
-			Mask: net.CIDRMask(36, 128),
-			IP:   net.IPv6loopback,
+func TestIAPrefixParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want []*OptIAPrefix
+	}{
+		{
+			buf: []byte{
+				0, 26, // IAPrefix option code
+				0, 25, // length
+				0, 0, 0, 1, // PreferredLifetime
+				0, 0, 0, 2, // ValidLifetime
+				16,                                                                        // prefix length
+				0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // v6-prefix
+			},
+			want: []*OptIAPrefix{
+				&OptIAPrefix{
+					PreferredLifetime: 1 * time.Second,
+					ValidLifetime:     2 * time.Second,
+					Prefix: &net.IPNet{
+						IP:   net.IP{0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0},
+						Mask: net.CIDRMask(16, 128),
+					},
+					Options: PrefixOptions{Options: Options{}},
+				},
+			},
 		},
-		Options: PrefixOptions{[]Option{}},
-	}
-	if !reflect.DeepEqual(want, opt) {
-		t.Errorf("parseIAPrefix = %v, want %v", opt, want)
-	}
-}
-
-func TestOptIAPrefixToBytes(t *testing.T) {
-	buf := []byte{
-		0xaa, 0xbb, 0xcc, 0xdd, // preferredLifetime
-		0xee, 0xff, 0x00, 0x11, // validLifetime
-		36,                                             // prefixLength
-		0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // ipv6Prefix
-		0, 8, 0, 2, 0x00, 0x01, // options
-	}
-	opt := OptIAPrefix{
-		PreferredLifetime: 0xaabbccdd * time.Second,
-		ValidLifetime:     0xeeff0011 * time.Second,
-		Prefix: &net.IPNet{
-			Mask: net.CIDRMask(36, 128),
-			IP:   net.IPv6zero,
+		{
+			buf: []byte{
+				0, 26, // IAPrefix option code
+				0, 25, // length
+				0, 0, 0, 1, // PreferredLifetime
+				0, 0, 0, 2, // ValidLifetime
+				0,                                              // prefix length
+				0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // v6-prefix
+			},
+			want: []*OptIAPrefix{
+				&OptIAPrefix{
+					PreferredLifetime: 1 * time.Second,
+					ValidLifetime:     2 * time.Second,
+					Prefix:            nil,
+					Options:           PrefixOptions{Options: Options{}},
+				},
+			},
 		},
-		Options: PrefixOptions{[]Option{OptElapsedTime(10 * time.Millisecond)}},
-	}
-	toBytes := opt.ToBytes()
-	if !bytes.Equal(toBytes, buf) {
-		t.Fatalf("Invalid ToBytes result. Expected %v, got %v", buf, toBytes)
-	}
-}
+		{
+			buf: []byte{
+				0, 26, // IAPrefix option code
+				0, 25, // length
+				0, 0, 0, 1, // PreferredLifetime
+				0, 0, 0, 2, // ValidLifetime
+				16,                                                                        // prefix length
+				0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // v6-prefix
 
-func TestOptIAPrefixToBytesDefault(t *testing.T) {
-	buf := []byte{
-		0, 0, 0, 0, // preferredLifetime
-		0, 0, 0, 0, // validLifetime
-		0,                                              // prefixLength
-		0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // ipv6Prefix
-	}
-	opt := OptIAPrefix{}
-	toBytes := opt.ToBytes()
-	if !bytes.Equal(toBytes, buf) {
-		t.Fatalf("Invalid ToBytes result. Expected %v, got %v", buf, toBytes)
-	}
-}
+				0, 26, // IAPrefix option code
+				0, 25, // length
+				0, 0, 0, 15, // PreferredLifetime
+				0, 0, 0, 14, // ValidLifetime
+				32,                                                                        // prefix length
+				0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // v6-prefix
+			},
+			want: []*OptIAPrefix{
+				&OptIAPrefix{
+					PreferredLifetime: 1 * time.Second,
+					ValidLifetime:     2 * time.Second,
+					Prefix: &net.IPNet{
+						IP:   net.IP{0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0},
+						Mask: net.CIDRMask(16, 128),
+					},
+					Options: PrefixOptions{Options: Options{}},
+				},
+				&OptIAPrefix{
+					PreferredLifetime: 15 * time.Second,
+					ValidLifetime:     14 * time.Second,
+					Prefix: &net.IPNet{
+						IP:   net.IP{0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0},
+						Mask: net.CIDRMask(32, 128),
+					},
+					Options: PrefixOptions{Options: Options{}},
+				},
+			},
+		},
+		{
+			buf:  []byte{0, 3, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+		{
+			buf: []byte{
+				0, 26, // IAPrefix option code
+				0, 8, // length
+				1, 0, 0, 0, // T1
+				0, 0, 0, 1, // T2
+				// truncated from here
+			},
+			want: nil,
+			err:  uio.ErrBufferTooShort,
+		},
+		{
+			buf: []byte{
+				0, 26, // IANA option code
+				0, 26, // length
+				0, 0, 0, 1, // T1
+				0, 0, 0, 2, // T2
+				8,
+				0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // IPv6
+				0, // malformed options
+			},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo PDOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.Prefixes(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("Prefixes = %#v, want %#v", got, tt.want)
+			}
 
-func TestOptIAPrefixParseInvalidTooShort(t *testing.T) {
-	buf := []byte{
-		0xaa, 0xbb, 0xcc, 0xdd, // preferredLifetime
-		0xee, 0xff, 0x00, 0x11, // validLifetime
-		36,                  // prefixLength
-		0, 0, 0, 0, 0, 0, 0, // truncated ipv6Prefix
-	}
-	if opt, err := ParseOptIAPrefix(buf); err == nil {
-		t.Fatalf("ParseOptIAPrefix: Expected error on truncated option, got %v", opt)
+			if len(tt.want) >= 1 {
+				var b PDOptions
+				for _, iana := range tt.want {
+					b.Add(iana)
+				}
+				got := b.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
 }
 
@@ -91,7 +155,8 @@ func TestOptIAPrefixString(t *testing.T) {
 		36,                                                         // prefixLength
 		0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // ipv6Prefix
 	}
-	opt, err := ParseOptIAPrefix(buf)
+	var opt OptIAPrefix
+	err := opt.FromBytes(buf)
 	require.NoError(t, err)
 
 	str := opt.String()
diff --git a/dhcpv6/option_informationrefreshtime.go b/dhcpv6/option_informationrefreshtime.go
index 20d8b5c..e0ba43c 100644
--- a/dhcpv6/option_informationrefreshtime.go
+++ b/dhcpv6/option_informationrefreshtime.go
@@ -32,17 +32,16 @@ func (op *optInformationRefreshTime) ToBytes() []byte {
 }
 
 func (op *optInformationRefreshTime) String() string {
-	return fmt.Sprintf("InformationRefreshTime: %v", op.InformationRefreshtime)
+	return fmt.Sprintf("%s: %v", op.Code(), op.InformationRefreshtime)
 }
 
-// parseOptInformationRefreshTime builds an optInformationRefreshTime structure from a sequence
-// of bytes. The input data does not include option code and length bytes.
-func parseOptInformationRefreshTime(data []byte) (*optInformationRefreshTime, error) {
-	var opt optInformationRefreshTime
+// FromBytes builds an optInformationRefreshTime structure from a sequence of
+// bytes. The input data does not include option code and length bytes.
+func (op *optInformationRefreshTime) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
 
 	var irt Duration
 	irt.Unmarshal(buf)
-	opt.InformationRefreshtime = irt.Duration
-	return &opt, buf.FinError()
+	op.InformationRefreshtime = irt.Duration
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_informationrefreshtime_test.go b/dhcpv6/option_informationrefreshtime_test.go
index 68f0855..1345c28 100644
--- a/dhcpv6/option_informationrefreshtime_test.go
+++ b/dhcpv6/option_informationrefreshtime_test.go
@@ -2,16 +2,75 @@ package dhcpv6
 
 import (
 	"bytes"
+	"errors"
+	"fmt"
+	"reflect"
 	"testing"
 	"time"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/u-root/uio/uio"
 )
 
+func TestInformationRefreshTimeParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want time.Duration
+	}{
+		{
+			buf: []byte{
+				0, 32, // IRT option
+				0, 4, // length
+				0, 0, 0, 3,
+			},
+			want: 3 * time.Second,
+		},
+		{
+			buf: []byte{
+				0, 32, // IRT option
+				0, 6, // length
+				0, 0, 0, 3, 0, 0,
+			},
+			err: uio.ErrUnreadBytes,
+		},
+		{
+			buf: []byte{0, 32, 0, 1, 0},
+			err: uio.ErrBufferTooShort,
+		},
+		{
+			buf: []byte{0, 32, 0},
+			err: uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.InformationRefreshTime(0); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("InformationRefreshTime = %v, want %v", got, tt.want)
+			}
+
+			if tt.err == nil {
+				var m MessageOptions
+				m.Add(OptInformationRefreshTime(tt.want))
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
+	}
+}
+
 func TestOptInformationRefreshTime(t *testing.T) {
-	opt, err := parseOptInformationRefreshTime([]byte{0xaa, 0xbb, 0xcc, 0xdd})
+	var opt optInformationRefreshTime
+	err := opt.FromBytes([]byte{0xaa, 0xbb, 0xcc, 0xdd})
 	if err != nil {
 		t.Fatal(err)
 	}
-	if informationRefreshTime := opt.InformationRefreshtime; informationRefreshTime != time.Duration(0xaabbccdd) * time.Second {
+	if informationRefreshTime := opt.InformationRefreshtime; informationRefreshTime != time.Duration(0xaabbccdd)*time.Second {
 		t.Fatalf("Invalid information refresh time. Expected 0xaabb, got %v", informationRefreshTime)
 	}
 }
@@ -26,7 +85,7 @@ func TestOptInformationRefreshTimeToBytes(t *testing.T) {
 
 func TestOptInformationRefreshTimeString(t *testing.T) {
 	opt := OptInformationRefreshTime(3600 * time.Second)
-	expected := "InformationRefreshTime: 1h0m0s"
+	expected := "Information Refresh Time: 1h0m0s"
 	if optString := opt.String(); optString != expected {
 		t.Fatalf("Invalid elapsed time string. Expected %v, got %v", expected, optString)
 	}
diff --git a/dhcpv6/option_interfaceid.go b/dhcpv6/option_interfaceid.go
index ce85714..a6debba 100644
--- a/dhcpv6/option_interfaceid.go
+++ b/dhcpv6/option_interfaceid.go
@@ -23,13 +23,12 @@ func (op *optInterfaceID) ToBytes() []byte {
 }
 
 func (op *optInterfaceID) String() string {
-	return fmt.Sprintf("InterfaceID: %v", op.ID)
+	return fmt.Sprintf("%s: %v", op.Code(), op.ID)
 }
 
-// build an optInterfaceID structure from a sequence of bytes.
-// The input data does not include option code and length bytes.
-func parseOptInterfaceID(data []byte) (*optInterfaceID, error) {
-	var opt optInterfaceID
-	opt.ID = append([]byte(nil), data...)
-	return &opt, nil
+// FromBytes builds an optInterfaceID structure from a sequence of bytes. The
+// input data does not include option code and length bytes.
+func (op *optInterfaceID) FromBytes(data []byte) error {
+	op.ID = append([]byte(nil), data...)
+	return nil
 }
diff --git a/dhcpv6/option_interfaceid_test.go b/dhcpv6/option_interfaceid_test.go
index 45c1799..640c06a 100644
--- a/dhcpv6/option_interfaceid_test.go
+++ b/dhcpv6/option_interfaceid_test.go
@@ -1,30 +1,64 @@
 package dhcpv6
 
 import (
-	"bytes"
+	"errors"
+	"fmt"
+	"reflect"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestParseOptInterfaceID(t *testing.T) {
-	expected := []byte("DSLAM01 eth2/1/01/21")
-	opt, err := parseOptInterfaceID(expected)
-	if err != nil {
-		t.Fatal(err)
-	}
-	if url := opt.ID; !bytes.Equal(url, expected) {
-		t.Fatalf("Invalid Interface ID. Expected %v, got %v", expected, url)
+func TestInterfaceIDParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want []byte
+	}{
+		{
+			buf: []byte{
+				0, 18, // Interface ID
+				0, 4, // length
+				'S', 'L', 'A', 'M',
+			},
+			want: []byte("SLAM"),
+		},
+		{
+			buf: []byte{
+				0, 18,
+				0, 0,
+			},
+		},
+		{
+			buf: []byte{0, 18, 0},
+			err: uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var ro RelayOptions
+			if err := ro.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := ro.InterfaceID(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("InterfaceID = %v, want %v", got, tt.want)
+			}
+
+			if tt.want != nil {
+				var m RelayOptions
+				m.Add(OptInterfaceID(tt.want))
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
 }
 
 func TestOptInterfaceID(t *testing.T) {
-	want := []byte("DSLAM01 eth2/1/01/21")
-	opt := OptInterfaceID(want)
-	if got := opt.ToBytes(); !bytes.Equal(got, want) {
-		t.Fatalf("%s.ToBytes() = %v, want %v", opt, got, want)
-	}
-
+	opt := OptInterfaceID([]byte("DSLAM01 eth2/1/01/21"))
 	require.Contains(
 		t,
 		opt.String(),
diff --git a/dhcpv6/option_nii.go b/dhcpv6/option_nii.go
index fc9f579..c9953ac 100644
--- a/dhcpv6/option_nii.go
+++ b/dhcpv6/option_nii.go
@@ -59,7 +59,7 @@ func (op *OptNetworkInterfaceID) ToBytes() []byte {
 }
 
 func (op *OptNetworkInterfaceID) String() string {
-	return fmt.Sprintf("NetworkInterfaceID: %s (Revision %d.%d)", op.Typ, op.Major, op.Minor)
+	return fmt.Sprintf("%s: %s (Revision %d.%d)", op.Code(), op.Typ, op.Major, op.Minor)
 }
 
 // FromBytes builds an OptNetworkInterfaceID structure from a sequence of
diff --git a/dhcpv6/option_nontemporaryaddress.go b/dhcpv6/option_nontemporaryaddress.go
index 6e5ddee..2734931 100644
--- a/dhcpv6/option_nontemporaryaddress.go
+++ b/dhcpv6/option_nontemporaryaddress.go
@@ -93,25 +93,29 @@ func (op *OptIANA) ToBytes() []byte {
 }
 
 func (op *OptIANA) String() string {
-	return fmt.Sprintf("IANA: {IAID=%v, t1=%v, t2=%v, options=%v}",
-		op.IaId, op.T1, op.T2, op.Options)
+	return fmt.Sprintf("%s: {IAID=%#x T1=%v T2=%v Options=%v}",
+		op.Code(), op.IaId, op.T1, op.T2, op.Options)
 }
 
-// ParseOptIANA builds an OptIANA structure from a sequence of bytes.  The
+// LongString returns a multi-line string representation of IANA data.
+func (op *OptIANA) LongString(indentSpace int) string {
+	return fmt.Sprintf("%s: IAID=%#x T1=%s T2=%s Options=%s", op.Code(), op.IaId, op.T1, op.T2, op.Options.LongString(indentSpace))
+}
+
+// FromBytes builds an OptIANA structure from a sequence of bytes.  The
 // input data does not include option code and length bytes.
-func ParseOptIANA(data []byte) (*OptIANA, error) {
-	var opt OptIANA
+func (op *OptIANA) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
-	buf.ReadBytes(opt.IaId[:])
+	buf.ReadBytes(op.IaId[:])
 
 	var t1, t2 Duration
 	t1.Unmarshal(buf)
 	t2.Unmarshal(buf)
-	opt.T1 = t1.Duration
-	opt.T2 = t2.Duration
+	op.T1 = t1.Duration
+	op.T2 = t2.Duration
 
-	if err := opt.Options.FromBytes(buf.ReadAll()); err != nil {
-		return nil, err
+	if err := op.Options.FromBytes(buf.ReadAll()); err != nil {
+		return err
 	}
-	return &opt, buf.FinError()
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_nontemporaryaddress_test.go b/dhcpv6/option_nontemporaryaddress_test.go
index 50cb11b..ed9cebd 100644
--- a/dhcpv6/option_nontemporaryaddress_test.go
+++ b/dhcpv6/option_nontemporaryaddress_test.go
@@ -1,44 +1,158 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
 	"net"
+	"reflect"
 	"testing"
 	"time"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestOptIANAParseOptIANA(t *testing.T) {
-	data := []byte{
-		1, 0, 0, 0, // IAID
-		0, 0, 0, 1, // T1
-		0, 0, 0, 2, // T2
-		0, 5, 0, 0x18, 0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, 0, 0, 0xb2, 0x7a, 0, 0, 0xc0, 0x8a, // options
-	}
-	opt, err := ParseOptIANA(data)
-	require.NoError(t, err)
-	require.Equal(t, OptionIANA, opt.Code())
-}
+func TestIANAParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want []*OptIANA
+	}{
+		{
+			buf: []byte{
+				0, 3, // IANA option code
+				0, 40, // length
+				1, 0, 0, 0, // IAID
+				0, 0, 0, 1, // T1
+				0, 0, 0, 2, // T2
+				0, 5, 0, 0x18, 0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // IPv6
+				0, 0, 0, 2, // PreferredLifetime
+				0, 0, 0, 4, // ValidLifetime
+			},
+			want: []*OptIANA{
+				&OptIANA{
+					IaId: [4]byte{1, 0, 0, 0},
+					T1:   1 * time.Second,
+					T2:   2 * time.Second,
+					Options: IdentityOptions{Options: Options{&OptIAAddress{
+						IPv6Addr:          net.IP{0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0},
+						PreferredLifetime: 2 * time.Second,
+						ValidLifetime:     4 * time.Second,
+						Options:           AddressOptions{Options: Options{}},
+					}}},
+				},
+			},
+		},
+		{
+			buf: []byte{
+				0, 3, // IANA option code
+				0, 40, // length
+				1, 0, 0, 0, // IAID
+				0, 0, 0, 1, // T1
+				0, 0, 0, 2, // T2
+				0, 5, 0, 0x18, 0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // IPv6
+				0, 0, 0, 2, // PreferredLifetime
+				0, 0, 0, 4, // ValidLifetime
 
-func TestOptIANAParseOptIANAInvalidLength(t *testing.T) {
-	data := []byte{
-		1, 0, 0, 0, // IAID
-		0, 0, 0, 1, // T1
-		// truncated from here
-	}
-	_, err := ParseOptIANA(data)
-	require.Error(t, err)
-}
+				0, 3, // IANA option code
+				0, 40, // length
+				1, 2, 3, 4, // IAID
+				0, 0, 0, 9, // T1
+				0, 0, 0, 8, // T2
+				0, 5, 0, 0x18, 0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // IPv6
+				0, 0, 0, 2, // PreferredLifetime
+				0, 0, 0, 4, // ValidLifetime
+			},
+			want: []*OptIANA{
+				&OptIANA{
+					IaId: [4]byte{1, 0, 0, 0},
+					T1:   1 * time.Second,
+					T2:   2 * time.Second,
+					Options: IdentityOptions{Options: Options{&OptIAAddress{
+						IPv6Addr:          net.IP{0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0},
+						PreferredLifetime: 2 * time.Second,
+						ValidLifetime:     4 * time.Second,
+						Options:           AddressOptions{Options: Options{}},
+					}}},
+				},
+				&OptIANA{
+					IaId: [4]byte{1, 2, 3, 4},
+					T1:   9 * time.Second,
+					T2:   8 * time.Second,
+					Options: IdentityOptions{Options: Options{&OptIAAddress{
+						IPv6Addr:          net.IP{0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0},
+						PreferredLifetime: 2 * time.Second,
+						ValidLifetime:     4 * time.Second,
+						Options:           AddressOptions{Options: Options{}},
+					}}},
+				},
+			},
+		},
 
-func TestOptIANAParseOptIANAInvalidOptions(t *testing.T) {
-	data := []byte{
-		1, 0, 0, 0, // IAID
-		0, 0, 0, 1, // T1
-		0, 0, 0, 2, // T2
-		0, 5, 0, 0x18, 0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, 0, 0, 0xb2, 0x7a, // truncated options
+		{
+			buf:  nil,
+			want: nil,
+		},
+		{
+			buf:  []byte{0, 3, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+		{
+			buf: []byte{
+				0, 3, // IANA option code
+				0, 8, // length
+				1, 0, 0, 0, // IAID
+				0, 0, 0, 1, // T1
+				// truncated from here
+			},
+			want: nil,
+			err:  uio.ErrBufferTooShort,
+		},
+		{
+			buf: []byte{
+				0, 3, // IANA option code
+				0, 36, // length
+				1, 0, 0, 0, // IAID
+				0, 0, 0, 1, // T1
+				0, 0, 0, 2, // T2
+				0, 5, 0, 0x18, 0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // IPv6
+				0, 0, 0xb2, 0x7a, // PreferredLifetime
+				// Missing ValidLifetime
+			},
+			want: nil,
+			err:  uio.ErrBufferTooShort,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.IANA(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("IANA = %v, want %v", got, tt.want)
+			}
+			var wantOne *OptIANA
+			if len(tt.want) >= 1 {
+				wantOne = tt.want[0]
+			}
+			if got := mo.OneIANA(); !reflect.DeepEqual(got, wantOne) {
+				t.Errorf("OneIANA = %v, want %v", got, wantOne)
+			}
+
+			if len(tt.want) >= 1 {
+				var b MessageOptions
+				for _, iana := range tt.want {
+					b.Add(iana)
+				}
+				got := b.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
-	_, err := ParseOptIANA(data)
-	require.Error(t, err)
 }
 
 func TestOptIANAGetOneOption(t *testing.T) {
@@ -93,48 +207,33 @@ func TestOptIANADelOption(t *testing.T) {
 	require.Equal(t, iana2.Options.Options, Options{&optsc})
 }
 
-func TestOptIANAToBytes(t *testing.T) {
-	opt := OptIANA{
-		IaId: [4]byte{1, 2, 3, 4},
-		T1:   12345 * time.Second,
-		T2:   54321 * time.Second,
-		Options: IdentityOptions{[]Option{
-			OptElapsedTime(10 * time.Millisecond),
-		}},
-	}
-	expected := []byte{
-		1, 2, 3, 4, // IA ID
-		0, 0, 0x30, 0x39, // T1 = 12345
-		0, 0, 0xd4, 0x31, // T2 = 54321
-		0, 8, 0, 2, 0x00, 0x01,
-	}
-	require.Equal(t, expected, opt.ToBytes())
-}
-
 func TestOptIANAString(t *testing.T) {
 	data := []byte{
 		1, 0, 0, 0, // IAID
 		0, 0, 0, 1, // T1
 		0, 0, 0, 2, // T2
-		0, 5, 0, 0x18, 0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, 0, 0, 0xb2, 0x7a, 0, 0, 0xc0, 0x8a, // options
+		0, 5, 0, 0x18, 0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // IPv6
+		0, 0, 0xb2, 0x7a, // PreferredLifetime
+		0, 0, 0xc0, 0x8a, // ValidLifetime
 	}
-	opt, err := ParseOptIANA(data)
+	var opt OptIANA
+	err := opt.FromBytes(data)
 	require.NoError(t, err)
 
 	str := opt.String()
 	require.Contains(
 		t, str,
-		"IAID=[1 0 0 0]",
+		"IAID=0x01000000",
 		"String() should return the IAID",
 	)
 	require.Contains(
 		t, str,
-		"t1=1s, t2=2s",
+		"T1=1s T2=2s",
 		"String() should return the T1/T2 options",
 	)
 	require.Contains(
 		t, str,
-		"options={",
+		"Options={",
 		"String() should return a list of options",
 	)
 }
diff --git a/dhcpv6/option_ntp_server.go b/dhcpv6/option_ntp_server.go
index a7aafb7..69a3e89 100644
--- a/dhcpv6/option_ntp_server.go
+++ b/dhcpv6/option_ntp_server.go
@@ -18,17 +18,20 @@ func (n *NTPSuboptionSrvAddr) Code() OptionCode {
 
 // ToBytes returns the byte serialization of the suboption.
 func (n *NTPSuboptionSrvAddr) ToBytes() []byte {
-	buf := uio.NewBigEndianBuffer(nil)
-	buf.Write16(uint16(NTPSuboptionSrvAddrCode))
-	buf.Write16(uint16(net.IPv6len))
-	buf.WriteBytes(net.IP(*n).To16())
-	return buf.Data()
+	return net.IP(*n).To16()
 }
 
 func (n *NTPSuboptionSrvAddr) String() string {
 	return fmt.Sprintf("Server Address: %s", net.IP(*n).String())
 }
 
+// FromBytes parses NTP server address from a byte slice p.
+func (n *NTPSuboptionSrvAddr) FromBytes(p []byte) error {
+	buf := uio.NewBigEndianBuffer(p)
+	*n = NTPSuboptionSrvAddr(buf.CopyN(net.IPv6len))
+	return buf.FinError()
+}
+
 // NTPSuboptionMCAddr is NTP_SUBOPTION_MC_ADDR according to RFC 5908.
 type NTPSuboptionMCAddr net.IP
 
@@ -39,19 +42,24 @@ func (n *NTPSuboptionMCAddr) Code() OptionCode {
 
 // ToBytes returns the byte serialization of the suboption.
 func (n *NTPSuboptionMCAddr) ToBytes() []byte {
-	buf := uio.NewBigEndianBuffer(nil)
-	buf.Write16(uint16(NTPSuboptionMCAddrCode))
-	buf.Write16(uint16(net.IPv6len))
-	buf.WriteBytes(net.IP(*n).To16())
-	return buf.Data()
+	return net.IP(*n).To16()
 }
 
 func (n *NTPSuboptionMCAddr) String() string {
 	return fmt.Sprintf("Multicast Address: %s", net.IP(*n).String())
 }
 
+// FromBytes parses NTP multicast address from a byte slice p.
+func (n *NTPSuboptionMCAddr) FromBytes(p []byte) error {
+	buf := uio.NewBigEndianBuffer(p)
+	*n = NTPSuboptionMCAddr(buf.CopyN(net.IPv6len))
+	return buf.FinError()
+}
+
 // NTPSuboptionSrvFQDN is NTP_SUBOPTION_SRV_FQDN according to RFC 5908.
-type NTPSuboptionSrvFQDN rfc1035label.Labels
+type NTPSuboptionSrvFQDN struct {
+	rfc1035label.Labels
+}
 
 // Code returns the suboption code.
 func (n *NTPSuboptionSrvFQDN) Code() OptionCode {
@@ -60,17 +68,16 @@ func (n *NTPSuboptionSrvFQDN) Code() OptionCode {
 
 // ToBytes returns the byte serialization of the suboption.
 func (n *NTPSuboptionSrvFQDN) ToBytes() []byte {
-	buf := uio.NewBigEndianBuffer(nil)
-	buf.Write16(uint16(NTPSuboptionSrvFQDNCode))
-	l := rfc1035label.Labels(*n)
-	buf.Write16(uint16(l.Length()))
-	buf.WriteBytes(l.ToBytes())
-	return buf.Data()
+	return n.Labels.ToBytes()
 }
 
 func (n *NTPSuboptionSrvFQDN) String() string {
-	l := rfc1035label.Labels(*n)
-	return fmt.Sprintf("Server FQDN: %s", l.String())
+	return fmt.Sprintf("Server FQDN: %s", n.Labels.String())
+}
+
+// FromBytes parses an NTP server FQDN from a byte slice p.
+func (n *NTPSuboptionSrvFQDN) FromBytes(p []byte) error {
+	return n.Labels.FromBytes(p)
 }
 
 // NTPSuboptionSrvAddr is the value of NTP_SUBOPTION_SRV_ADDR according to RFC 5908.
@@ -82,53 +89,18 @@ const (
 
 // parseNTPSuboption implements the OptionParser interface.
 func parseNTPSuboption(code OptionCode, data []byte) (Option, error) {
-	//var o Options
-	buf := uio.NewBigEndianBuffer(data)
-	length := len(data)
-	data, err := buf.ReadN(length)
-	if err != nil {
-		return nil, fmt.Errorf("failed to read %d bytes for suboption: %w", length, err)
-	}
+	var o Option
 	switch code {
-	case NTPSuboptionSrvAddrCode, NTPSuboptionMCAddrCode:
-		if length != net.IPv6len {
-			return nil, fmt.Errorf("invalid suboption length, want %d, got %d", net.IPv6len, length)
-		}
-		var so Option
-		switch code {
-		case NTPSuboptionSrvAddrCode:
-			sos := NTPSuboptionSrvAddr(data)
-			so = &sos
-		case NTPSuboptionMCAddrCode:
-			som := NTPSuboptionMCAddr(data)
-			so = &som
-		}
-		return so, nil
+	case NTPSuboptionSrvAddrCode:
+		o = &NTPSuboptionSrvAddr{}
+	case NTPSuboptionMCAddrCode:
+		o = &NTPSuboptionMCAddr{}
 	case NTPSuboptionSrvFQDNCode:
-		l, err := rfc1035label.FromBytes(data)
-		if err != nil {
-			return nil, fmt.Errorf("failed to parse rfc1035 labels: %w", err)
-		}
-		// TODO according to rfc3315, this label must not be compressed.
-		// Need to add support for compression detection to the
-		// `rfc1035label` package in order to do that.
-		so := NTPSuboptionSrvFQDN(*l)
-		return &so, nil
+		o = &NTPSuboptionSrvFQDN{}
 	default:
-		gopt := OptionGeneric{OptionCode: code, OptionData: data}
-		return &gopt, nil
-	}
-}
-
-// ParseOptNTPServer parses a sequence of bytes into an OptNTPServer object.
-func ParseOptNTPServer(data []byte) (*OptNTPServer, error) {
-	var so Options
-	if err := so.FromBytesWithParser(data, parseNTPSuboption); err != nil {
-		return nil, err
+		o = &OptionGeneric{OptionCode: code}
 	}
-	return &OptNTPServer{
-		Suboptions: so,
-	}, nil
+	return o, o.FromBytes(data)
 }
 
 // OptNTPServer is an option NTP server as defined by RFC 5908.
@@ -141,13 +113,14 @@ func (op *OptNTPServer) Code() OptionCode {
 	return OptionNTPServer
 }
 
+// FromBytes parses a sequence of bytes into an OptNTPServer object.
+func (op *OptNTPServer) FromBytes(data []byte) error {
+	return op.Suboptions.FromBytesWithParser(data, parseNTPSuboption)
+}
+
 // ToBytes returns the option serialized to bytes.
 func (op *OptNTPServer) ToBytes() []byte {
-	buf := uio.NewBigEndianBuffer(nil)
-	for _, so := range op.Suboptions {
-		buf.WriteBytes(so.ToBytes())
-	}
-	return buf.Data()
+	return op.Suboptions.ToBytes()
 }
 
 func (op *OptNTPServer) String() string {
diff --git a/dhcpv6/option_ntp_server_test.go b/dhcpv6/option_ntp_server_test.go
index 105a753..59b3c69 100644
--- a/dhcpv6/option_ntp_server_test.go
+++ b/dhcpv6/option_ntp_server_test.go
@@ -13,25 +13,22 @@ func TestSuboptionSrvAddr(t *testing.T) {
 	ip := net.ParseIP("2a03:2880:fffe:c:face:b00c:0:35")
 	so := NTPSuboptionSrvAddr(ip)
 	assert.Equal(t, NTPSuboptionSrvAddrCode, so.Code())
-	expected := append([]byte{0x00, 0x01, 0x00, 0x10}, ip...)
-	assert.Equal(t, expected, so.ToBytes())
+	assert.Equal(t, []byte(ip), so.ToBytes())
 }
 
 func TestSuboptionMCAddr(t *testing.T) {
 	ip := net.ParseIP("2a03:2880:fffe:c:face:b00c:0:35")
 	so := NTPSuboptionMCAddr(ip)
 	assert.Equal(t, NTPSuboptionMCAddrCode, so.Code())
-	expected := append([]byte{0x00, 0x02, 0x00, 0x10}, ip...)
-	assert.Equal(t, expected, so.ToBytes())
+	assert.Equal(t, []byte(ip), so.ToBytes())
 }
 
 func TestSuboptionSrvFQDN(t *testing.T) {
 	fqdn, err := rfc1035label.FromBytes([]byte("\x03ntp\x07example\x03com"))
 	require.NoError(t, err)
-	so := NTPSuboptionSrvFQDN(*fqdn)
+	so := NTPSuboptionSrvFQDN{*fqdn}
 	assert.Equal(t, NTPSuboptionSrvFQDNCode, so.Code())
-	expected := append([]byte{0x00, 0x03, 0x00, 0x10}, fqdn.ToBytes()...)
-	assert.Equal(t, expected, so.ToBytes())
+	assert.Equal(t, fqdn.ToBytes(), so.ToBytes())
 }
 
 func TestSuboptionGeneric(t *testing.T) {
@@ -40,7 +37,8 @@ func TestSuboptionGeneric(t *testing.T) {
 		0x00, 0x04, // length, 4 bytes
 		0x74, 0x65, 0x73, 0x74, // the ASCII bytes for the string "test"
 	}
-	o, err := ParseOptNTPServer(data)
+	var o OptNTPServer
+	err := o.FromBytes(data)
 	require.NoError(t, err)
 	require.Equal(t, 1, len(o.Suboptions))
 	assert.IsType(t, &OptionGeneric{}, o.Suboptions[0])
@@ -67,7 +65,8 @@ func TestParseOptNTPServer(t *testing.T) {
 	}...)
 	data = append(data, fqdn.ToBytes()...)
 
-	o, err := ParseOptNTPServer(data)
+	var o OptNTPServer
+	err = o.FromBytes(data)
 	require.NoError(t, err)
 	require.NotNil(t, o)
 	assert.Equal(t, 2, len(o.Suboptions))
@@ -78,11 +77,11 @@ func TestParseOptNTPServer(t *testing.T) {
 
 	optFQDN, ok := o.Suboptions[1].(*NTPSuboptionSrvFQDN)
 	require.True(t, ok)
-	assert.Equal(t, *fqdn, rfc1035label.Labels(*optFQDN))
+	assert.Equal(t, *fqdn, optFQDN.Labels)
 
 	var mo MessageOptions
 	assert.Nil(t, mo.NTPServers())
-	mo.Add(o)
+	mo.Add(&o)
 	// MessageOptions.NTPServers only returns server address values.
 	assert.Equal(t, []net.IP{ip}, mo.NTPServers())
 }
diff --git a/dhcpv6/option_relaymsg.go b/dhcpv6/option_relaymsg.go
index 48d9716..9962756 100644
--- a/dhcpv6/option_relaymsg.go
+++ b/dhcpv6/option_relaymsg.go
@@ -25,17 +25,18 @@ func (op *optRelayMsg) ToBytes() []byte {
 }
 
 func (op *optRelayMsg) String() string {
-	return fmt.Sprintf("RelayMsg: %v", op.Msg)
+	return fmt.Sprintf("%s: %v", op.Code(), op.Msg)
 }
 
-// build an optRelayMsg structure from a sequence of bytes.
-// The input data does not include option code and length bytes.
-func parseOptRelayMsg(data []byte) (*optRelayMsg, error) {
+// LongString returns a multi-line string representation of the relay message data.
+func (op *optRelayMsg) LongString(indent int) string {
+	return fmt.Sprintf("%s: %v", op.Code(), op.Msg.LongString(indent))
+}
+
+// FromBytes build an optRelayMsg structure from a sequence of bytes. The input
+// data does not include option code and length bytes.
+func (op *optRelayMsg) FromBytes(data []byte) error {
 	var err error
-	var opt optRelayMsg
-	opt.Msg, err = FromBytes(data)
-	if err != nil {
-		return nil, err
-	}
-	return &opt, nil
+	op.Msg, err = FromBytes(data)
+	return err
 }
diff --git a/dhcpv6/option_relaymsg_test.go b/dhcpv6/option_relaymsg_test.go
index b898a75..b96c0f9 100644
--- a/dhcpv6/option_relaymsg_test.go
+++ b/dhcpv6/option_relaymsg_test.go
@@ -1,110 +1,78 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
 	"reflect"
 	"testing"
-	"time"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestRelayMsgParseOptRelayMsg(t *testing.T) {
-	opt, err := parseOptRelayMsg([]byte{
-		1,                // MessageTypeSolicit
-		0xaa, 0xbb, 0xcc, // transaction ID
-		0, 8, // option: elapsed time
-		0, 2, // option length
-		0, 0, // option value
-	})
-	if err != nil {
-		t.Fatal(err)
-	}
-	if code := opt.Code(); code != OptionRelayMsg {
-		t.Fatalf("Invalid option code. Expected OptionRelayMsg (%v), got %v",
-			OptionRelayMsg, code,
-		)
-	}
-}
-
-func TestRelayMsgOptionsFromBytes(t *testing.T) {
-	var opts Options
-	err := opts.FromBytes([]byte{
-		0, 9, // option: relay message
-		0, 10, // relayed message length
-		1,                // MessageTypeSolicit
-		0xaa, 0xbb, 0xcc, // transaction ID
-		0, 8, // option: elapsed time
-		0, 2, // option length
-		0, 0, // option value
-	})
-	if err != nil {
-		t.Fatal(err)
-	}
-	if len(opts) != 1 {
-		t.Fatalf("Invalid number of options. Expected 1, got %v", len(opts))
-	}
-	opt := opts[0]
-	if code := opt.Code(); code != OptionRelayMsg {
-		t.Fatalf("Invalid option code. Expected OptionRelayMsg (%v), got %v",
-			OptionRelayMsg, code,
-		)
-	}
-}
+func TestRelayMsgOptionParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want DHCPv6
+	}{
+		{
+			buf: []byte{
+				0, 9, // Relay Msg option
+				0, 10, // length
+				1,                // MessageTypeSolicit
+				0xaa, 0xbb, 0xcc, // transaction ID
+				0, 8, // option: elapsed time
+				0, 2, // option length
+				0, 0, // option value
+			},
+			want: &Message{
+				MessageType:   MessageTypeSolicit,
+				TransactionID: TransactionID{0xaa, 0xbb, 0xcc},
+				Options:       MessageOptions{Options{OptElapsedTime(0)}},
+			},
+		},
+		{
+			buf: []byte{
+				0, 9, // Relay Msg option
+				0, 6, // length
+				1,                // MessageTypeSolicit
+				0xaa, 0xbb, 0xcc, // transaction ID
+				0, 8, // option: elapsed time
+				// no length/value for elapsed time option
+			},
+			err: uio.ErrUnreadBytes,
+		},
+		{
+			buf:  []byte{0, 9, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrBufferTooShort,
+		},
+		{
+			buf:  []byte{0, 9, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var ro RelayOptions
+			if err := ro.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := ro.RelayMessage(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("RelayMessage = %v, want %v", got, tt.want)
+			}
 
-func TestRelayMsgParseOptRelayMsgSingleEncapsulation(t *testing.T) {
-	d, err := FromBytes([]byte{
-		12,                                             // RELAY-FORW
-		0,                                              // hop count
-		0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // linkAddr
-		0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, // peerAddr
-		0, 9, // option: relay message
-		0, 10, // relayed message length
-		1,                // MessageTypeSolicit
-		0xaa, 0xbb, 0xcc, // transaction ID
-		0, 8, // option: elapsed time
-		0, 2, // option length
-		0x00, 0x01, // option value
-	})
-	if err != nil {
-		t.Fatal(err)
-	}
-	r, ok := d.(*RelayMessage)
-	if !ok {
-		t.Fatalf("Invalid DHCPv6 type. Expected RelayMessage, got %v",
-			reflect.TypeOf(d),
-		)
-	}
-	if mType := r.Type(); mType != MessageTypeRelayForward {
-		t.Fatalf("Invalid messge type for relay. Expected %v, got %v", MessageTypeRelayForward, mType)
-	}
-	if len(r.Options.Options) != 1 {
-		t.Fatalf("Invalid number of options. Expected 1, got %v", len(r.Options.Options))
-	}
-	ro := r.Options.RelayMessage()
-	if ro == nil {
-		t.Fatalf("No relay message")
-	}
-	innerDHCP, ok := ro.(*Message)
-	if !ok {
-		t.Fatalf("Invalid relay message type. Expected Message, got %v",
-			reflect.TypeOf(innerDHCP),
-		)
-	}
-	if dType := innerDHCP.Type(); dType != MessageTypeSolicit {
-		t.Fatalf("Invalid inner DHCP type. Expected MessageTypeSolicit (%v), got %v",
-			MessageTypeSolicit, dType,
-		)
-	}
-	xid := TransactionID{0xaa, 0xbb, 0xcc}
-	if tID := innerDHCP.TransactionID; tID != xid {
-		t.Fatalf("Invalid inner DHCP transaction ID. Expected 0xaabbcc, got %v", tID)
-	}
-	if len(innerDHCP.Options.Options) != 1 {
-		t.Fatalf("Invalid inner DHCP options length. Expected 1, got %v", len(innerDHCP.Options.Options))
-	}
-	eTime := innerDHCP.Options.ElapsedTime()
-	if eTime != 10*time.Millisecond {
-		t.Fatalf("Invalid elapsed time. Expected 0x1122, got 0x%04x", eTime)
+			if tt.want != nil {
+				var m RelayOptions
+				m.Add(OptRelayMessage(tt.want))
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
 }
 
@@ -148,17 +116,14 @@ func TestSample(t *testing.T) {
 }
 
 func TestRelayMsgParseOptRelayMsgTooShort(t *testing.T) {
-	_, err := parseOptRelayMsg([]byte{
-		1,                // MessageTypeSolicit
-		0xaa, 0xbb, 0xcc, // transaction ID
-		0, 8, // option: elapsed time
-		// no length/value for elapsed time option
-	})
+	var opt optRelayMsg
+	err := opt.FromBytes([]byte{})
 	require.Error(t, err, "ParseOptRelayMsg() should return an error if the encapsulated message is invalid")
 }
 
 func TestRelayMsgString(t *testing.T) {
-	opt, err := parseOptRelayMsg([]byte{
+	var opt optRelayMsg
+	err := opt.FromBytes([]byte{
 		1,                // MessageTypeSolicit
 		0xaa, 0xbb, 0xcc, // transaction ID
 		0, 8, // option: elapsed time
@@ -169,7 +134,7 @@ func TestRelayMsgString(t *testing.T) {
 	require.Contains(
 		t,
 		opt.String(),
-		"RelayMsg: Message",
+		"Relay Message: Message",
 		"String() should contain the relaymsg contents",
 	)
 }
diff --git a/dhcpv6/option_relayport.go b/dhcpv6/option_relayport.go
index fd51948..8bf9eff 100644
--- a/dhcpv6/option_relayport.go
+++ b/dhcpv6/option_relayport.go
@@ -29,14 +29,13 @@ func (op *optRelayPort) ToBytes() []byte {
 }
 
 func (op *optRelayPort) String() string {
-	return fmt.Sprintf("RelayPort: %d", op.DownstreamSourcePort)
+	return fmt.Sprintf("%s: %d", op.Code(), op.DownstreamSourcePort)
 }
 
-// build an optRelayPort structure from a sequence of bytes.
-// The input data does not include option code and length bytes.
-func parseOptRelayPort(data []byte) (*optRelayPort, error) {
-	var opt optRelayPort
+// FromBytes build an optRelayPort structure from a sequence of bytes. The
+// input data does not include option code and length bytes.
+func (op *optRelayPort) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
-	opt.DownstreamSourcePort = buf.Read16()
-	return &opt, buf.FinError()
+	op.DownstreamSourcePort = buf.Read16()
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_relayport_test.go b/dhcpv6/option_relayport_test.go
index 124af62..d80e268 100644
--- a/dhcpv6/option_relayport_test.go
+++ b/dhcpv6/option_relayport_test.go
@@ -7,9 +7,10 @@ import (
 )
 
 func TestParseRelayPort(t *testing.T) {
-	opt, err := parseOptRelayPort([]byte{0x12, 0x32})
+	var opt optRelayPort
+	err := opt.FromBytes([]byte{0x12, 0x32})
 	require.NoError(t, err)
-	require.Equal(t, &optRelayPort{DownstreamSourcePort: 0x1232}, opt)
+	require.Equal(t, optRelayPort{DownstreamSourcePort: 0x1232}, opt)
 }
 
 func TestRelayPortToBytes(t *testing.T) {
diff --git a/dhcpv6/option_remoteid.go b/dhcpv6/option_remoteid.go
index fb028e6..7a3b3b4 100644
--- a/dhcpv6/option_remoteid.go
+++ b/dhcpv6/option_remoteid.go
@@ -26,17 +26,16 @@ func (op *OptRemoteID) ToBytes() []byte {
 }
 
 func (op *OptRemoteID) String() string {
-	return fmt.Sprintf("RemoteID: EnterpriseNumber %d RemoteID %v",
-		op.EnterpriseNumber, op.RemoteID,
+	return fmt.Sprintf("%s: {EnterpriseNumber=%d RemoteID=%#x}",
+		op.Code(), op.EnterpriseNumber, op.RemoteID,
 	)
 }
 
-// ParseOptRemoteId builds an OptRemoteId structure from a sequence of bytes.
-// The input data does not include option code and length bytes.
-func ParseOptRemoteID(data []byte) (*OptRemoteID, error) {
-	var opt OptRemoteID
+// FromBytes builds an OptRemoteID structure from a sequence of bytes. The
+// input data does not include option code and length bytes.
+func (op *OptRemoteID) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
-	opt.EnterpriseNumber = buf.Read32()
-	opt.RemoteID = buf.ReadAll()
-	return &opt, buf.FinError()
+	op.EnterpriseNumber = buf.Read32()
+	op.RemoteID = buf.ReadAll()
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_remoteid_test.go b/dhcpv6/option_remoteid_test.go
index 5c1ee52..4cdd37d 100644
--- a/dhcpv6/option_remoteid_test.go
+++ b/dhcpv6/option_remoteid_test.go
@@ -1,64 +1,94 @@
 package dhcpv6
 
 import (
-	"bytes"
+	"errors"
+	"fmt"
+	"reflect"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestOptRemoteID(t *testing.T) {
-	expected := []byte{0xaa, 0xbb, 0xcc, 0xdd}
-	remoteId := []byte("DSLAM01 eth2/1/01/21")
-	expected = append(expected, remoteId...)
-	opt, err := ParseOptRemoteID(expected)
-	if err != nil {
-		t.Fatal(err)
-	}
-	if en := opt.EnterpriseNumber; en != 0xaabbccdd {
-		t.Fatalf("Invalid Enterprise Number. Expected 0xaabbccdd, got %v", en)
-	}
-	if rid := opt.RemoteID; !bytes.Equal(rid, remoteId) {
-		t.Fatalf("Invalid Remote ID. Expected %v, got %v", expected, rid)
-	}
-}
+func TestRemoteIDParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want *OptRemoteID
+	}{
+		{
+			buf: []byte{
+				0, 37, // Remote ID
+				0, 8, // length
+				0, 0, 0, 16,
+				'S', 'L', 'A', 'M',
+			},
+			want: &OptRemoteID{
+				EnterpriseNumber: 16,
+				RemoteID:         []byte("SLAM"),
+			},
+		},
+		{
+			buf: []byte{
+				0, 37,
+				0, 0,
+			},
+			err: uio.ErrBufferTooShort,
+		},
+		{
+			buf: []byte{
+				0, 37,
+				0, 4,
+				0, 0, 0, 6,
+			},
+			want: &OptRemoteID{
+				EnterpriseNumber: 6,
+				RemoteID:         []byte{},
+			},
+		},
+		{
+			buf: []byte{0, 37, 0},
+			err: uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var ro RelayOptions
+			if err := ro.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := ro.RemoteID(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("RemoteID = %v, want %v", got, tt.want)
+			}
 
-func TestOptRemoteIDToBytes(t *testing.T) {
-	remoteId := []byte("DSLAM01 eth2/1/01/21")
-	expected := append([]byte{0, 0, 0, 0}, remoteId...)
-	opt := OptRemoteID{
-		RemoteID: remoteId,
-	}
-	toBytes := opt.ToBytes()
-	if !bytes.Equal(toBytes, expected) {
-		t.Fatalf("Invalid ToBytes result. Expected %v, got %v", expected, toBytes)
+			if tt.want != nil {
+				var m RelayOptions
+				m.Add(tt.want)
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
 }
 
-func TestOptRemoteIDParseOptRemoteIDTooShort(t *testing.T) {
-	buf := []byte{0xaa, 0xbb, 0xcc}
-	_, err := ParseOptRemoteID(buf)
-	require.Error(t, err, "A short option should return an error")
-}
-
 func TestOptRemoteIDString(t *testing.T) {
-	buf := []byte{0xaa, 0xbb, 0xcc, 0xdd}
-	remoteId := []byte("Test1234")
-	buf = append(buf, remoteId...)
-
-	opt, err := ParseOptRemoteID(buf)
-	require.NoError(t, err)
+	opt := &OptRemoteID{
+		EnterpriseNumber: 123,
+		RemoteID:         []byte("Test1234"),
+	}
 	str := opt.String()
 	require.Contains(
 		t,
 		str,
-		"EnterpriseNumber 2864434397",
+		"EnterpriseNumber=123",
 		"String() should contain the enterprisenum",
 	)
 	require.Contains(
 		t,
 		str,
-		"RemoteID [84 101 115 116 49 50 51 52]",
+		"RemoteID=0x5465737431323334",
 		"String() should contain the remoteid bytes",
 	)
 }
diff --git a/dhcpv6/option_requestedoption.go b/dhcpv6/option_requestedoption.go
index 3281a61..bba5e48 100644
--- a/dhcpv6/option_requestedoption.go
+++ b/dhcpv6/option_requestedoption.go
@@ -71,5 +71,5 @@ func (*optRequestedOption) Code() OptionCode {
 }
 
 func (op *optRequestedOption) String() string {
-	return fmt.Sprintf("RequestedOptions: %s", op.OptionCodes)
+	return fmt.Sprintf("%s: %s", op.Code(), op.OptionCodes)
 }
diff --git a/dhcpv6/option_requestedoption_test.go b/dhcpv6/option_requestedoption_test.go
index 9941a89..ab6e86b 100644
--- a/dhcpv6/option_requestedoption_test.go
+++ b/dhcpv6/option_requestedoption_test.go
@@ -1,23 +1,92 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
+	"reflect"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestOptRequestedOption(t *testing.T) {
-	expected := []byte{0, 1, 0, 2}
-	var o optRequestedOption
-	err := o.FromBytes(expected)
-	require.NoError(t, err, "ParseOptRequestedOption() correct options should not error")
+func TestOROParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want OptionCodes
+	}{
+		{
+			buf: []byte{
+				0, 6, // ORO option
+				0, 2, // length
+				0, 3, // IANA option
+			},
+			want: OptionCodes{OptionIANA},
+		},
+		{
+			buf: []byte{
+				0, 6, // ORO option
+				0, 4, // length
+				0, 3, // IANA
+				0, 4, // IATA
+			},
+			want: OptionCodes{OptionIANA, OptionIATA},
+		},
+		{
+			buf:  nil,
+			want: nil,
+		},
+		{
+			buf:  []byte{0, 6, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+		{
+			buf:  []byte{0, 6, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.RequestedOptions(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("RequestedOptions = %v, want %v", got, tt.want)
+			}
+
+			if tt.want != nil {
+				var m MessageOptions
+				m.Add(OptRequestedOption(tt.want...))
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
+	}
 }
 
-func TestOptRequestedOptionParseOptRequestedOptionTooShort(t *testing.T) {
-	buf := []byte{0, 1, 0}
-	var o optRequestedOption
-	err := o.FromBytes(buf)
-	require.Error(t, err, "A short option should return an error (must be divisible by 2)")
+func TestParseMessageOptionsWithORO(t *testing.T) {
+	buf := []byte{
+		0, 6, // ORO option
+		0, 2, // length
+		0, 3, // IANA Option
+		0, 6, // ORO
+		0, 2, // length
+		0, 4, // IATA
+	}
+
+	want := OptionCodes{OptionIANA, OptionIATA}
+	var mo MessageOptions
+	if err := mo.FromBytes(buf); err != nil {
+		t.Errorf("FromBytes = %v", err)
+	} else if got := mo.RequestedOptions(); !reflect.DeepEqual(got, want) {
+		t.Errorf("ORO = %v, want %v", got, want)
+	}
 }
 
 func TestOptRequestedOptionString(t *testing.T) {
@@ -28,7 +97,7 @@ func TestOptRequestedOptionString(t *testing.T) {
 	require.Contains(
 		t,
 		o.String(),
-		"Client Identifier, Server Identifier",
+		"Client ID, Server ID",
 		"String() should contain the options specified",
 	)
 	o.OptionCodes = append(o.OptionCodes, 12345)
diff --git a/dhcpv6/option_serverid.go b/dhcpv6/option_serverid.go
index c2c10da..4c35cc1 100644
--- a/dhcpv6/option_serverid.go
+++ b/dhcpv6/option_serverid.go
@@ -6,12 +6,12 @@ import (
 
 // OptServerID represents a Server Identifier option as defined by RFC 3315
 // Section 22.1.
-func OptServerID(d Duid) Option {
+func OptServerID(d DUID) Option {
 	return &optServerID{d}
 }
 
 type optServerID struct {
-	Duid
+	DUID
 }
 
 func (*optServerID) Code() OptionCode {
@@ -19,15 +19,13 @@ func (*optServerID) Code() OptionCode {
 }
 
 func (op *optServerID) String() string {
-	return fmt.Sprintf("ServerID: %v", op.Duid.String())
+	return fmt.Sprintf("%s: %v", op.Code(), op.DUID)
 }
 
-// parseOptServerID builds an optServerID structure from a sequence of bytes.
-// The input data does not include option code and length bytes.
-func parseOptServerID(data []byte) (*optServerID, error) {
-	sid, err := DuidFromBytes(data)
-	if err != nil {
-		return nil, err
-	}
-	return &optServerID{*sid}, nil
+// FromBytes builds an optServerID structure from a sequence of bytes. The
+// input data does not include option code and length bytes.
+func (op *optServerID) FromBytes(data []byte) error {
+	var err error
+	op.DUID, err = DUIDFromBytes(data)
+	return err
 }
diff --git a/dhcpv6/option_serverid_test.go b/dhcpv6/option_serverid_test.go
index 05158c4..bb4a928 100644
--- a/dhcpv6/option_serverid_test.go
+++ b/dhcpv6/option_serverid_test.go
@@ -1,84 +1,119 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
 	"net"
+	"reflect"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/insomniacslk/dhcp/iana"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestParseOptServerID(t *testing.T) {
-	data := []byte{
-		0, 3, // DUID_LL
-		0, 1, // hwtype ethernet
-		0, 1, 2, 3, 4, 5, // hw addr
+func TestServerIDParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want DUID
+	}{
+		{
+			buf: []byte{
+				0, 2, // Server ID option
+				0, 10, // length
+				0, 3, // DUID_LL
+				0, 1, // hwtype ethernet
+				0, 1, 2, 3, 4, 5, // HW addr
+			},
+			want: &DUIDLL{HWType: iana.HWTypeEthernet, LinkLayerAddr: net.HardwareAddr{0, 1, 2, 3, 4, 5}},
+		},
+		{
+			buf:  nil,
+			want: nil,
+		},
+		{
+			buf:  []byte{0, 1, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrBufferTooShort,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.ServerID(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("ServerID = %v, want %v", got, tt.want)
+			}
+		})
 	}
-	opt, err := parseOptServerID(data)
-	require.NoError(t, err)
-	require.Equal(t, DUID_LL, opt.Type)
-	require.Equal(t, iana.HWTypeEthernet, opt.HwType)
-	require.Equal(t, net.HardwareAddr{0, 1, 2, 3, 4, 5}, opt.LinkLayerAddr)
 }
 
-func TestOptServerIdToBytes(t *testing.T) {
-	opt := OptServerID(
-		Duid{
-			Type:          DUID_LL,
-			HwType:        iana.HWTypeEthernet,
-			LinkLayerAddr: net.HardwareAddr{5, 4, 3, 2, 1, 0},
+func TestServerID(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		want optServerID
+		err  error
+	}{
+		{
+			buf: []byte{
+				0, 3, // DUID_LL
+				0, 1, // hwtype ethernet
+				0, 1, 2, 3, 4, 5, // hw addr
+			},
+			want: optServerID{
+				&DUIDLL{
+					HWType:        iana.HWTypeEthernet,
+					LinkLayerAddr: net.HardwareAddr([]byte{0, 1, 2, 3, 4, 5}),
+				},
+			},
 		},
-	)
-	expected := []byte{
-		0, 3, // DUID_LL
-		0, 1, // hwtype ethernet
-		5, 4, 3, 2, 1, 0, // hw addr
-	}
-	require.Equal(t, expected, opt.ToBytes())
-}
+		{
+			buf: []byte{0},
+			err: uio.ErrBufferTooShort,
+		},
+		{
+			buf: []byte{0, 3, 0},
+			err: uio.ErrBufferTooShort,
+		},
+		{
+			buf: nil,
+			err: uio.ErrBufferTooShort,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var opt optServerID
+			if err := opt.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if tt.err == nil {
+				if !reflect.DeepEqual(tt.want, opt) {
+					t.Errorf("FromBytes = %v, want %v", opt, tt.want)
+				}
 
-func TestOptServerIdDecodeEncode(t *testing.T) {
-	data := []byte{
-		0, 3, // DUID_LL
-		0, 1, // hwtype ethernet
-		5, 4, 3, 2, 1, 0, // hw addr
+				out := tt.want.ToBytes()
+				if diff := cmp.Diff(tt.buf, out); diff != "" {
+					t.Errorf("ToBytes mismatch: (-want, +got):\n%s", diff)
+				}
+			}
+		})
 	}
-	opt, err := parseOptServerID(data)
-	require.NoError(t, err)
-	require.Equal(t, data, opt.ToBytes())
 }
 
-func TestOptionServerId(t *testing.T) {
+func TestOptionServerIDString(t *testing.T) {
 	opt := OptServerID(
-		Duid{
-			Type:          DUID_LL,
-			HwType:        iana.HWTypeEthernet,
-			LinkLayerAddr: net.HardwareAddr{0xde, 0xad, 0, 0, 0xbe, 0xef},
+		&DUIDLL{
+			HWType:        iana.HWTypeEthernet,
+			LinkLayerAddr: net.HardwareAddr([]byte{0xde, 0xad, 0, 0, 0xbe, 0xef}),
 		},
 	)
 	require.Equal(t, OptionServerID, opt.Code())
 	require.Contains(
 		t,
 		opt.String(),
-		"ServerID: DUID{type=DUID-LL hwtype=Ethernet hwaddr=de:ad:00:00:be:ef}",
-		"String() should contain the correct sid output",
+		"Server ID: DUID-LL{HWType=Ethernet HWAddr=de:ad:00:00:be:ef}",
+		"String() should contain the correct cid output",
 	)
 }
-
-func TestOptServerIdparseOptServerIDBogusDUID(t *testing.T) {
-	data := []byte{
-		0, 4, // DUID_UUID
-		1, 2, 3, 4, 5, 6, 7, 8, 9, // a UUID should be 18 bytes not 17
-		10, 11, 12, 13, 14, 15, 16, 17,
-	}
-	_, err := parseOptServerID(data)
-	require.Error(t, err, "A truncated OptServerId DUID should return an error")
-}
-
-func TestOptServerIdparseOptServerIDInvalidTooShort(t *testing.T) {
-	data := []byte{
-		0, // truncated: DUIDs are at least 2 bytes
-	}
-	_, err := parseOptServerID(data)
-	require.Error(t, err, "A truncated OptServerId should return an error")
-}
diff --git a/dhcpv6/option_statuscode.go b/dhcpv6/option_statuscode.go
index b6b5a14..c745bc1 100644
--- a/dhcpv6/option_statuscode.go
+++ b/dhcpv6/option_statuscode.go
@@ -31,16 +31,15 @@ func (op *OptStatusCode) ToBytes() []byte {
 
 // String returns a human-readable option.
 func (op *OptStatusCode) String() string {
-	return fmt.Sprintf("StatusCode: Code: %s (%d); Message: %s",
-		op.StatusCode, op.StatusCode, op.StatusMessage)
+	return fmt.Sprintf("%s: {Code=%s (%d); Message=%s}",
+		op.Code(), op.StatusCode, op.StatusCode, op.StatusMessage)
 }
 
-// ParseOptStatusCode builds an OptStatusCode structure from a sequence of
-// bytes. The input data does not include option code and length bytes.
-func ParseOptStatusCode(data []byte) (*OptStatusCode, error) {
-	var opt OptStatusCode
+// FromBytes builds an OptStatusCode structure from a sequence of bytes. The
+// input data does not include option code and length bytes.
+func (op *OptStatusCode) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
-	opt.StatusCode = iana.StatusCode(buf.Read16())
-	opt.StatusMessage = string(buf.ReadAll())
-	return &opt, buf.FinError()
+	op.StatusCode = iana.StatusCode(buf.Read16())
+	op.StatusMessage = string(buf.ReadAll())
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_statuscode_test.go b/dhcpv6/option_statuscode_test.go
index 1478678..a2655b2 100644
--- a/dhcpv6/option_statuscode_test.go
+++ b/dhcpv6/option_statuscode_test.go
@@ -1,39 +1,91 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
+	"reflect"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/insomniacslk/dhcp/iana"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestParseOptStatusCode(t *testing.T) {
-	data := []byte{
-		0, 5, // StatusUseMulticast
-		'u', 's', 'e', ' ', 'm', 'u', 'l', 't', 'i', 'c', 'a', 's', 't',
-	}
-	opt, err := ParseOptStatusCode(data)
-	require.NoError(t, err)
-	require.Equal(t, iana.StatusUseMulticast, opt.StatusCode)
-	require.Equal(t, "use multicast", opt.StatusMessage)
+type optionsWithStatusCode interface {
+	Status() *OptStatusCode
+	ToBytes() []byte
 }
 
-func TestOptStatusCodeToBytes(t *testing.T) {
-	expected := []byte{
-		0, 0, // StatusSuccess
-		's', 'u', 'c', 'c', 'e', 's', 's',
-	}
-	opt := OptStatusCode{
-		StatusCode:    iana.StatusSuccess,
-		StatusMessage: "success",
+type optionsPtr[O any] interface {
+	*O
+	FromBytes([]byte) error
+	Add(o Option)
+}
+
+type testCase struct {
+	buf  []byte
+	err  error
+	want *OptStatusCode
+}
+
+func testParseStatus[MO optionsWithStatusCode, OA optionsPtr[MO]](t *testing.T, tt testCase) func(t *testing.T) {
+	return func(t *testing.T) {
+		t.Helper()
+		var mo MO
+		if err := OA(&mo).FromBytes(tt.buf); !errors.Is(err, tt.err) {
+			t.Errorf("FromBytes = %v, want %v", err, tt.err)
+		}
+		if got := mo.Status(); !reflect.DeepEqual(got, tt.want) {
+			t.Errorf("Status = %v, want %v", got, tt.want)
+		}
+
+		if tt.want != nil {
+			var m MO
+			OA(&m).Add(tt.want)
+			got := m.ToBytes()
+			if diff := cmp.Diff(tt.buf, got); diff != "" {
+				t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+			}
+		}
 	}
-	actual := opt.ToBytes()
-	require.Equal(t, expected, actual)
 }
 
-func TestOptStatusCodeParseOptStatusCodeTooShort(t *testing.T) {
-	_, err := ParseOptStatusCode([]byte{0})
-	require.Error(t, err, "ParseOptStatusCode: Expected error on truncated option")
+func TestStatusCodeParseAndGetter(t *testing.T) {
+	for i, tt := range []testCase{
+		{
+			buf: []byte{
+				0, 13, // StatusCode option
+				0, 15, // length
+				0, 5, // StatusUseMulticast
+				'u', 's', 'e', ' ', 'm', 'u', 'l', 't', 'i', 'c', 'a', 's', 't',
+			},
+			want: &OptStatusCode{
+				StatusCode:    iana.StatusUseMulticast,
+				StatusMessage: "use multicast",
+			},
+		},
+		{
+			buf:  nil,
+			want: nil,
+		},
+		{
+			buf:  []byte{0, 13, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrBufferTooShort,
+		},
+		{
+			buf:  []byte{0, 13, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("MO-%d", i), testParseStatus[MessageOptions, *MessageOptions](t, tt))
+		t.Run(fmt.Sprintf("IO-%d", i), testParseStatus[IdentityOptions, *IdentityOptions](t, tt))
+		t.Run(fmt.Sprintf("AO-%d", i), testParseStatus[AddressOptions, *AddressOptions](t, tt))
+		t.Run(fmt.Sprintf("PDO-%d", i), testParseStatus[PDOptions, *PDOptions](t, tt))
+		t.Run(fmt.Sprintf("PO-%d", i), testParseStatus[PrefixOptions, *PrefixOptions](t, tt))
+	}
 }
 
 func TestOptStatusCodeString(t *testing.T) {
@@ -41,13 +93,14 @@ func TestOptStatusCodeString(t *testing.T) {
 		0, 5, // StatusUseMulticast
 		'u', 's', 'e', ' ', 'm', 'u', 'l', 't', 'i', 'c', 'a', 's', 't',
 	}
-	opt, err := ParseOptStatusCode(data)
+	var opt OptStatusCode
+	err := opt.FromBytes(data)
 	require.NoError(t, err)
 
 	require.Contains(
 		t,
 		opt.String(),
-		"Code: UseMulticast (5); Message: use multicast",
+		"Code=UseMulticast (5); Message=use multicast",
 		"String() should contain the code and message",
 	)
 }
diff --git a/dhcpv6/option_temporaryaddress.go b/dhcpv6/option_temporaryaddress.go
index f9dd3ca..cc102a4 100644
--- a/dhcpv6/option_temporaryaddress.go
+++ b/dhcpv6/option_temporaryaddress.go
@@ -9,8 +9,8 @@ import (
 // OptIATA implements the identity association for non-temporary addresses
 // option.
 //
-// This module defines the OptIATA structure.
-// https://www.ietf.org/rfc/rfc8415.txt
+// This module defines the OptIATA structure, as defined by RFC 8415 Section
+// 21.5.
 type OptIATA struct {
 	IaId    [4]byte
 	Options IdentityOptions
@@ -30,19 +30,22 @@ func (op *OptIATA) ToBytes() []byte {
 }
 
 func (op *OptIATA) String() string {
-	return fmt.Sprintf("IATA: {IAID=%v, options=%v}",
-		op.IaId, op.Options)
+	return fmt.Sprintf("%s: {IAID=%#x, Options=%v}", op.Code(), op.IaId, op.Options)
 }
 
-// ParseOptIATA builds an OptIATA structure from a sequence of bytes.  The
-// input data does not include option code and length bytes.
-func ParseOptIATA(data []byte) (*OptIATA, error) {
-	var opt OptIATA
+// LongString returns a multi-line string representation of IATA data.
+func (op *OptIATA) LongString(indentSpace int) string {
+	return fmt.Sprintf("%s: IAID=%#x Options=%v", op.Code(), op.IaId, op.Options.LongString(indentSpace))
+}
+
+// FromBytes builds an OptIATA structure from a sequence of bytes.  The input
+// data does not include option code and length bytes.
+func (op *OptIATA) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
-	buf.ReadBytes(opt.IaId[:])
+	buf.ReadBytes(op.IaId[:])
 
-	if err := opt.Options.FromBytes(buf.ReadAll()); err != nil {
-		return nil, err
+	if err := op.Options.FromBytes(buf.ReadAll()); err != nil {
+		return err
 	}
-	return &opt, buf.FinError()
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_temporaryaddress_test.go b/dhcpv6/option_temporaryaddress_test.go
index de7fe39..693ffa6 100644
--- a/dhcpv6/option_temporaryaddress_test.go
+++ b/dhcpv6/option_temporaryaddress_test.go
@@ -1,38 +1,142 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
 	"net"
+	"reflect"
 	"testing"
 	"time"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestOptIATAParseOptIATA(t *testing.T) {
-	data := []byte{
-		1, 0, 0, 0, // IAID
-		0, 5, 0, 0x18, 0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, 0, 0, 0xb2, 0x7a, 0, 0, 0xc0, 0x8a, // options
-	}
-	opt, err := ParseOptIATA(data)
-	require.NoError(t, err)
-	require.Equal(t, OptionIATA, opt.Code())
-}
+func TestIATAParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want []*OptIATA
+	}{
+		{
+			buf: []byte{
+				0, 4, // IATA option code
+				0, 32, // length
+				1, 0, 0, 0, // IAID
+				0, 5, 0, 0x18, 0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // IPv6
+				0, 0, 0, 2, // PreferredLifetime
+				0, 0, 0, 4, // ValidLifetime
+			},
+			want: []*OptIATA{
+				&OptIATA{
+					IaId: [4]byte{1, 0, 0, 0},
+					Options: IdentityOptions{Options: Options{&OptIAAddress{
+						IPv6Addr:          net.IP{0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0},
+						PreferredLifetime: 2 * time.Second,
+						ValidLifetime:     4 * time.Second,
+						Options:           AddressOptions{Options: Options{}},
+					}}},
+				},
+			},
+		},
+		{
+			buf: []byte{
+				0, 4, // IATA option code
+				0, 32, // length
+				1, 0, 0, 0, // IAID
+				0, 5, 0, 0x18, 0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // IPv6
+				0, 0, 0, 2, // PreferredLifetime
+				0, 0, 0, 4, // ValidLifetime
 
-func TestOptIATAParseOptIATAInvalidLength(t *testing.T) {
-	data := []byte{
-		1, 0, 0, // truncated IAID
-	}
-	_, err := ParseOptIATA(data)
-	require.Error(t, err)
-}
+				0, 4, // IATA option code
+				0, 32, // length
+				1, 2, 3, 4, // IAID
+				0, 5, 0, 0x18, 0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // IPv6
+				0, 0, 0, 2, // PreferredLifetime
+				0, 0, 0, 4, // ValidLifetime
+			},
+			want: []*OptIATA{
+				&OptIATA{
+					IaId: [4]byte{1, 0, 0, 0},
+					Options: IdentityOptions{Options: Options{&OptIAAddress{
+						IPv6Addr:          net.IP{0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0},
+						PreferredLifetime: 2 * time.Second,
+						ValidLifetime:     4 * time.Second,
+						Options:           AddressOptions{Options: Options{}},
+					}}},
+				},
+				&OptIATA{
+					IaId: [4]byte{1, 2, 3, 4},
+					Options: IdentityOptions{Options: Options{&OptIAAddress{
+						IPv6Addr:          net.IP{0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0},
+						PreferredLifetime: 2 * time.Second,
+						ValidLifetime:     4 * time.Second,
+						Options:           AddressOptions{Options: Options{}},
+					}}},
+				},
+			},
+		},
 
-func TestOptIATAParseOptIATAInvalidOptions(t *testing.T) {
-	data := []byte{
-		1, 0, 0, 0, // IAID
-		0, 5, 0, 0x18, 0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, 0, 0, 0xb2, 0x7a, // truncated options
+		{
+			buf:  nil,
+			want: nil,
+		},
+		{
+			buf:  []byte{0, 4, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+		{
+			buf: []byte{
+				0, 4, // IATA option code
+				0, 3, // length
+				1, 0, 0, // IAID too short
+			},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+		{
+			buf: []byte{
+				0, 4, // IATA option code
+				0, 28, // length
+				1, 0, 0, 0, // IAID
+				0, 5, 0, 0x18, 0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, // IPv6
+				0, 0, 0xb2, 0x7a, // PreferredLifetime
+				// Missing ValidLifetime
+			},
+			want: nil,
+			err:  uio.ErrBufferTooShort,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.IATA(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("IATA = %v, want %v", got, tt.want)
+			}
+			var wantOne *OptIATA
+			if len(tt.want) >= 1 {
+				wantOne = tt.want[0]
+			}
+			if got := mo.OneIATA(); !reflect.DeepEqual(got, wantOne) {
+				t.Errorf("OneIATA = %v, want %v", got, wantOne)
+			}
+
+			if len(tt.want) >= 1 {
+				var b MessageOptions
+				for _, iata := range tt.want {
+					b.Add(iata)
+				}
+				got := b.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
-	_, err := ParseOptIATA(data)
-	require.Error(t, err)
 }
 
 func TestOptIATAGetOneOption(t *testing.T) {
@@ -87,37 +191,24 @@ func TestOptIATADelOption(t *testing.T) {
 	require.Equal(t, iana2.Options.Options, Options{&optsc})
 }
 
-func TestOptIATAToBytes(t *testing.T) {
-	opt := OptIATA{
-		IaId: [4]byte{1, 2, 3, 4},
-		Options: IdentityOptions{[]Option{
-			OptElapsedTime(10 * time.Millisecond),
-		}},
-	}
-	expected := []byte{
-		1, 2, 3, 4, // IA ID
-		0, 8, 0, 2, 0x00, 0x01,
-	}
-	require.Equal(t, expected, opt.ToBytes())
-}
-
 func TestOptIATAString(t *testing.T) {
 	data := []byte{
 		1, 0, 0, 0, // IAID
 		0, 5, 0, 0x18, 0x24, 1, 0xdb, 0, 0x30, 0x10, 0xc0, 0x8f, 0xfa, 0xce, 0, 0, 0, 0x44, 0, 0, 0, 0, 0xb2, 0x7a, 0, 0, 0xc0, 0x8a, // options
 	}
-	opt, err := ParseOptIATA(data)
+	var opt OptIATA
+	err := opt.FromBytes(data)
 	require.NoError(t, err)
 
 	str := opt.String()
 	require.Contains(
 		t, str,
-		"IAID=[1 0 0 0]",
+		"IAID=0x01000000",
 		"String() should return the IAID",
 	)
 	require.Contains(
 		t, str,
-		"options={",
+		"Options={",
 		"String() should return a list of options",
 	)
 }
diff --git a/dhcpv6/option_userclass.go b/dhcpv6/option_userclass.go
index e5d7b31..8eeb3d7 100644
--- a/dhcpv6/option_userclass.go
+++ b/dhcpv6/option_userclass.go
@@ -35,20 +35,19 @@ func (op *OptUserClass) String() string {
 	for _, uc := range op.UserClasses {
 		ucStrings = append(ucStrings, string(uc))
 	}
-	return fmt.Sprintf("OptUserClass{userclass=[%s]}", strings.Join(ucStrings, ", "))
+	return fmt.Sprintf("%s: [%s]", op.Code(), strings.Join(ucStrings, ", "))
 }
 
-// ParseOptUserClass builds an OptUserClass structure from a sequence of
-// bytes. The input data does not include option code and length bytes.
-func ParseOptUserClass(data []byte) (*OptUserClass, error) {
-	var opt OptUserClass
+// FromBytes builds an OptUserClass structure from a sequence of bytes. The
+// input data does not include option code and length bytes.
+func (op *OptUserClass) FromBytes(data []byte) error {
 	if len(data) == 0 {
-		return nil, fmt.Errorf("user class option must not be empty")
+		return fmt.Errorf("%w: user class option must not be empty", uio.ErrBufferTooShort)
 	}
 	buf := uio.NewBigEndianBuffer(data)
 	for buf.Has(2) {
 		len := buf.Read16()
-		opt.UserClasses = append(opt.UserClasses, buf.CopyN(int(len)))
+		op.UserClasses = append(op.UserClasses, buf.CopyN(int(len)))
 	}
-	return &opt, buf.FinError()
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_userclass_test.go b/dhcpv6/option_userclass_test.go
index 16fae72..ff2926b 100644
--- a/dhcpv6/option_userclass_test.go
+++ b/dhcpv6/option_userclass_test.go
@@ -1,79 +1,64 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
+	"reflect"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestParseOptUserClass(t *testing.T) {
-	expected := []byte{
-		0, 9, 'l', 'i', 'n', 'u', 'x', 'b', 'o', 'o', 't',
-	}
-	opt, err := ParseOptUserClass(expected)
-	require.NoError(t, err)
-	require.Equal(t, 1, len(opt.UserClasses))
-	require.Equal(t, []byte("linuxboot"), opt.UserClasses[0])
-}
-
-func TestParseOptUserClassMultiple(t *testing.T) {
-	expected := []byte{
-		0, 9, 'l', 'i', 'n', 'u', 'x', 'b', 'o', 'o', 't',
-		0, 4, 't', 'e', 's', 't',
-	}
-	opt, err := ParseOptUserClass(expected)
-	require.NoError(t, err)
-	require.Equal(t, len(opt.UserClasses), 2)
-	require.Equal(t, []byte("linuxboot"), opt.UserClasses[0])
-	require.Equal(t, []byte("test"), opt.UserClasses[1])
-}
-
-func TestParseOptUserClassNone(t *testing.T) {
-	expected := []byte{}
-	_, err := ParseOptUserClass(expected)
-	require.Error(t, err)
-}
-
-func TestOptUserClassToBytes(t *testing.T) {
-	opt := OptUserClass{
-		UserClasses: [][]byte{[]byte("linuxboot")},
-	}
-	data := opt.ToBytes()
-	expected := []byte{
-		0, 9, 'l', 'i', 'n', 'u', 'x', 'b', 'o', 'o', 't',
-	}
-	require.Equal(t, expected, data)
-}
-
-func TestOptUserClassToBytesMultiple(t *testing.T) {
-	opt := OptUserClass{
-		UserClasses: [][]byte{
-			[]byte("linuxboot"),
-			[]byte("test"),
+func TestUserClassParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want [][]byte
+	}{
+		{
+			buf: joinBytes([]byte{
+				0, 15, // User Class
+				0, 19, // length
+				0, 8,
+			}, []byte("bladibla"), []byte{0, 7}, []byte("foo=bar")),
+			want: [][]byte{[]byte("bladibla"), []byte("foo=bar")},
 		},
-	}
-	data := opt.ToBytes()
-	expected := []byte{
-		0, 9, 'l', 'i', 'n', 'u', 'x', 'b', 'o', 'o', 't',
-		0, 4, 't', 'e', 's', 't',
-	}
-	require.Equal(t, expected, data)
-}
-
-func TestOptUserClassParseOptUserClassTooShort(t *testing.T) {
-	buf := []byte{
-		0, 9, 'l', 'i', 'n', 'u', 'x', 'b', 'o', 'o', 't',
-		0, 4, 't', 'e',
-	}
-	_, err := ParseOptUserClass(buf)
-	require.Error(t, err, "ParseOptUserClass() should error if given truncated user classes")
+		{
+			buf: nil,
+		},
+		{
+			buf: []byte{
+				0, 15,
+				0, 0,
+			},
+			err: uio.ErrBufferTooShort,
+		},
+		{
+			buf: []byte{0, 15, 0},
+			err: uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.UserClasses(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("UserClass = %v, want %v", got, tt.want)
+			}
 
-	buf = []byte{
-		0, 9, 'l', 'i', 'n', 'u', 'x', 'b', 'o', 'o', 't',
-		0,
+			if tt.want != nil {
+				var m MessageOptions
+				m.Add(&OptUserClass{UserClasses: tt.want})
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
-	_, err = ParseOptUserClass(buf)
-	require.Error(t, err, "ParseOptUserClass() should error if given a truncated length")
 }
 
 func TestOptUserClassString(t *testing.T) {
@@ -81,13 +66,14 @@ func TestOptUserClassString(t *testing.T) {
 		0, 9, 'l', 'i', 'n', 'u', 'x', 'b', 'o', 'o', 't',
 		0, 4, 't', 'e', 's', 't',
 	}
-	opt, err := ParseOptUserClass(data)
+	var opt OptUserClass
+	err := opt.FromBytes(data)
 	require.NoError(t, err)
 
 	require.Contains(
 		t,
 		opt.String(),
-		"userclass=[linuxboot, test]",
+		"User Class: [linuxboot, test]",
 		"String() should contain the list of user classes",
 	)
 }
diff --git a/dhcpv6/option_vendor_opts.go b/dhcpv6/option_vendor_opts.go
index 7965908..8412fd9 100644
--- a/dhcpv6/option_vendor_opts.go
+++ b/dhcpv6/option_vendor_opts.go
@@ -30,21 +30,23 @@ func (op *OptVendorOpts) ToBytes() []byte {
 
 // String returns a string representation of the VendorOpts data
 func (op *OptVendorOpts) String() string {
-	return fmt.Sprintf("OptVendorOpts{enterprisenum=%v, vendorOpts=%v}",
-		op.EnterpriseNumber, op.VendorOpts,
-	)
+	return fmt.Sprintf("%s: {EnterpriseNumber=%v VendorOptions=%v}", op.Code(), op.EnterpriseNumber, op.VendorOpts)
 }
 
-// ParseOptVendorOpts builds an OptVendorOpts structure from a sequence of bytes.
-// The input data does not include option code and length bytes.
-func ParseOptVendorOpts(data []byte) (*OptVendorOpts, error) {
-	var opt OptVendorOpts
+// LongString returns a string representation of the VendorOpts data
+func (op *OptVendorOpts) LongString(indent int) string {
+	return fmt.Sprintf("%s: EnterpriseNumber=%v VendorOptions=%s", op.Code(), op.EnterpriseNumber, op.VendorOpts.LongString(indent))
+}
+
+// FromBytes builds an OptVendorOpts structure from a sequence of bytes. The
+// input data does not include option code and length bytes.
+func (op *OptVendorOpts) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
-	opt.EnterpriseNumber = buf.Read32()
-	if err := opt.VendorOpts.FromBytesWithParser(buf.ReadAll(), vendParseOption); err != nil {
-		return nil, err
+	op.EnterpriseNumber = buf.Read32()
+	if err := op.VendorOpts.FromBytesWithParser(buf.ReadAll(), vendParseOption); err != nil {
+		return err
 	}
-	return &opt, buf.FinError()
+	return buf.FinError()
 }
 
 // vendParseOption builds a GenericOption from a slice of bytes
diff --git a/dhcpv6/option_vendor_opts_test.go b/dhcpv6/option_vendor_opts_test.go
index f6c2b2a..569b45e 100644
--- a/dhcpv6/option_vendor_opts_test.go
+++ b/dhcpv6/option_vendor_opts_test.go
@@ -1,47 +1,94 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
+	"reflect"
 	"testing"
 
-	"github.com/stretchr/testify/require"
+	"github.com/google/go-cmp/cmp"
+	"github.com/u-root/uio/uio"
 )
 
-func TestOptVendorOpts(t *testing.T) {
-	optData := []byte("Arista;DCS-7304;01.00;HSH14425148")
-	// NOTE: this should be aware of endianness
-	expected := []byte{0xaa, 0xbb, 0xcc, 0xdd}
-	expected = append(expected, []byte{0, 1, //code
-		0, byte(len(optData)), //length
-	}...)
-	expected = append(expected, optData...)
-	expectedOpts := OptVendorOpts{}
-	var vendorOpts []Option
-	expectedOpts.VendorOpts = append(vendorOpts, &OptionGeneric{OptionCode: 1, OptionData: optData})
-	opt, err := ParseOptVendorOpts(expected)
-	require.NoError(t, err)
-	require.Equal(t, uint32(0xaabbccdd), opt.EnterpriseNumber)
-	require.Equal(t, expectedOpts.VendorOpts, opt.VendorOpts)
+func TestVendorOptsParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want []*OptVendorOpts
+	}{
+		{
+			buf: []byte{
+				0, 17, // VendorOpts option
+				0, 10, // length
+				0, 0, 0, 16,
+				0, 5, // type
+				0, 2, // length
+				0xa, 0xb,
 
-	shortData := make([]byte, 1)
-	_, err = ParseOptVendorOpts(shortData)
-	require.Error(t, err)
-}
-
-func TestOptVendorOptsToBytes(t *testing.T) {
-	optData := []byte("Arista;DCS-7304;01.00;HSH14425148")
-	var opts []Option
-	opts = append(opts, &OptionGeneric{OptionCode: 1, OptionData: optData})
-
-	expected := append([]byte{
-		0, 0, 0, 0, // EnterpriseNumber
-		0, 1, // Sub-Option code from vendor
-		0, byte(len(optData)), // Length of optionData only
-	}, optData...)
+				0, 17, // VendorOpts option
+				0, 9, // length
+				0, 0, 0, 14,
+				0, 9, // type
+				0, 1, // length
+				0xa,
+			},
+			want: []*OptVendorOpts{
+				&OptVendorOpts{
+					EnterpriseNumber: 16,
+					VendorOpts: Options{
+						&OptionGeneric{OptionCode: 5, OptionData: []byte{0xa, 0xb}},
+					},
+				},
+				&OptVendorOpts{
+					EnterpriseNumber: 14,
+					VendorOpts: Options{
+						&OptionGeneric{OptionCode: 9, OptionData: []byte{0xa}},
+					},
+				},
+			},
+		},
+		{
+			buf:  nil,
+			want: nil,
+		},
+		{
+			buf:  []byte{0, 17, 0, 1, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+		{
+			buf:  []byte{0, 17, 0},
+			want: nil,
+			err:  uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.VendorOpts(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("VendorOpts = %v, want %v", got, tt.want)
+			}
+			for _, vo := range tt.want {
+				if got := mo.VendorOpt(vo.EnterpriseNumber); !reflect.DeepEqual(got, vo.VendorOpts) {
+					t.Errorf("VendorOpt(%d) = %v, want %v", vo.EnterpriseNumber, got, vo.VendorOpts)
+				}
+			}
+			if got := mo.VendorOpt(100); got != nil {
+				t.Errorf("VendorOpt(100) = %v, not nil", got)
+			}
 
-	opt := OptVendorOpts{
-		EnterpriseNumber: 0000,
-		VendorOpts:       opts,
+			if tt.want != nil {
+				var m MessageOptions
+				for _, opt := range tt.want {
+					m.Add(opt)
+				}
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
-	toBytes := opt.ToBytes()
-	require.Equal(t, expected, toBytes)
 }
diff --git a/dhcpv6/option_vendorclass.go b/dhcpv6/option_vendorclass.go
index 1144974..f85795e 100644
--- a/dhcpv6/option_vendorclass.go
+++ b/dhcpv6/option_vendorclass.go
@@ -1,7 +1,6 @@
 package dhcpv6
 
 import (
-	"errors"
 	"fmt"
 	"strings"
 
@@ -36,21 +35,21 @@ func (op *OptVendorClass) String() string {
 	for _, data := range op.Data {
 		vcStrings = append(vcStrings, string(data))
 	}
-	return fmt.Sprintf("OptVendorClass{enterprisenum=%d, data=[%s]}", op.EnterpriseNumber, strings.Join(vcStrings, ", "))
+	return fmt.Sprintf("%s: {EnterpriseNumber=%d Data=[%s]}", op.Code(), op.EnterpriseNumber, strings.Join(vcStrings, ", "))
 }
 
-// ParseOptVendorClass builds an OptVendorClass structure from a sequence of
-// bytes. The input data does not include option code and length bytes.
-func ParseOptVendorClass(data []byte) (*OptVendorClass, error) {
-	var opt OptVendorClass
+// FromBytes builds an OptVendorClass structure from a sequence of bytes. The
+// input data does not include option code and length bytes.
+func (op *OptVendorClass) FromBytes(data []byte) error {
 	buf := uio.NewBigEndianBuffer(data)
-	opt.EnterpriseNumber = buf.Read32()
+	*op = OptVendorClass{}
+	op.EnterpriseNumber = buf.Read32()
 	for buf.Has(2) {
 		len := buf.Read16()
-		opt.Data = append(opt.Data, buf.CopyN(int(len)))
+		op.Data = append(op.Data, buf.CopyN(int(len)))
 	}
-	if len(opt.Data) < 1 {
-		return nil, errors.New("ParseOptVendorClass: at least one vendor class data is required")
+	if len(op.Data) == 0 {
+		return fmt.Errorf("%w: vendor class data should not be empty", uio.ErrBufferTooShort)
 	}
-	return &opt, buf.FinError()
+	return buf.FinError()
 }
diff --git a/dhcpv6/option_vendorclass_test.go b/dhcpv6/option_vendorclass_test.go
index 685b8b0..5fbc0e6 100644
--- a/dhcpv6/option_vendorclass_test.go
+++ b/dhcpv6/option_vendorclass_test.go
@@ -1,71 +1,88 @@
 package dhcpv6
 
 import (
+	"errors"
+	"fmt"
+	"reflect"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/require"
+	"github.com/u-root/uio/uio"
 )
 
-func TestParseOptVendorClass(t *testing.T) {
-	data := []byte{
-		0xaa, 0xbb, 0xcc, 0xdd, // EnterpriseNumber
-		0, 10, 'H', 'T', 'T', 'P', 'C', 'l', 'i', 'e', 'n', 't',
-		0, 4, 't', 'e', 's', 't',
-	}
-	opt, err := ParseOptVendorClass(data)
-	require.NoError(t, err)
-	require.Equal(t, OptionVendorClass, opt.Code())
-	require.Equal(t, 2, len(opt.Data))
-	require.Equal(t, uint32(0xaabbccdd), opt.EnterpriseNumber)
-	require.Equal(t, []byte("HTTPClient"), opt.Data[0])
-	require.Equal(t, []byte("test"), opt.Data[1])
-}
-
-func TestOptVendorClassToBytes(t *testing.T) {
-	opt := OptVendorClass{
-		EnterpriseNumber: uint32(0xaabbccdd),
-		Data: [][]byte{
-			[]byte("HTTPClient"),
-			[]byte("test"),
+func TestVendorClassParseAndGetter(t *testing.T) {
+	for i, tt := range []struct {
+		buf  []byte
+		err  error
+		want []*OptVendorClass
+	}{
+		{
+			buf: []byte{
+				0, 16, // Vendor Class
+				0, 14, // length
+				0, 0, 0, 16,
+				0, 4,
+				'S', 'L', 'A', 'M',
+				0, 2,
+				'h', 'h',
+			},
+			want: []*OptVendorClass{
+				&OptVendorClass{
+					EnterpriseNumber: 16,
+					Data:             [][]byte{[]byte("SLAM"), []byte("hh")},
+				},
+			},
 		},
-	}
-	data := opt.ToBytes()
-	expected := []byte{
-		0xaa, 0xbb, 0xcc, 0xdd, // EnterpriseNumber
-		0, 10, 'H', 'T', 'T', 'P', 'C', 'l', 'i', 'e', 'n', 't',
-		0, 4, 't', 'e', 's', 't',
-	}
-	require.Equal(t, expected, data)
-}
-
-func TestOptVendorClassParseOptVendorClassMalformed(t *testing.T) {
-	buf := []byte{
-		0xaa, 0xbb, // truncated EnterpriseNumber
-	}
-	_, err := ParseOptVendorClass(buf)
-	require.Error(t, err, "ParseOptVendorClass() should error if given truncated EnterpriseNumber")
-
-	buf = []byte{
-		0xaa, 0xbb, 0xcc, 0xdd, // EnterpriseNumber
-	}
-	_, err = ParseOptVendorClass(buf)
-	require.Error(t, err, "ParseOptVendorClass() should error if given no vendor classes")
-
-	buf = []byte{
-		0xaa, 0xbb, 0xcc, 0xdd, // EnterpriseNumber
-		0, 9, 'l', 'i', 'n', 'u', 'x', 'b', 'o', 'o', 't',
-		0, 4, 't', 'e',
-	}
-	_, err = ParseOptVendorClass(buf)
-	require.Error(t, err, "ParseOptVendorClass() should error if given truncated vendor classes")
+		{
+			buf: []byte{
+				0, 16,
+				0, 0,
+			},
+			err: uio.ErrBufferTooShort,
+		},
+		{
+			buf: []byte{
+				0, 16,
+				0, 4,
+				0, 0, 0, 6,
+			},
+			err: uio.ErrBufferTooShort,
+		},
+		{
+			buf: []byte{0, 16, 0},
+			err: uio.ErrUnreadBytes,
+		},
+	} {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			var mo MessageOptions
+			if err := mo.FromBytes(tt.buf); !errors.Is(err, tt.err) {
+				t.Errorf("FromBytes = %v, want %v", err, tt.err)
+			}
+			if got := mo.VendorClasses(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("VendorClass = %v, want %v", got, tt.want)
+			}
+			for _, v := range tt.want {
+				if got := mo.VendorClass(v.EnterpriseNumber); !reflect.DeepEqual(got, v.Data) {
+					t.Errorf("VendorClass(%d) = %v, want %v", v.EnterpriseNumber, got, v.Data)
+				}
+			}
+			if got := mo.VendorClass(100); got != nil {
+				t.Errorf("VendorClass(100) = %v, want nil", got)
+			}
 
-	buf = []byte{
-		0xaa, 0xbb, 0xcc, 0xdd, // EnterpriseNumber
-		0, 9, 'l', 'i', 'n', 'u', 'x', 'b', 'o', 'o', 't',
-		0,
+			if tt.want != nil {
+				var m MessageOptions
+				for _, o := range tt.want {
+					m.Add(o)
+				}
+				got := m.ToBytes()
+				if diff := cmp.Diff(tt.buf, got); diff != "" {
+					t.Errorf("ToBytes mismatch (-want, +got): %s", diff)
+				}
+			}
+		})
 	}
-	_, err = ParseOptVendorClass(buf)
-	require.Error(t, err, "ParseOptVendorClass() should error if given a truncated length")
 }
 
 func TestOptVendorClassString(t *testing.T) {
@@ -74,20 +91,21 @@ func TestOptVendorClassString(t *testing.T) {
 		0, 9, 'l', 'i', 'n', 'u', 'x', 'b', 'o', 'o', 't',
 		0, 4, 't', 'e', 's', 't',
 	}
-	opt, err := ParseOptVendorClass(data)
+	var opt OptVendorClass
+	err := opt.FromBytes(data)
 	require.NoError(t, err)
 
 	str := opt.String()
 	require.Contains(
 		t,
 		str,
-		"enterprisenum=2864434397",
+		"EnterpriseNumber=2864434397",
 		"String() should contain the enterprisenum",
 	)
 	require.Contains(
 		t,
 		str,
-		"data=[linuxboot, test]",
+		"Data=[linuxboot, test]",
 		"String() should contain the list of vendor classes",
 	)
 }
diff --git a/dhcpv6/options.go b/dhcpv6/options.go
index f8445bb..552abd0 100644
--- a/dhcpv6/options.go
+++ b/dhcpv6/options.go
@@ -2,6 +2,7 @@ package dhcpv6
 
 import (
 	"fmt"
+	"strings"
 
 	"github.com/u-root/uio/uio"
 )
@@ -11,6 +12,7 @@ type Option interface {
 	Code() OptionCode
 	ToBytes() []byte
 	String() string
+	FromBytes([]byte) error
 }
 
 type OptionGeneric struct {
@@ -27,98 +29,126 @@ func (og *OptionGeneric) ToBytes() []byte {
 }
 
 func (og *OptionGeneric) String() string {
-	return fmt.Sprintf("%s -> %v", og.OptionCode, og.OptionData)
+	if len(og.OptionData) == 0 {
+		return og.OptionCode.String()
+	}
+	return fmt.Sprintf("%s: %v", og.OptionCode, og.OptionData)
+}
+
+// FromBytes resets OptionData to p.
+func (og *OptionGeneric) FromBytes(p []byte) error {
+	og.OptionData = append([]byte(nil), p...)
+	return nil
 }
 
 // ParseOption parses data according to the given code.
+//
+// Parse a sequence of bytes as a single DHCPv6 option.
+// Returns the option structure, or an error if any.
 func ParseOption(code OptionCode, optData []byte) (Option, error) {
-	// Parse a sequence of bytes as a single DHCPv6 option.
-	// Returns the option structure, or an error if any.
-	var (
-		err error
-		opt Option
-	)
+	var opt Option
 	switch code {
 	case OptionClientID:
-		opt, err = parseOptClientID(optData)
+		opt = &optClientID{}
 	case OptionServerID:
-		opt, err = parseOptServerID(optData)
+		opt = &optServerID{}
 	case OptionIANA:
-		opt, err = ParseOptIANA(optData)
+		opt = &OptIANA{}
 	case OptionIATA:
-		opt, err = ParseOptIATA(optData)
+		opt = &OptIATA{}
 	case OptionIAAddr:
-		opt, err = ParseOptIAAddress(optData)
+		opt = &OptIAAddress{}
 	case OptionORO:
-		var o optRequestedOption
-		err = o.FromBytes(optData)
-		opt = &o
+		opt = &optRequestedOption{}
 	case OptionElapsedTime:
-		opt, err = parseOptElapsedTime(optData)
+		opt = &optElapsedTime{}
 	case OptionRelayMsg:
-		opt, err = parseOptRelayMsg(optData)
+		opt = &optRelayMsg{}
 	case OptionStatusCode:
-		opt, err = ParseOptStatusCode(optData)
+		opt = &OptStatusCode{}
 	case OptionUserClass:
-		opt, err = ParseOptUserClass(optData)
+		opt = &OptUserClass{}
 	case OptionVendorClass:
-		opt, err = ParseOptVendorClass(optData)
+		opt = &OptVendorClass{}
 	case OptionVendorOpts:
-		opt, err = ParseOptVendorOpts(optData)
+		opt = &OptVendorOpts{}
 	case OptionInterfaceID:
-		opt, err = parseOptInterfaceID(optData)
+		opt = &optInterfaceID{}
 	case OptionDNSRecursiveNameServer:
-		opt, err = parseOptDNS(optData)
+		opt = &optDNS{}
 	case OptionDomainSearchList:
-		opt, err = parseOptDomainSearchList(optData)
+		opt = &optDomainSearchList{}
 	case OptionIAPD:
-		opt, err = ParseOptIAPD(optData)
+		opt = &OptIAPD{}
 	case OptionIAPrefix:
-		opt, err = ParseOptIAPrefix(optData)
+		opt = &OptIAPrefix{}
 	case OptionInformationRefreshTime:
-		opt, err = parseOptInformationRefreshTime(optData)
+		opt = &optInformationRefreshTime{}
 	case OptionRemoteID:
-		opt, err = ParseOptRemoteID(optData)
+		opt = &OptRemoteID{}
 	case OptionFQDN:
-		opt, err = ParseOptFQDN(optData)
+		opt = &OptFQDN{}
 	case OptionNTPServer:
-		opt, err = ParseOptNTPServer(optData)
+		opt = &OptNTPServer{}
 	case OptionBootfileURL:
-		opt, err = parseOptBootFileURL(optData)
+		opt = &optBootFileURL{}
 	case OptionBootfileParam:
-		opt, err = parseOptBootFileParam(optData)
+		opt = &optBootFileParam{}
 	case OptionClientArchType:
-		opt, err = parseOptClientArchType(optData)
+		opt = &optClientArchType{}
 	case OptionNII:
-		var o OptNetworkInterfaceID
-		err = o.FromBytes(optData)
-		opt = &o
+		opt = &OptNetworkInterfaceID{}
 	case OptionClientLinkLayerAddr:
-		opt, err = parseOptClientLinkLayerAddress(optData)
+		opt = &optClientLinkLayerAddress{}
 	case OptionDHCPv4Msg:
-		opt, err = ParseOptDHCPv4Msg(optData)
+		opt = &OptDHCPv4Msg{}
 	case OptionDHCP4oDHCP6Server:
-		opt, err = ParseOptDHCP4oDHCP6Server(optData)
+		opt = &OptDHCP4oDHCP6Server{}
 	case Option4RD:
-		opt, err = ParseOpt4RD(optData)
+		opt = &Opt4RD{}
 	case Option4RDMapRule:
-		opt, err = ParseOpt4RDMapRule(optData)
+		opt = &Opt4RDMapRule{}
 	case Option4RDNonMapRule:
-		opt, err = ParseOpt4RDNonMapRule(optData)
+		opt = &Opt4RDNonMapRule{}
 	case OptionRelayPort:
-		opt, err = parseOptRelayPort(optData)
+		opt = &optRelayPort{}
 	default:
-		opt = &OptionGeneric{OptionCode: code, OptionData: optData}
-	}
-	if err != nil {
-		return nil, err
+		opt = &OptionGeneric{OptionCode: code}
 	}
-	return opt, nil
+	return opt, opt.FromBytes(optData)
+}
+
+type longStringer interface {
+	LongString(spaceIndent int) string
 }
 
 // Options is a collection of options.
 type Options []Option
 
+// LongString prints options with indentation of at least spaceIndent spaces.
+func (o Options) LongString(spaceIndent int) string {
+	indent := strings.Repeat(" ", spaceIndent)
+	var s strings.Builder
+	if len(o) == 0 {
+		s.WriteString("[]")
+	} else {
+		s.WriteString("[\n")
+		for _, opt := range o {
+			s.WriteString(indent)
+			s.WriteString("  ")
+			if ls, ok := opt.(longStringer); ok {
+				s.WriteString(ls.LongString(spaceIndent + 2))
+			} else {
+				s.WriteString(opt.String())
+			}
+			s.WriteString("\n")
+		}
+		s.WriteString(indent)
+		s.WriteString("]")
+	}
+	return s.String()
+}
+
 // Get returns all options matching the option code.
 func (o Options) Get(code OptionCode) []Option {
 	var ret []Option
@@ -194,7 +224,9 @@ type OptionParser func(code OptionCode, data []byte) (Option, error)
 // FromBytesWithParser parses Options from byte sequences using the parsing
 // function that is passed in as a paremeter
 func (o *Options) FromBytesWithParser(data []byte, parser OptionParser) error {
-	*o = make(Options, 0, 10)
+	if *o == nil {
+		*o = make(Options, 0, 10)
+	}
 	if len(data) == 0 {
 		// no options, no party
 		return nil
diff --git a/dhcpv6/prettyprint_test.go b/dhcpv6/prettyprint_test.go
new file mode 100644
index 0000000..def212c
--- /dev/null
+++ b/dhcpv6/prettyprint_test.go
@@ -0,0 +1,96 @@
+package dhcpv6
+
+import (
+	"net"
+	"testing"
+	"time"
+
+	"github.com/insomniacslk/dhcp/dhcpv4"
+	"github.com/insomniacslk/dhcp/iana"
+)
+
+func TestPrint(t *testing.T) {
+	m4, _ := dhcpv4.NewDiscovery(net.HardwareAddr{0x1, 0x2, 0xde, 0xad, 0xbe, 0xef})
+
+	m, _ := NewSolicit(net.HardwareAddr{0x1, 0x2, 0xde, 0xad, 0xbe, 0xef}, WithRapidCommit)
+
+	oneiana := m.Options.OneIANA()
+	iaaddr := &OptIAAddress{IPv6Addr: net.ParseIP("fe80::1")}
+	iaaddr.Options.Add(&OptStatusCode{StatusCode: iana.StatusSuccess, StatusMessage: "yes"})
+	oneiana.Options.Add(iaaddr)
+
+	oneiata := &OptIATA{}
+	oneiata.Options.Add(iaaddr)
+
+	fourrd := &Opt4RD{}
+	fourrd.Add(&Opt4RDMapRule{
+		Prefix4: net.IPNet{
+			IP:   net.IP{123, 123, 0, 0},
+			Mask: net.CIDRMask(16, 32),
+		},
+		Prefix6: net.IPNet{
+			IP:   net.ParseIP("fc80::"),
+			Mask: net.CIDRMask(64, 128),
+		},
+	})
+	fourrd.Add(&Opt4RDNonMapRule{
+		HubAndSpoke: true,
+	})
+
+	iapd := &OptIAPD{
+		IaId: [4]byte{0x1, 0x2, 0x3, 0x4},
+	}
+	iaprefix := &OptIAPrefix{
+		Prefix: &net.IPNet{
+			IP:   net.ParseIP("fc80::"),
+			Mask: net.CIDRMask(64, 128),
+		},
+	}
+	iaprefix.Options.Add(&OptStatusCode{StatusCode: iana.StatusSuccess, StatusMessage: "yeah whatever"})
+	iapd.Options.Add(iaprefix)
+
+	vendorOpts := &OptVendorOpts{
+		EnterpriseNumber: 123,
+	}
+	vendorOpts.VendorOpts.Add(&OptionGeneric{OptionCode: 400, OptionData: []byte("foobar")})
+
+	adv, _ := NewReplyFromMessage(m,
+		WithOption(OptClientArchType(iana.INTEL_X86PC, iana.EFI_X86_64)),
+		WithOption(OptBootFileURL("http://foobar")),
+		WithOption(OptBootFileParam("loglevel=10", "uroot.nohwrng")),
+		WithOption(OptClientLinkLayerAddress(iana.HWTypeEthernet, net.HardwareAddr{0x1, 0x2, 0xbe, 0xef, 0xde, 0xad})),
+		WithOption(fourrd),
+		WithOption(&OptDHCPv4Msg{m4}),
+		WithOption(&OptDHCP4oDHCP6Server{[]net.IP{net.ParseIP("fe81::1")}}),
+		WithOption(OptDNS(net.ParseIP("fe82::1"))),
+		WithOption(iapd),
+		WithOption(OptInformationRefreshTime(1*time.Second)),
+		WithOption(OptInterfaceID([]byte{0x1, 0x2})),
+		WithOption(&OptNetworkInterfaceID{
+			Typ:   NII_PXE_GEN_I,
+			Major: 1,
+		}),
+		WithOption(OptRelayPort(1026)),
+		WithOption(&OptRemoteID{EnterpriseNumber: 300, RemoteID: []byte{0xde, 0xad, 0xbe, 0xed}}),
+		WithOption(OptRequestedOption(OptionBootfileURL, OptionBootfileParam)),
+		WithOption(OptServerID(&DUIDLL{HWType: iana.HWTypeEthernet, LinkLayerAddr: net.HardwareAddr{0x1, 0x2, 0x3, 0x4, 0x5, 0x6}})),
+		WithOption(&OptUserClass{[][]byte{[]byte("foo"), []byte("bar")}}),
+		WithOption(oneiana),
+		WithOption(oneiata),
+		WithOption(&OptVendorClass{EnterpriseNumber: 300, Data: [][]byte{[]byte("foo"), []byte("bar")}}),
+		WithOption(vendorOpts),
+	)
+	t.Log(adv.String())
+	t.Log(adv.Summary())
+
+	relayfw := RelayMessage{
+		MessageType: MessageTypeRelayForward,
+	}
+	relayfw.Options.Add(OptRelayMessage(adv))
+	relayfw.Options.Add(&OptRemoteID{
+		EnterpriseNumber: 0x123,
+		RemoteID:         []byte{0x1, 0x2},
+	})
+	t.Log(relayfw.String())
+	t.Log(relayfw.Summary())
+}
diff --git a/dhcpv6/types.go b/dhcpv6/types.go
index 7c4052f..be5d925 100644
--- a/dhcpv6/types.go
+++ b/dhcpv6/types.go
@@ -234,10 +234,10 @@ const (
 
 // optionCodeToString maps DHCPv6 OptionCodes to human-readable strings.
 var optionCodeToString = map[OptionCode]string{
-	OptionClientID:                              "Client Identifier",
-	OptionServerID:                              "Server Identifier",
-	OptionIANA:                                  "IA_NA",
-	OptionIATA:                                  "IA_TA",
+	OptionClientID:                              "Client ID",
+	OptionServerID:                              "Server ID",
+	OptionIANA:                                  "IANA",
+	OptionIATA:                                  "IATA",
 	OptionIAAddr:                                "IA IP Address",
 	OptionORO:                                   "Requested Options",
 	OptionPreference:                            "Preference",
@@ -255,9 +255,9 @@ var optionCodeToString = map[OptionCode]string{
 	OptionReconfAccept:                          "Reconfig Accept",
 	OptionSIPServersDomainNameList:              "SIP Servers Domain Name List",
 	OptionSIPServersIPv6AddressList:             "SIP Servers IPv6 Address List",
-	OptionDNSRecursiveNameServer:                "DNS Recursive Name Server",
+	OptionDNSRecursiveNameServer:                "DNS",
 	OptionDomainSearchList:                      "Domain Search List",
-	OptionIAPD:                                  "IA_PD",
+	OptionIAPD:                                  "IAPD",
 	OptionIAPrefix:                              "IA Prefix",
 	OptionNISServers:                            "NIS Servers",
 	OptionNISPServers:                           "NISP Servers",
@@ -284,7 +284,7 @@ var optionCodeToString = map[OptionCode]string{
 	OptionMIPv6VisitedHomeNetworkInformation:    "MIPv6 Visited Home Network Information",
 	OptionLoSTServer:                            "LoST Server",
 	OptionCAPWAPAccessControllerAddresses:       "CAPWAP Access Controller Addresses",
-	OptionRelayID:                               "RELAY_ID",
+	OptionRelayID:                               "Relay ID",
 	OptionIPv6AddressMOS:                        "OPTION-IPv6_Address-MoS",
 	OptionIPv6FQDNMOS:                           "OPTION-IPv6-FQDN-MoS",
 	OptionNTPServer:                             "NTP Server",
@@ -328,9 +328,9 @@ var optionCodeToString = map[OptionCode]string{
 	OptionS46ContMapE:                             "Softwire46 MAP-E Container",
 	OptionS46ContMapT:                             "Softwire46 MAP-T Container",
 	OptionS46ContLW:                               "Softwire46 Lightweight 4over6 Container",
-	Option4RD:                                     "IPv4 Residual Deployment",
-	Option4RDMapRule:                              "IPv4 Residual Deployment Mapping Rule",
-	Option4RDNonMapRule:                           "IPv4 Residual Deployment Non-Mapping Rule",
+	Option4RD:                                     "4RD",
+	Option4RDMapRule:                              "4RD Mapping Rule",
+	Option4RDNonMapRule:                           "4RD Non-Mapping Rule",
 	OptionLQBaseTime:                              "Leasequery Server Base time",
 	OptionLQStartTime:                             "Leasequery Server Query Start Time",
 	OptionLQEndTime:                               "Leasequery Server Query End Time",
diff --git a/dhcpv6/ztpv6/parse_vendor_options.go b/dhcpv6/ztpv6/parse_vendor_options.go
index b3ce55b..f1508fd 100644
--- a/dhcpv6/ztpv6/parse_vendor_options.go
+++ b/dhcpv6/ztpv6/parse_vendor_options.go
@@ -81,10 +81,12 @@ func ParseVendorData(packet dhcpv6.DHCPv6) (*VendorData, error) {
 			if len(v) < 3 {
 				return nil, errVendorOptionMalformed
 			}
-			duid := packet.(*dhcpv6.Message).Options.ClientID()
 			vd.VendorName = iana.EnterpriseIDCienaCorporation.String()
 			vd.Model = v[1] + "-" + v[2]
-			vd.Serial = string(duid.EnterpriseIdentifier)
+			duid := packet.(*dhcpv6.Message).Options.ClientID()
+			if enterpriseDUID, ok := duid.(*dhcpv6.DUIDEN); ok {
+				vd.Serial = string(enterpriseDUID.EnterpriseIdentifier)
+			}
 			return &vd, nil
 		}
 	}
diff --git a/dhcpv6/ztpv6/parse_vendor_options_test.go b/dhcpv6/ztpv6/parse_vendor_options_test.go
index 03ff8cf..cfbfdde 100644
--- a/dhcpv6/ztpv6/parse_vendor_options_test.go
+++ b/dhcpv6/ztpv6/parse_vendor_options_test.go
@@ -64,7 +64,7 @@ func TestParseVendorDataWithVendorClass(t *testing.T) {
 	tt := []struct {
 		name     string
 		vc       string
-		clientId *dhcpv6.Duid
+		clientId dhcpv6.DUID
 		want     *VendorData
 		fail     bool
 	}{
@@ -84,8 +84,7 @@ func TestParseVendorDataWithVendorClass(t *testing.T) {
 		{
 			name: "Ciena",
 			vc:   "1271-23422Z11-123",
-			clientId: &dhcpv6.Duid{
-				Type:                 dhcpv6.DUID_EN,
+			clientId: &dhcpv6.DUIDEN{
 				EnterpriseIdentifier: []byte("001234567"),
 			},
 			want: &VendorData{VendorName: iana.EnterpriseIDCienaCorporation.String(), Model: "23422Z11-123", Serial: "001234567"},
@@ -102,7 +101,7 @@ func TestParseVendorDataWithVendorClass(t *testing.T) {
 			packet.AddOption(&dhcpv6.OptVendorClass{
 				EnterpriseNumber: 0000, Data: [][]byte{[]byte(tc.vc)}})
 			if tc.clientId != nil {
-				packet.AddOption(dhcpv6.OptClientID(*tc.clientId))
+				packet.AddOption(dhcpv6.OptClientID(tc.clientId))
 			}
 			vd, err := ParseVendorData(packet)
 			if err != nil && !tc.fail {
diff --git a/examples/packetcrafting6/main.go b/examples/packetcrafting6/main.go
index f2c1855..13e1c22 100644
--- a/examples/packetcrafting6/main.go
+++ b/examples/packetcrafting6/main.go
@@ -41,9 +41,8 @@ func main() {
 	if err != nil {
 		log.Fatal(err)
 	}
-	duid := dhcpv6.Duid{
-		Type:          dhcpv6.DUID_LLT,
-		HwType:        iana.HWTypeEthernet,
+	duid := &dhcpv6.DUIDLLT{
+		HWType:        iana.HWTypeEthernet,
 		Time:          dhcpv6.GetTime(),
 		LinkLayerAddr: mac,
 	}
diff --git a/go.mod b/go.mod
index d53b0e4..1f7e78f 100644
--- a/go.mod
+++ b/go.mod
@@ -1,17 +1,28 @@
 module github.com/insomniacslk/dhcp
 
-go 1.13
+go 1.18
 
 require (
 	github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72
+	github.com/google/go-cmp v0.5.9
 	github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714
 	github.com/jsimonetti/rtnetlink v0.0.0-20201110080708-d2c240429e6c
-	github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7
 	github.com/mdlayher/netlink v1.1.1
-	github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065
-	github.com/smartystreets/goconvey v1.6.4 // indirect
+	github.com/mdlayher/packet v1.1.1
 	github.com/stretchr/testify v1.6.1
-	github.com/u-root/uio v0.0.0-20210528114334-82958018845c
-	golang.org/x/net v0.0.0-20201110031124-69a78807bb2b
-	golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea
+	github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923
+	golang.org/x/net v0.7.0
+	golang.org/x/sys v0.5.0
+)
+
+require (
+	github.com/davecgh/go-spew v1.1.0 // indirect
+	github.com/josharian/native v1.1.0 // indirect
+	github.com/mdlayher/socket v0.4.0 // indirect
+	github.com/pierrec/lz4/v4 v4.1.14 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/smartystreets/goconvey v1.6.4 // indirect
+	github.com/stretchr/objx v0.1.0 // indirect
+	golang.org/x/sync v0.1.0 // indirect
+	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
 )
diff --git a/go.sum b/go.sum
index a89fe9d..058dbef 100644
--- a/go.sum
+++ b/go.sum
@@ -3,15 +3,18 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
 github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72 h1:0eU/faU2oDIB2BkQVM02hgRLJjGzzUuRf19HUhp0394=
 github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72/go.mod h1:PjfxuH4FZdUyfMdtBio2lsRr1AKEaVPwelzuHuh8Lqc=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
 github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8=
 github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis=
+github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
+github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
+github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
 github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw=
 github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ=
 github.com/jsimonetti/rtnetlink v0.0.0-20201009170750-9c6f07d100c1/go.mod h1:hqoO/u39cqLeBLebZ8fWdE96O7FxrAsRYhnVOdgHxok=
@@ -19,16 +22,17 @@ github.com/jsimonetti/rtnetlink v0.0.0-20201110080708-d2c240429e6c h1:7cpGGTQO6+
 github.com/jsimonetti/rtnetlink v0.0.0-20201110080708-d2c240429e6c/go.mod h1:huN4d1phzjhlOsNIjFsw2SVRbwIHj3fJDMEU2SDPTmg=
 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
-github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7 h1:lez6TS6aAau+8wXUP3G9I3TGlmPFEq2CTxBaRqY6AGE=
-github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7/go.mod h1:U6ZQobyTjI/tJyq2HG+i/dfSoFUt8/aZCM+GKtmFk/Y=
 github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA=
 github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M=
 github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY=
 github.com/mdlayher/netlink v1.1.1 h1:VqG+Voq9V4uZ+04vjIrcSCWDpf91B1xxbP4QBUmUJE8=
 github.com/mdlayher/netlink v1.1.1/go.mod h1:WTYpFb/WTvlRJAyKhZL5/uy69TDDpHHu2VZmb2XgV7o=
-github.com/mdlayher/raw v0.0.0-20190606142536-fef19f00fc18/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg=
-github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 h1:aFkJ6lx4FPip+S+Uw4aTegFMct9shDvP+79PsSxpm3w=
-github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg=
+github.com/mdlayher/packet v1.1.1 h1:7Fv4OEMYqPl7//uBm04VgPpnSNi8fbBZznppgh6WMr8=
+github.com/mdlayher/packet v1.1.1/go.mod h1:DRvYY5mH4M4lUqAnMg04E60U4fjUKMZ/4g2cHElZkKo=
+github.com/mdlayher/socket v0.4.0 h1:280wsy40IC9M9q1uPGcLBwXpcTQDtoGwVt+BNoITxIw=
+github.com/mdlayher/socket v0.4.0/go.mod h1:xxFqz5GRCUN3UEOm9CZqEJsAbe1C8OwSK46NlmWuVoc=
+github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
+github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
@@ -39,39 +43,37 @@ github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/u-root/uio v0.0.0-20210528114334-82958018845c h1:BFvcl34IGnw8yvJi8hlqLFo9EshRInwWBs2M5fGWzQA=
-github.com/u-root/uio v0.0.0-20210528114334-82958018845c/go.mod h1:LpEX5FO/cB+WF4TYGY1V5qktpaZLkKkSegbr0V4eYXA=
+github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA=
+github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190419010253-1f3472d942ba/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
 golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME=
-golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190606122018-79a91cf218c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea h1:+WiDlPBBaO+h9vPNZi8uJ3k4BkKQB7Iow3aqwHVA5hI=
-golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/netboot/netboot.go b/netboot/netboot.go
index b32f69e..adfc7f3 100644
--- a/netboot/netboot.go
+++ b/netboot/netboot.go
@@ -48,7 +48,6 @@ func RequestNetbootv6(ifname string, timeout time.Duration, retries int, modifie
 		if err != nil {
 			log.Printf("Client.Exchange failed: %v", err)
 			if i >= retries {
-				// don't wait at the end of the last attempt
 				return nil, fmt.Errorf("netboot failed after %d attempts: %v", retries+1, err)
 			}
 			log.Printf("sleeping %v before retrying", delay)
@@ -80,8 +79,7 @@ func RequestNetbootv4(ifname string, timeout time.Duration, retries int, modifie
 			log.Printf("Client.Exchange failed: %v", err)
 			log.Printf("sleeping %v before retrying", delay)
 			if i >= retries {
-				// don't wait at the end of the last attempt
-				break
+				return nil, fmt.Errorf("netboot failed after %d attempts: %v", retries+1, err)
 			}
 			sleeper(delay)
 			// TODO add random splay
diff --git a/rfc1035label/label.go b/rfc1035label/label.go
index 042c17b..f727ec6 100644
--- a/rfc1035label/label.go
+++ b/rfc1035label/label.go
@@ -89,6 +89,10 @@ func FromBytes(data []byte) (*Labels, error) {
 	return &l, nil
 }
 
+// ErrBufferTooShort is returned when the label cannot be parsed due to a wrong
+// length or missing bytes.
+var ErrBufferTooShort = errors.New("rfc1035label: buffer too short")
+
 // fromBytes decodes a serialized stream and returns a list of labels
 func labelsFromBytes(buf []byte) ([]string, error) {
 	var (
@@ -132,7 +136,7 @@ func labelsFromBytes(buf []byte) ([]string, error) {
 			pos = off
 		} else {
 			if pos+length > len(buf) {
-				return nil, errors.New("rfc1035label: buffer too short")
+				return nil, ErrBufferTooShort
 			}
 			chunk = string(buf[pos : pos+length])
 			if label != "" {

More details

Full run details

Historical runs