From ce621c5c48a5b771eb437b58ad19d55ccd6deab2 Mon Sep 17 00:00:00 2001 From: Matt Layher Date: Mon, 21 Jun 2021 21:59:42 -0400 Subject: [PATCH] internal/plugin: implement RDNSS wildcard syntax Signed-off-by: Matt Layher --- internal/config/config_test.go | 13 ++- internal/config/default.toml | 8 +- internal/config/plugin.go | 2 + internal/config/plugin_test.go | 13 +++ internal/plugin/plugin.go | 147 ++++++++++++++++++++++++++++++-- internal/plugin/plugin_test.go | 148 +++++++++++++++++++++++++++++---- 6 files changed, 307 insertions(+), 24 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 18308c0..40e2972 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -140,6 +140,9 @@ func TestParse(t *testing.T) { source_lla = true preference = "low" + [[interfaces.rdnss]] + servers = ["::"] + [[interfaces]] name = "eth2" verbose = true @@ -219,7 +222,13 @@ func TestParse(t *testing.T) { RetransmitTimer: 5 * time.Second, DefaultLifetime: 8 * time.Second, Preference: ndp.Low, - Plugins: []plugin.Plugin{&plugin.LLA{}}, + Plugins: []plugin.Plugin{ + &plugin.RDNSS{ + Lifetime: 8 * time.Second, + Servers: []netaddr.IP{netaddr.IPv6Unspecified()}, + }, + &plugin.LLA{}, + }, }, { Name: "eth2", @@ -299,7 +308,7 @@ func TestParseDefaults(t *testing.T) { prefix = "2001:db8:ffff::/64" [[interfaces.rdnss]] - servers = ["2001:db8::1", "2001:db8::2"] + servers = ["::"] [[interfaces.dnssl]] domain_names = ["foo.example.com"] diff --git a/internal/config/default.toml b/internal/config/default.toml index 0cdaa48..153f630 100644 --- a/internal/config/default.toml +++ b/internal/config/default.toml @@ -143,12 +143,18 @@ preference = "medium" # RDNSS: attaches a NDP Recursive DNS Servers option to the router advertisement. [[interfaces.rdnss]] + # The DNS servers which should be advertised. Explicit IPv6 addresses may be + # specified, but the special :: wildcard will choose a suitable IPv6 address + # (preferring Unique Local Addresses, then Global Unicast Addresses, then + # Link-Local Addresses) from this interface to serve in the event that the DNS + # server resides on the same interface as CoreRAD. + servers = ["::"] + # The maximum time these RDNSS addresses may be used for name resolution. # An empty string or 0 means these servers should no longer be used. # "auto" will compute a sane default. "infinite" means these servers should # be used forever. lifetime = "auto" - servers = ["2001:db8::1", "2001:db8::2"] # DNSSL: attaches a NDP DNS Search List option to the router advertisement. [[interfaces.dnssl]] diff --git a/internal/config/plugin.go b/internal/config/plugin.go index bd2fa09..114bea5 100644 --- a/internal/config/plugin.go +++ b/internal/config/plugin.go @@ -246,6 +246,8 @@ func parseRDNSS(d rawRDNSS, maxInterval time.Duration) (*plugin.RDNSS, error) { } if len(d.Servers) == 0 { + // TODO(mdlayher): should this imply the :: wildcard support? If so + // consider also implying ::/64 for empty prefix option. return nil, errors.New("must specify one or more DNS server IPv6 addresses") } diff --git a/internal/config/plugin_test.go b/internal/config/plugin_test.go index 40b3fba..c72d0d2 100644 --- a/internal/config/plugin_test.go +++ b/internal/config/plugin_test.go @@ -637,6 +637,19 @@ func Test_parseRDNSS(t *testing.T) { }, ok: true, }, + { + name: "OK wildcard server", + s: ` + [[interfaces]] + [[interfaces.rdnss]] + servers = ["::"] + `, + r: &plugin.RDNSS{ + Lifetime: 20 * time.Minute, + Servers: []netaddr.IP{netaddr.IPv6Unspecified()}, + }, + ok: true, + }, } for _, tt := range tests { diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index d49cbcb..63777b7 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -14,6 +14,7 @@ package plugin import ( + "errors" "fmt" "net" "sort" @@ -393,8 +394,12 @@ func (r *Route) lifetime() time.Duration { // RDNSS configures a NDP Recursive DNS Servers option. type RDNSS struct { + // Parameters from configuration. Lifetime time.Duration Servers []netaddr.IP + + // Functions which can be swapped for tests. + Addrs func() ([]net.Addr, error) } // Name implements Plugin. @@ -407,17 +412,50 @@ func (r *RDNSS) String() string { ips = append(ips, s.String()) } - return fmt.Sprintf("servers: [%s], lifetime: %s", - strings.Join(ips, ", "), durString(r.Lifetime)) + servers := fmt.Sprintf("[%s]", strings.Join(ips, ", ")) + if r.wildcard() { + // Make a best-effort to note the current server if the user is using + // the wildcard syntax. If this returns an error, we'll return "::" + // with no further information. + if s, err := r.currentServer(); err == nil { + servers = fmt.Sprintf(":: [%s]", s.String()) + } + } + + return fmt.Sprintf("servers: %s, lifetime: %s", servers, durString(r.Lifetime)) } // Prepare implements Plugin. -func (*RDNSS) Prepare(_ *net.Interface) error { return nil } +func (r *RDNSS) Prepare(ifi *net.Interface) error { + // Fetch addresses from the specified interface whenever invoked. + r.Addrs = ifi.Addrs + return nil +} // Apply implements Plugin. func (r *RDNSS) Apply(ra *ndp.RouterAdvertisement) error { - ips := make([]net.IP, 0, len(r.Servers)) - for _, s := range r.Servers { + if !r.wildcard() { + // User specified exact servers so apply them directly. + r.applyServers(r.Servers, ra) + return nil + } + + // User specified the :: wildcard syntax, automatically choose a DNS server + // address from this interface. + server, err := r.currentServer() + if err != nil { + return err + } + + // Produce a RecursiveDNSServers option for this server. + r.applyServers([]netaddr.IP{server}, ra) + return nil +} + +// applyServers unpacks servers into an ndp.RecursiveDNSServer option within ra. +func (r *RDNSS) applyServers(servers []netaddr.IP, ra *ndp.RouterAdvertisement) { + ips := make([]net.IP, 0, len(servers)) + for _, s := range servers { ips = append(ips, s.IPAddr().IP) } @@ -425,8 +463,97 @@ func (r *RDNSS) Apply(ra *ndp.RouterAdvertisement) error { Lifetime: r.Lifetime, Servers: ips, }) +} - return nil +// wildcard determines if the RDNSS option is configured with the :: wildcard +// syntax. +func (r *RDNSS) wildcard() bool { + // TODO(mdlayher): allow both wildcard and non-wildcard servers? + return len(r.Servers) == 1 && r.Servers[0] == netaddr.IPv6Unspecified() +} + +// currentServer fetches the current DNS server IP from the interface. +func (r *RDNSS) currentServer() (netaddr.IP, error) { + // Expand :: to one of the IPv6 addresses on this interface. + addrs, err := r.Addrs() + if err != nil { + return netaddr.IP{}, fmt.Errorf("failed to fetch IP addresses: %v", err) + } + + var ips []netaddr.IP + for _, a := range addrs { + ipn, ok := a.(*net.IPNet) + if !ok { + continue + } + + ipp, ok := netaddr.FromStdIPNet(ipn) + if !ok { + panicf("corerad: invalid net.IPNet: %+v", a) + } + + // Only consider IPv6 addresses. + if ipp.IP().Is4() { + continue + } + + ips = append(ips, ipp.IP()) + } + + switch len(ips) { + case 0: + // No IPv6 addresses, cannot use wildcard syntax. + return netaddr.IP{}, errors.New("interface has no IPv6 addresses") + case 1: + // One IPv6 address, use that one. + return ips[0], nil + } + + // More than one IPv6 address was found. Now that we've gathered the + // addresses on this interface, select one as follows: + // + // 1) Unique Local Address (ULA) + // - if assigned, high probability of use for internal-only services. + // 2) Global Unicast Address (GUA) + // - de-facto choice when ULA is not available. + // 3) Link-Local Address (LLA) + // - last resort, doesn't work across subnets but since this machine is + // also running CoreRAD that may not be a problem. + // + // In the event of a tie, the lesser address by byte comparison wins. + // + // TODO(mdlayher): actually consider OS-specific data like + // temporary/deprecated address flags. + // + // TODO(mdlayher): infer permanence of an address from EUI-64 format. + sort.SliceStable(ips, func(i, j int) bool { + // Prefer ULA. + if isI, isJ := isULA(ips[i]), isULA(ips[j]); isI && !isJ { + return true + } else if isJ && !isI { + return false + } + + // Prefer GUA. + if isI, isJ := isGUA(ips[i]), isGUA(ips[j]); isI && !isJ { + return true + } else if isJ && !isI { + return false + } + + // Prefer LLA. + if isI, isJ := ips[i].IsLinkLocalUnicast(), ips[j].IsLinkLocalUnicast(); isI && !isJ { + return true + } else if isJ && !isI { + return false + } + + // Tie-breaker: prefer lowest address. + return ips[i].Less(ips[j]) + }) + + // The first address wins. + return ips[0], nil } // durString converts a time.Duration into a string while also recognizing @@ -440,6 +567,14 @@ func durString(d time.Duration) string { } } +// TODO(mdlayher): upstream into inet.af/netaddr. + +func isGUA(ip netaddr.IP) bool { return ip.IPAddr().IP.IsGlobalUnicast() } + +var ula = netaddr.MustParseIPPrefix("fc00::/7") + +func isULA(ip netaddr.IP) bool { return ula.Contains(ip) } + func panicf(format string, a ...interface{}) { panic(fmt.Sprintf(format, a...)) } diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index d8c77a8..53ed337 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -24,6 +24,38 @@ import ( ) func TestPluginString(t *testing.T) { + // A function which returns synthesized interface addresses. Address + // prefixes are duplicated and of different address types to exercise + // different test cases for wildcard options. + addrs := func() ([]net.Addr, error) { + return []net.Addr{ + &net.IPNet{ + IP: net.ParseIP("2001:db8::"), + Mask: net.CIDRMask(64, 128), + }, + &net.IPNet{ + IP: net.ParseIP("2001:db8::1"), + Mask: net.CIDRMask(64, 128), + }, + &net.IPNet{ + IP: net.ParseIP("fdff::"), + Mask: net.CIDRMask(64, 128), + }, + &net.IPNet{ + IP: net.ParseIP("fdff::1"), + Mask: net.CIDRMask(64, 128), + }, + &net.IPNet{ + IP: net.ParseIP("fe80::"), + Mask: net.CIDRMask(64, 128), + }, + &net.IPNet{ + IP: net.ParseIP("fe80::1"), + Mask: net.CIDRMask(64, 128), + }, + }, nil + } + tests := []struct { name string p Plugin @@ -49,6 +81,17 @@ func TestPluginString(t *testing.T) { }, { name: "Prefix", + p: &Prefix{ + Prefix: netaddr.MustParseIPPrefix("2001:db8::/64"), + OnLink: true, + Autonomous: true, + PreferredLifetime: 15 * time.Minute, + ValidLifetime: ndp.Infinity, + }, + s: "2001:db8::/64 [on-link, autonomous], preferred: 15m0s, valid: infinite", + }, + { + name: "Prefix wildcard", p: &Prefix{ Prefix: netaddr.MustParseIPPrefix("::/64"), OnLink: true, @@ -56,18 +99,7 @@ func TestPluginString(t *testing.T) { PreferredLifetime: 15 * time.Minute, ValidLifetime: ndp.Infinity, Deprecated: true, - Addrs: func() ([]net.Addr, error) { - return []net.Addr{ - &net.IPNet{ - IP: net.ParseIP("2001:db8::"), - Mask: net.CIDRMask(64, 128), - }, - &net.IPNet{ - IP: net.ParseIP("fdff::"), - Mask: net.CIDRMask(64, 128), - }, - }, nil - }, + Addrs: addrs, }, s: "::/64 [2001:db8::/64, fdff::/64] [DEPRECATED, on-link, autonomous], preferred: 15m0s, valid: infinite", }, @@ -92,6 +124,15 @@ func TestPluginString(t *testing.T) { }, s: "servers: [2001:db8::1, 2001:db8::2], lifetime: 30s", }, + { + name: "RDNSS wildcard", + p: &RDNSS{ + Lifetime: 30 * time.Second, + Servers: []netaddr.IP{netaddr.IPv6Unspecified()}, + Addrs: addrs, + }, + s: "servers: :: [fdff::], lifetime: 30s", + }, } for _, tt := range tests { @@ -109,6 +150,7 @@ func TestBuild(t *testing.T) { plugin Plugin ifi *net.Interface ra *ndp.RouterAdvertisement + ok bool }{ { name: "DNSSL", @@ -130,6 +172,7 @@ func TestBuild(t *testing.T) { }, }, }, + ok: true, }, { name: "LLA", @@ -145,6 +188,7 @@ func TestBuild(t *testing.T) { }, }, }, + ok: true, }, { name: "MTU", @@ -152,6 +196,7 @@ func TestBuild(t *testing.T) { ra: &ndp.RouterAdvertisement{ Options: []ndp.Option{ndp.NewMTU(1500)}, }, + ok: true, }, { name: "static prefix", @@ -172,6 +217,7 @@ func TestBuild(t *testing.T) { }, }, }, + ok: true, }, { name: "automatic prefixes /64", @@ -215,6 +261,7 @@ func TestBuild(t *testing.T) { }, }, }, + ok: true, }, { name: "automatic prefixes /32", @@ -238,6 +285,7 @@ func TestBuild(t *testing.T) { }, }, }, + ok: true, }, { name: "prefix deprecated preferred and valid", @@ -264,6 +312,7 @@ func TestBuild(t *testing.T) { }, }, }, + ok: true, }, { name: "prefix deprecated valid", @@ -290,6 +339,7 @@ func TestBuild(t *testing.T) { }, }, }, + ok: true, }, { name: "prefix deprecated invalid", @@ -316,6 +366,7 @@ func TestBuild(t *testing.T) { }, }, }, + ok: true, }, { name: "route", @@ -334,6 +385,7 @@ func TestBuild(t *testing.T) { }, }, }, + ok: true, }, { name: "route deprecated valid", @@ -354,6 +406,7 @@ func TestBuild(t *testing.T) { }, }, }, + ok: true, }, { name: "route deprecated invalid", @@ -374,9 +427,10 @@ func TestBuild(t *testing.T) { }, }, }, + ok: true, }, { - name: "RDNSS", + name: "static RDNSS", plugin: &RDNSS{ Lifetime: 10 * time.Second, Servers: []netaddr.IP{ @@ -395,6 +449,59 @@ func TestBuild(t *testing.T) { }, }, }, + ok: true, + }, + { + name: "automatic RDNSS no addresses", + plugin: &RDNSS{ + Lifetime: 10 * time.Second, + Servers: []netaddr.IP{netaddr.IPv6Unspecified()}, + Addrs: func() ([]net.Addr, error) { return nil, nil }, + }, + }, + { + name: "automatic RDNSS one address", + plugin: &RDNSS{ + Lifetime: 10 * time.Second, + Servers: []netaddr.IP{netaddr.IPv6Unspecified()}, + Addrs: func() ([]net.Addr, error) { + return []net.Addr{mustCIDR("2001:db8::1/64")}, nil + }, + }, + ra: &ndp.RouterAdvertisement{ + Options: []ndp.Option{ + &ndp.RecursiveDNSServer{ + Lifetime: 10 * time.Second, + Servers: []net.IP{mustIP("2001:db8::1")}, + }, + }, + }, + ok: true, + }, + { + name: "automatic RDNSS many addresses", + plugin: &RDNSS{ + Lifetime: 10 * time.Second, + Servers: []netaddr.IP{netaddr.IPv6Unspecified()}, + Addrs: func() ([]net.Addr, error) { + return []net.Addr{ + // Populate some addresses which should be ignored. + &net.TCPAddr{}, + mustCIDR("192.0.2.1/32"), + mustCIDR("fdff::1/64"), + mustCIDR("2001:db8::1/64"), + }, nil + }, + }, + ra: &ndp.RouterAdvertisement{ + Options: []ndp.Option{ + &ndp.RecursiveDNSServer{ + Lifetime: 10 * time.Second, + Servers: []net.IP{mustIP("fdff::1")}, + }, + }, + }, + ok: true, }, } @@ -408,13 +515,22 @@ func TestBuild(t *testing.T) { } } - if err := tt.plugin.Apply(ra); err != nil { + err := tt.plugin.Apply(ra) + if tt.ok && err != nil { t.Fatalf("failed to apply: %v", err) } + if !tt.ok && err == nil { + t.Fatal("expected an error, but none occurred") + } + if err != nil { + t.Logf("err: %v", err) + return + } if diff := cmp.Diff(tt.ra, ra); diff != "" { t.Fatalf("unexpected RA (-want +got):\n%s", diff) } + }) } } @@ -429,10 +545,12 @@ func mustIP(s string) net.IP { } func mustCIDR(s string) *net.IPNet { - _, ipn, err := net.ParseCIDR(s) + ip, ipn, err := net.ParseCIDR(s) if err != nil { panicf("failed to parse CIDR: %v", err) } + // Remove masking to simulate 2001:db8::1/64 and etc. properly. + ipn.IP = ip return ipn }