Skip to content

Commit

Permalink
add ipv6 support
Browse files Browse the repository at this point in the history
  • Loading branch information
wildum committed Aug 28, 2023
1 parent 0f8917b commit 6e782ca
Show file tree
Hide file tree
Showing 4 changed files with 295 additions and 54 deletions.
171 changes: 117 additions & 54 deletions advertise/addr.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,99 +5,162 @@ package advertise
import (
"fmt"
"net"
"net/netip"

"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
)

// DefaultInterfaces is a default list of common interfaces that are used for
// local network traffic for Unix-like platforms.
var DefaultInterfaces = []string{"eth0", "en0"}

// FirstAddress returns the first IPv4 address from the given interface names.
// Addresses used for APIPA will be ignored if possible.
func FirstAddress(interfaces []string) (net.IP, error) {
// FirstAddress returns the first IPv4/IPv6 address from the given interface names.
// Link-local unicast addresses will be ignored if possible.
func FirstAddress(interfaces []string) (string, error) {
return firstAddress(interfaces, getInterfaceAddresses, net.Interfaces)
}

// NetworkInterfaceAddressGetter matches the signature of net.InterfaceByName() to allow for test mocks.
type NetworkInterfaceAddressGetter func(name string) ([]netip.Addr, error)

// InterfaceLister matches the signature of net.Interfaces() to allow for test mocks.
type InterfaceLister func() ([]net.Interface, error)

// FirstAddress returns the first IPv4/IPv6 address from the given interface names.
// Link-local unicast addresses will be ignored if possible.
func firstAddress(interfaces []string, interfaceAddrsFunc NetworkInterfaceAddressGetter, interfaceLister InterfaceLister) (string, error) {
var (
errs *multierror.Error
privateIP net.IP
errs *multierror.Error
bestIP netip.Addr
)

if len(interfaces) == 1 && interfaces[0] == "all" {
infs, err := interfaceLister()
if err != nil {
return "", fmt.Errorf("failed to get interface list: %w", err)
}
interfaces = make([]string, len(infs))
for i, v := range infs {
interfaces[i] = v.Name
}
}

for _, ifaceName := range interfaces {
iface, err := net.InterfaceByName(ifaceName)
addrs, err := interfaceAddrsFunc(ifaceName)
if err != nil {
err = fmt.Errorf("interface %q: %w", ifaceName, err)
errs = multierror.Append(errs, err)
continue
}

addrs, err := iface.Addrs()
if err != nil {
err = fmt.Errorf("interface %q addrs: %w", ifaceName, err)
errs = multierror.Append(errs, err)
continue
} else if len(addrs) <= 0 {
err = fmt.Errorf("interface %q has no addresses", ifaceName)
errs = multierror.Append(errs, err)
canditate := filterBestIP(addrs)
if !canditate.IsValid() {
continue
}

foundAddr := findSuitableIP(addrs)
if foundAddr == nil {
err = fmt.Errorf("interface %q has no suitable addresses", ifaceName)
errs = multierror.Append(errs, err)
continue
if canditate.Is4() && !canditate.IsLinkLocalUnicast() {
// Best address possible, we can return early.
return canditate.String(), nil
}

if !IsAutomaticPrivateIP(foundAddr) {
return foundAddr, nil
} else if privateIP == nil {
privateIP = foundAddr
bestIP = filterBestIP([]netip.Addr{canditate, bestIP})
}
if !bestIP.IsValid() {
if errs != nil {
return "", errors.Wrapf(errs, "no useable address found for interfaces %v", interfaces)
} else {
return "", fmt.Errorf("no useable address found for interfaces %v", interfaces)
}
}
return bestIP.String(), nil
}

// getInterfaceAddresses is the standard approach to collecting []net.Addr from a network interface by name.
func getInterfaceAddresses(name string) ([]netip.Addr, error) {
inf, err := net.InterfaceByName(name)
if err != nil {
return nil, err
}

addrs, err := inf.Addrs()
if err != nil {
return nil, err
}

if privateIP == nil {
return nil, errs.ErrorOrNil()
// Using netip.Addr to allow for easier and consistent address parsing.
// Without this, the net.ParseCIDR() that we might like to use in a test does
// not have the same net.Addr implementation that we get from calling
// interface.Addrs() as above. Here we normalize on netip.Addr.
netaddrs := make([]netip.Addr, len(addrs))
for i, a := range addrs {
prefix, err := netip.ParsePrefix(a.String())
if err != nil {
return nil, errors.Wrap(err, "failed to parse netip.Prefix")
}
netaddrs[i] = prefix.Addr()
}
return privateIP, nil

return netaddrs, nil
}

// findSuitableIP searches addrs for the first IPv4 address. IPv4 addresses
// used for APIPA will be ignored if possible.
//
// Returns nil if no suitable addresses were found.
func findSuitableIP(addrs []net.Addr) net.IP {
var privateIP net.IP
// filterBestIP returns an opinionated "best" address from a list of addresses.
// The ordering is the following:
// - IPv4 valid and not link-local unicast
// - IPv6 valid and not link-local unicast
// - IPv4 valid and link-local unicast
// - IPv6 valid and link-local unicast
// If none of the above are found, an invalid address is returned.
// Loopback addresses are never selected.
func filterBestIP(addrs []netip.Addr) netip.Addr {
var invalid, inet4Addr, inet6Addr netip.Addr

for _, addr := range addrs {
addr, ok := addr.(*net.IPNet)
if !ok {
if addr.IsLoopback() || !addr.IsValid() {
continue
}
ipv4 := addr.IP.To4()
if ipv4 == nil {
// Not IPv4
continue

if addr.Is4() {
// If we have already been set, can we improve on the quality?
if inet4Addr.IsValid() {
if inet4Addr.IsLinkLocalUnicast() && !addr.IsLinkLocalUnicast() {
inet4Addr = addr
}
continue
}
inet4Addr = addr
}

// We can return non-automatic private IPs immediately, otherwise we'll
// save the first one as a fallback if there are no better IPs.
if !IsAutomaticPrivateIP(ipv4) {
return ipv4
} else if privateIP == nil {
privateIP = ipv4
if addr.Is6() {
// If we have already been set, can we improve on the quality?
if inet6Addr.IsValid() {
if inet6Addr.IsLinkLocalUnicast() && !addr.IsLinkLocalUnicast() {
inet6Addr = addr
}
continue
}
inet6Addr = addr
}
}

return privateIP
}
// If both address families have been set, compare.
if inet4Addr.IsValid() && inet6Addr.IsValid() {
if inet4Addr.IsLinkLocalUnicast() && !inet6Addr.IsLinkLocalUnicast() {
return inet6Addr
}
if inet6Addr.IsLinkLocalUnicast() && !inet4Addr.IsLinkLocalUnicast() {
return inet4Addr
}
return inet4Addr
}

if inet4Addr.IsValid() {
return inet4Addr
}

// IsAutomaticPrivateIP checks whether IP represents an IP address for
// APIPA (Automatic Private IP Addressing) in the 169.254.0.0/16 range.
func IsAutomaticPrivateIP(ip net.IP) bool {
if ip.To4() == nil {
return false
if inet6Addr.IsValid() {
return inet6Addr
}

var mask = net.IPv4Mask(255, 255, 0, 0)
var subnet = net.IPv4(169, 254, 0, 0)
return ip.Mask(mask).Equal(subnet)
return invalid
}
168 changes: 168 additions & 0 deletions advertise/addr_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package advertise

import (
"errors"
"net"
"net/netip"
"testing"

"github.com/stretchr/testify/assert"
)

// Mock function to simulate various interface address conditions
func mockAddressGetter(data map[string][]string) NetworkInterfaceAddressGetter {
return func(name string) ([]netip.Addr, error) {
if addrs, found := data[name]; found {
var netAddrs []netip.Addr
for _, a := range addrs {
prefix, _ := netip.ParsePrefix(a)
netAddrs = append(netAddrs, prefix.Addr())
}
return netAddrs, nil
}
return nil, errors.New("interface not found")
}
}

func mockInterfaceLister() ([]net.Interface, error) {
return []net.Interface{
{Name: "eth0"},
{Name: "eth1"},
{Name: "eth2"},
{Name: "lo"},
}, nil
}

func TestFirstAddress(t *testing.T) {
tests := []struct {
name string
interfaceData map[string][]string
interfaces []string
expected string
expectedError string
}{
{
name: "Multiple interfaces, one with valid IPv4",
interfaceData: map[string][]string{
"eth0": {"::2/128"},
"eth1": {"192.168.1.1/24"},
},
interfaces: []string{"eth0", "eth1"},
expected: "192.168.1.1",
},
{
name: "Multiple interfaces, all with IPv6",
interfaceData: map[string][]string{
"eth0": {"::2/128"},
"eth1": {"::3/128"},
},
interfaces: []string{"eth0", "eth1"},
expected: "::3",
},
{
name: "Invalid interface",
interfaceData: map[string][]string{
"eth0": {"192.168.1.1/24"},
},
interfaces: []string{"invalid"},
expectedError: "no useable address found for interfaces [invalid]: 1 error occurred:\n\t* interface \"invalid\": interface not found\n\n",
},
{
name: "Multiple interfaces, one invalid, one with valid IPv4",
interfaceData: map[string][]string{
"eth0": {"::2/128"},
"eth1": {"192.168.1.1/24"},
},
interfaces: []string{"invalid", "eth0", "eth1"},
expected: "192.168.1.1",
},
{
name: "Empty interfaces",
interfaceData: map[string][]string{
"eth0": {},
"eth1": {},
},
interfaces: []string{"eth0", "eth1"},
expectedError: "no useable address found for interfaces [eth0 eth1]",
},
{
name: "No interfaces",
interfaceData: map[string][]string{},
interfaces: []string{"eth0", "eth1"},
expectedError: "no useable address found for interfaces [eth0 eth1]: 2 errors occurred:\n\t* interface \"eth0\": interface not found\n\t* interface \"eth1\": interface not found\n\n",
},
{
name: "Ignore loopback addresses",
interfaceData: map[string][]string{
"eth0": {"127.0.0.1/8", "192.168.1.1/24"},
},
interfaces: []string{"eth0"},
expected: "192.168.1.1",
},
{
name: "Ignore IPv6 loopback addresses",
interfaceData: map[string][]string{
"eth0": {"::1/128", "::2/128"},
},
interfaces: []string{"eth0"},
expected: "::2",
},
{
name: "Ignore link-local unicast if possible (IPv4)",
interfaceData: map[string][]string{
"eth0": {"169.254.0.1/16", "192.168.1.1/24"},
},
interfaces: []string{"eth0"},
expected: "192.168.1.1",
},
{
name: "Ignore link-local unicast if possible (IPv6)",
interfaceData: map[string][]string{
"eth0": {"fe80::1/64", "::2/128"},
},
interfaces: []string{"eth0"},
expected: "::2",
},
{
name: "Use link-local unicast if no other option (IPv4)",
interfaceData: map[string][]string{
"eth0": {"169.254.0.1/16"},
},
interfaces: []string{"eth0"},
expected: "169.254.0.1",
},
{
name: "Use link-local unicast if no other option (IPv6)",
interfaceData: map[string][]string{
"eth0": {"fe80::1/64"},
},
interfaces: []string{"eth0"},
expected: "fe80::1",
},
{
name: "Select from all interfaces",
interfaceData: map[string][]string{
"eth0": {"192.168.1.1/24"},
"eth1": {"10.0.0.2/24"},
"eth2": {"169.254.0.1/16"},
"lo": {"127.0.0.1/8"},
},
interfaces: []string{"all"},
expected: "192.168.1.1",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockFunc := mockAddressGetter(tt.interfaceData)
result, err := firstAddress(tt.interfaces, mockFunc, mockInterfaceLister)
if tt.expectedError != "" {
assert.NotNil(t, err)
assert.Equal(t, tt.expectedError, err.Error())
} else {
assert.Nil(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ require (
github.com/kr/text v0.2.0 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/miekg/dns v1.1.26 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.42.0 // indirect
Expand Down
Loading

0 comments on commit 6e782ca

Please sign in to comment.