diff --git a/command/agent/config.go b/command/agent/config.go index 2be65e22e..a98f2c30b 100644 --- a/command/agent/config.go +++ b/command/agent/config.go @@ -26,6 +26,7 @@ import ( client "github.com/hashicorp/nomad/client/config" "github.com/hashicorp/nomad/client/fingerprint" "github.com/hashicorp/nomad/helper" + "github.com/hashicorp/nomad/helper/ipaddr" "github.com/hashicorp/nomad/helper/pointer" "github.com/hashicorp/nomad/helper/users" "github.com/hashicorp/nomad/nomad" @@ -1995,6 +1996,7 @@ func (c *Config) normalizeAddrs() error { } c.BindAddr = ipStr } + c.BindAddr = ipaddr.NormalizeAddr(c.BindAddr) httpAddrs, err := normalizeMultipleBind(c.Addresses.HTTP, c.BindAddr) if err != nil { @@ -2015,9 +2017,12 @@ func (c *Config) normalizeAddrs() error { c.Addresses.Serf = addr c.normalizedAddrs = &NormalizedAddrs{ - HTTP: joinHostPorts(httpAddrs, strconv.Itoa(c.Ports.HTTP)), - RPC: net.JoinHostPort(c.Addresses.RPC, strconv.Itoa(c.Ports.RPC)), - Serf: net.JoinHostPort(c.Addresses.Serf, strconv.Itoa(c.Ports.Serf)), + RPC: normalizeAddrWithPort(c.Addresses.RPC, c.Ports.RPC), + Serf: normalizeAddrWithPort(c.Addresses.Serf, c.Ports.Serf), + } + c.normalizedAddrs.HTTP = make([]string, len(httpAddrs)) + for i, addr := range httpAddrs { + c.normalizedAddrs.HTTP[i] = normalizeAddrWithPort(addr, c.Ports.HTTP) } addr, err = normalizeAdvertise(c.AdvertiseAddrs.HTTP, httpAddrs[0], c.Ports.HTTP, c.DevMode) @@ -2100,6 +2105,12 @@ func parseMultipleIPTemplate(ipTmpl string) ([]string, error) { return deduplicateAddrs(ips), nil } +// normalizeAddrWithPort assumes that addr does not contain a port, +// noramlizes it per ipv6 RFC-5942 §4, and appends ":{port}". +func normalizeAddrWithPort(addr string, port int) string { + return ipaddr.NormalizeAddr(net.JoinHostPort(addr, strconv.Itoa(port))) +} + // normalizeBind returns a normalized bind address. // // If addr is set it is used, if not the default bind address is used. @@ -2107,7 +2118,8 @@ func normalizeBind(addr, bind string) (string, error) { if addr == "" { return bind, nil } - return listenerutil.ParseSingleIPTemplate(addr) + addr, err := listenerutil.ParseSingleIPTemplate(addr) + return ipaddr.NormalizeAddr(addr), err } // normalizeMultipleBind returns normalized bind addresses. @@ -2117,7 +2129,11 @@ func normalizeMultipleBind(addr, bind string) ([]string, error) { if addr == "" { return []string{bind}, nil } - return parseMultipleIPTemplate(addr) + addrs, err := parseMultipleIPTemplate(addr) + for i, addr := range addrs { + addrs[i] = ipaddr.NormalizeAddr(addr) + } + return addrs, err } // normalizeAdvertise returns a normalized advertise address. @@ -2147,10 +2163,10 @@ func normalizeAdvertise(addr string, bind string, defport int, dev bool) (string } // missing port, append the default - return net.JoinHostPort(addr, strconv.Itoa(defport)), nil + return normalizeAddrWithPort(addr, defport), nil } - return addr, nil + return ipaddr.NormalizeAddr(addr), nil } // Fallback to bind address first, and then try resolving the local hostname @@ -2162,12 +2178,12 @@ func normalizeAdvertise(addr string, bind string, defport int, dev bool) (string // Return the first non-localhost unicast address for _, ip := range ips { if ip.IsLinkLocalUnicast() || ip.IsGlobalUnicast() { - return net.JoinHostPort(ip.String(), strconv.Itoa(defport)), nil + return normalizeAddrWithPort(ip.String(), defport), nil } if ip.IsLoopback() { if dev { // loopback is fine for dev mode - return net.JoinHostPort(ip.String(), strconv.Itoa(defport)), nil + return normalizeAddrWithPort(ip.String(), defport), nil } return "", fmt.Errorf("Defaulting advertise to localhost is unsafe, please set advertise manually") } @@ -2178,7 +2194,7 @@ func normalizeAdvertise(addr string, bind string, defport int, dev bool) (string if err != nil { return "", fmt.Errorf("Unable to parse default advertise address: %v", err) } - return net.JoinHostPort(addr, strconv.Itoa(defport)), nil + return normalizeAddrWithPort(addr, defport), nil } // isMissingPort returns true if an error is a "missing port" error from @@ -2923,17 +2939,6 @@ func LoadConfigDir(dir string) (*Config, error) { return result, nil } -// joinHostPorts joins every addr in addrs with the specified port -func joinHostPorts(addrs []string, port string) []string { - localAddrs := make([]string, len(addrs)) - for i, k := range addrs { - localAddrs[i] = net.JoinHostPort(k, port) - - } - - return localAddrs -} - // isTemporaryFile returns true or false depending on whether the // provided file name is a temporary file for the following editors: // emacs or vim. diff --git a/command/agent/config_parse.go b/command/agent/config_parse.go index 494a6d6b9..7b71b620a 100644 --- a/command/agent/config_parse.go +++ b/command/agent/config_parse.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/hcl/hcl/ast" client "github.com/hashicorp/nomad/client/config" "github.com/hashicorp/nomad/helper" + "github.com/hashicorp/nomad/helper/ipaddr" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs/config" "github.com/mitchellh/mapstructure" @@ -434,6 +435,10 @@ func parseVaults(c *Config, list *ast.ObjectList) error { c.Vaults = append(c.Vaults, v) } + for _, conf := range c.Vaults { + conf.Addr = ipaddr.NormalizeAddr(conf.Addr) + } + // Decode the default identity. var listVal *ast.ObjectList if ot, ok := obj.Val.(*ast.ObjectType); ok { @@ -505,6 +510,11 @@ func parseConsuls(c *Config, list *ast.ObjectList) error { c.Consuls = append(c.Consuls, cc) } + for _, conf := range c.Consuls { + conf.Addr = ipaddr.NormalizeAddr(conf.Addr) + conf.GRPCAddr = ipaddr.NormalizeAddr(conf.GRPCAddr) + } + // decode service and template identity blocks var listVal *ast.ObjectList if ot, ok := obj.Val.(*ast.ObjectType); ok { diff --git a/command/agent/config_parse_test.go b/command/agent/config_parse_test.go index fda422997..485af4643 100644 --- a/command/agent/config_parse_test.go +++ b/command/agent/config_parse_test.go @@ -1067,7 +1067,7 @@ func TestConfig_MultipleVault(t *testing.T) { must.Eq(t, "alternate", cfg.Vaults[1].Name) must.True(t, *cfg.Vaults[1].Enabled) - must.Eq(t, "127.0.0.1:9501", cfg.Vaults[1].Addr) + must.Eq(t, "[::1f]:9501", cfg.Vaults[1].Addr) must.Eq(t, "other", cfg.Vaults[2].Name) must.Nil(t, cfg.Vaults[2].Enabled) @@ -1119,7 +1119,7 @@ func TestConfig_MultipleConsul(t *testing.T) { must.Eq(t, "abracadabra", defaultConsul.Token) must.Eq(t, "alternate", cfg.Consuls[1].Name) - must.Eq(t, "127.0.0.2:8501", cfg.Consuls[1].Addr) + must.Eq(t, "[::1f]:8501", cfg.Consuls[1].Addr) must.Eq(t, "xyzzy", cfg.Consuls[1].Token) must.Eq(t, "other", cfg.Consuls[2].Name) diff --git a/command/agent/config_test.go b/command/agent/config_test.go index 19d4f2937..201868c78 100644 --- a/command/agent/config_test.go +++ b/command/agent/config_test.go @@ -21,6 +21,7 @@ import ( "github.com/hashicorp/nomad/helper/pointer" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs/config" + "github.com/shoenig/test" "github.com/shoenig/test/must" "github.com/stretchr/testify/require" ) @@ -956,6 +957,33 @@ func TestConfig_normalizeAddrs_IPv6Loopback(t *testing.T) { } } +// TestConfig_normalizeAddrs_IPv6 asserts that bind and advertise addrs conform +// to RFC 5942 §4: https://www.rfc-editor.org/rfc/rfc5942.html#section-4 +// Full coverage is provided by tests for ipaddr.NormalizeAddr +func TestConfig_normalizeAddrs_IPv6(t *testing.T) { + c := &Config{ + Addresses: &Addresses{}, + + BindAddr: "0:0::1F", + Ports: &Ports{ + HTTP: 4646, + RPC: 4647, + }, + AdvertiseAddrs: &AdvertiseAddrs{ + HTTP: "[A110::0:0:C8]:8080", + RPC: "0:00FA:0:0:0::CE", + }, + DevMode: false, + } + must.NoError(t, c.normalizeAddrs()) + test.Eq(t, "::1f", c.Addresses.HTTP, test.Sprint("bind HTTP")) + test.Eq(t, "::1f", c.Addresses.RPC, test.Sprint("bind RPC")) + test.Eq(t, []string{"[::1f]:4646"}, c.normalizedAddrs.HTTP, test.Sprint("normalized HTTP")) + test.Eq(t, "[::1f]:4647", c.normalizedAddrs.RPC, test.Sprint("normalized RPC")) + test.Eq(t, "[a110::c8]:8080", c.AdvertiseAddrs.HTTP, test.Sprint("advertise HTTP")) + test.Eq(t, "[0:fa::ce]:4647", c.AdvertiseAddrs.RPC, test.Sprint("advertise RPC")) +} + // TestConfig_normalizeAddrs_MultipleInterface asserts that normalizeAddrs will // handle normalizing multiple interfaces in a single protocol. func TestConfig_normalizeAddrs_MultipleInterfaces(t *testing.T) { diff --git a/command/agent/testdata/extra-consul.hcl b/command/agent/testdata/extra-consul.hcl index 76808606c..0345a6f34 100644 --- a/command/agent/testdata/extra-consul.hcl +++ b/command/agent/testdata/extra-consul.hcl @@ -18,7 +18,7 @@ consul { server_rpc_check_name = "nomad-server-rpc-health-check" client_service_name = "nomad-client" client_http_check_name = "nomad-client-http-health-check" - address = "127.0.0.2:8501" + address = "[0:0::1F]:8501" allow_unauthenticated = true token = "xyzzy" auth = "username:pass" diff --git a/command/agent/testdata/extra-consul.json b/command/agent/testdata/extra-consul.json index c97389492..9430a820c 100644 --- a/command/agent/testdata/extra-consul.json +++ b/command/agent/testdata/extra-consul.json @@ -14,7 +14,7 @@ "server_rpc_check_name": "nomad-server-rpc-health-check", "client_service_name": "nomad-client", "client_http_check_name": "nomad-client-http-health-check", - "address": "127.0.0.2:8501", + "address": "[0:0::1F]:8501", "allow_unauthenticated": true, "token": "xyzzy", "auth": "username:pass" diff --git a/command/agent/testdata/extra-vault.hcl b/command/agent/testdata/extra-vault.hcl index 78fcdb29d..85917949d 100644 --- a/command/agent/testdata/extra-vault.hcl +++ b/command/agent/testdata/extra-vault.hcl @@ -10,7 +10,7 @@ vault { # these alternate configs should be added as an extra vault configs vault { name = "alternate" - address = "127.0.0.1:9501" + address = "[0:0::1F]:9501" allow_unauthenticated = true task_token_ttl = "5s" enabled = true diff --git a/command/agent/testdata/extra-vault.json b/command/agent/testdata/extra-vault.json index e1d106ce7..8cc067224 100644 --- a/command/agent/testdata/extra-vault.json +++ b/command/agent/testdata/extra-vault.json @@ -6,7 +6,7 @@ }, { "name": "alternate", - "address": "127.0.0.1:9501", + "address": "[0:0::1F]:9501", "allow_unauthenticated": true, "task_token_ttl": "5s", "enabled": true, diff --git a/helper/ipaddr/ipaddr.go b/helper/ipaddr/ipaddr.go index 822988df4..59991f05d 100644 --- a/helper/ipaddr/ipaddr.go +++ b/helper/ipaddr/ipaddr.go @@ -3,6 +3,12 @@ package ipaddr +import ( + "net" + "net/url" + "strings" +) + // IsAny checks if the given IP address is an IPv4 or IPv6 ANY address. func IsAny(ip string) bool { return isAnyV4(ip) || isAnyV6(ip) @@ -11,3 +17,104 @@ func IsAny(ip string) bool { func isAnyV4(ip string) bool { return ip == "0.0.0.0" } func isAnyV6(ip string) bool { return ip == "::" || ip == "[::]" } + +// NormalizeAddr takes a string of a Host, Host:Port, URL, or Destination +// Address and returns a copy where any IP addresses have been normalized to be +// conformant with RFC 5942 §4. If the input string does not match any of the +// supported syntaxes, or the "host" section is not an IP address, the input +// will be returned unchanged. Supported syntaxes are: +// +// Host host or [host] +// Host:Port host:port or [host]:port +// URL scheme://user@host/path?query#frag or scheme://user@[host]/path?query#frag +// Destination Address user@host:port or user@[host]:port +// +// See: +// +// https://rfc-editor.org/rfc/rfc3986.html +// https://rfc-editor.org/rfc/rfc5942.html +// https://rfc-editor.org/rfc/rfc5952.html +// +// Note: This function was copied verbatim from Vault: +// https://github.com/hashicorp/vault/blob/58a49e6/internalshared/configutil/normalize.go +func NormalizeAddr(addr string) string { + if addr == "" { + return "" + } + + // Host + ip := net.ParseIP(addr) + if ip != nil { + // net.IP.String() is RFC 5942 §4 compliant + return ip.String() + } + + // [Host] + if strings.HasPrefix(addr, "[") && strings.HasSuffix(addr, "]") { + if len(addr) < 3 { + return addr + } + + // If we've been given a bracketed IP address, return the address + // normalized without brackets. + ip := net.ParseIP(addr[1 : len(addr)-1]) + if ip != nil { + return ip.String() + } + + // Our input is not a valid schema. + return addr + } + + // Host:Port + host, port, err := net.SplitHostPort(addr) + if err == nil { + ip := net.ParseIP(host) + if ip == nil { + // Our host isn't an IP address so we can return it unchanged + return addr + } + + // net.JoinHostPort handles bracketing for RFC 5952 §6 + return net.JoinHostPort(ip.String(), port) + } + + // URL + u, err := url.Parse(addr) + if err == nil { + uhost := u.Hostname() + ip := net.ParseIP(uhost) + if ip == nil { + // Our URL doesn't contain an IP address so we can return our input unchanged. + return addr + } else { + uhost = ip.String() + } + + if uport := u.Port(); uport != "" { + uhost = net.JoinHostPort(uhost, uport) + } else if !strings.HasPrefix(uhost, "[") && !strings.HasSuffix(uhost, "]") { + // Ensure the IPv6 URL host is bracketed post-normalization. + // When*url.URL.String() reassembles the URL it will not consider + // whether or not the *url.URL.Host is RFC 5952 §6 and RFC 3986 §3.2.2 + // conformant. + uhost = "[" + uhost + "]" + + } + u.Host = uhost + + return u.String() + } + + // Destination Address + if idx := strings.LastIndex(addr, "@"); idx > 0 { + if idx+1 > len(addr) { + return addr + } + + return addr[:idx+1] + NormalizeAddr(addr[idx+1:]) + } + + // Our input did not match our supported schemas. Return it unchanged. + return addr +} diff --git a/helper/ipaddr/ipaddr_test.go b/helper/ipaddr/ipaddr_test.go index 2366697a7..5240b0af1 100644 --- a/helper/ipaddr/ipaddr_test.go +++ b/helper/ipaddr/ipaddr_test.go @@ -7,7 +7,7 @@ import ( "net" "testing" - "github.com/stretchr/testify/require" + "github.com/shoenig/test/must" ) func Test_IsAny(t *testing.T) { @@ -50,7 +50,212 @@ func Test_IsAny(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - require.Equal(t, tc.expectedOutput, IsAny(tc.inputIP)) + must.Eq(t, tc.expectedOutput, IsAny(tc.inputIP)) + }) + } +} + +// TestNormalizeAddr ensures that strings that match either an IP address or URL +// and contain an IPv6 address conform to RFC-5942 §4 +// See: https://rfc-editor.org/rfc/rfc5952.html +// Note: This was copied verbatim from Vault: +// https://github.com/hashicorp/vault/blob/58a49e6/internalshared/configutil/normalize_test.go +func TestNormalizeAddr(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + addr string + expected string + isErrorExpected bool + }{ + "hostname": { + addr: "vaultproject.io", + expected: "vaultproject.io", + }, + "hostname port": { + addr: "vaultproject.io:8200", + expected: "vaultproject.io:8200", + }, + "hostname URL": { + addr: "https://vaultproject.io", + expected: "https://vaultproject.io", + }, + "hostname port URL": { + addr: "https://vaultproject.io:8200", + expected: "https://vaultproject.io:8200", + }, + "hostname destination address": { + addr: "user@vaultproject.io", + expected: "user@vaultproject.io", + }, + "hostname destination address URL": { + addr: "http://user@vaultproject.io", + expected: "http://user@vaultproject.io", + }, + "hostname destination address URL port": { + addr: "http://user@vaultproject.io:8200", + expected: "http://user@vaultproject.io:8200", + }, + "ipv4": { + addr: "10.10.1.10", + expected: "10.10.1.10", + }, + "ipv4 invalid bracketed": { + addr: "[10.10.1.10]", + expected: "10.10.1.10", + }, + "ipv4 IP:Port addr": { + addr: "10.10.1.10:8500", + expected: "10.10.1.10:8500", + }, + "ipv4 invalid IP:Port addr": { + addr: "[10.10.1.10]:8500", + expected: "10.10.1.10:8500", + }, + "ipv4 URL": { + addr: "https://10.10.1.10:8200", + expected: "https://10.10.1.10:8200", + }, + "ipv4 invalid URL": { + addr: "https://[10.10.1.10]:8200", + expected: "https://10.10.1.10:8200", + }, + "ipv4 destination address": { + addr: "username@10.10.1.10", + expected: "username@10.10.1.10", + }, + "ipv4 invalid destination address": { + addr: "username@10.10.1.10", + expected: "username@10.10.1.10", + }, + "ipv4 destination address port": { + addr: "username@10.10.1.10:8200", + expected: "username@10.10.1.10:8200", + }, + "ipv4 invalid destination address port": { + addr: "username@[10.10.1.10]:8200", + expected: "username@10.10.1.10:8200", + }, + "ipv4 destination address URL": { + addr: "https://username@10.10.1.10", + expected: "https://username@10.10.1.10", + }, + "ipv4 destination address URL port": { + addr: "https://username@10.10.1.10:8200", + expected: "https://username@10.10.1.10:8200", + }, + "ipv6 invalid address": { + addr: "[2001:0db8::0001]", + expected: "2001:db8::1", + }, + "ipv6 IP:Port RFC-5952 4.1 conformance leading zeroes": { + addr: "[2001:0db8::0001]:8500", + expected: "[2001:db8::1]:8500", + }, + "ipv6 RFC-5952 4.1 conformance leading zeroes": { + addr: "2001:0db8::0001", + expected: "2001:db8::1", + }, + "ipv6 URL RFC-5952 4.1 conformance leading zeroes": { + addr: "https://[2001:0db8::0001]:8200", + expected: "https://[2001:db8::1]:8200", + }, + "ipv6 bracketed destination address with port RFC-5952 4.1 conformance leading zeroes": { + addr: "username@[2001:0db8::0001]:8200", + expected: "username@[2001:db8::1]:8200", + }, + "ipv6 invalid ambiguous destination address with port": { + addr: "username@2001:0db8::0001:8200", + // Since the address and port are ambiguous the value appears to be + // only an address and as such is normalized as an address only + expected: "username@2001:db8::1:8200", + }, + "ipv6 invalid leading zeroes ambiguous destination address with port": { + addr: "username@2001:db8:0:1:1:1:1:1:8200", + // Since the address and port are ambiguous the value is treated as + // a string because it has too many colons to be a valid IPv6 address. + expected: "username@2001:db8:0:1:1:1:1:1:8200", + }, + "ipv6 destination address no port RFC-5952 4.1 conformance leading zeroes": { + addr: "username@2001:0db8::0001", + expected: "username@2001:db8::1", + }, + "ipv6 RFC-5952 4.2.2 conformance one 16-bit 0 field": { + addr: "2001:db8:0:1:1:1:1:1", + expected: "2001:db8:0:1:1:1:1:1", + }, + "ipv6 URL RFC-5952 4.2.2 conformance one 16-bit 0 field": { + addr: "https://[2001:db8:0:1:1:1:1:1]:8200", + expected: "https://[2001:db8:0:1:1:1:1:1]:8200", + }, + "ipv6 destination address with port RFC-5952 4.2.2 conformance one 16-bit 0 field": { + addr: "username@[2001:db8:0:1:1:1:1:1]:8200", + expected: "username@[2001:db8:0:1:1:1:1:1]:8200", + }, + "ipv6 destination address no port RFC-5952 4.2.2 conformance one 16-bit 0 field": { + addr: "username@2001:db8:0:1:1:1:1:1", + expected: "username@2001:db8:0:1:1:1:1:1", + }, + "ipv6 RFC-5952 4.2.3 conformance longest run of 0 bits shortened": { + addr: "2001:0:0:1:0:0:0:1", + expected: "2001:0:0:1::1", + }, + "ipv6 URL RFC-5952 4.2.3 conformance longest run of 0 bits shortened": { + addr: "https://[2001:0:0:1:0:0:0:1]:8200", + expected: "https://[2001:0:0:1::1]:8200", + }, + "ipv6 destination address with port RFC-5952 4.2.3 conformance longest run of 0 bits shortened": { + addr: "username@[2001:0:0:1:0:0:0:1]:8200", + expected: "username@[2001:0:0:1::1]:8200", + }, + "ipv6 destination address no port RFC-5952 4.2.3 conformance longest run of 0 bits shortened": { + addr: "username@2001:0:0:1:0:0:0:1", + expected: "username@2001:0:0:1::1", + }, + "ipv6 RFC-5952 4.2.3 conformance equal runs of 0 bits shortened": { + addr: "2001:db8:0:0:1:0:0:1", + expected: "2001:db8::1:0:0:1", + }, + "ipv6 URL no port RFC-5952 4.2.3 conformance equal runs of 0 bits shortened": { + addr: "https://[2001:db8:0:0:1:0:0:1]", + expected: "https://[2001:db8::1:0:0:1]", + }, + "ipv6 URL with port RFC-5952 4.2.3 conformance equal runs of 0 bits shortened": { + addr: "https://[2001:db8:0:0:1:0:0:1]:8200", + expected: "https://[2001:db8::1:0:0:1]:8200", + }, + + "ipv6 destination address with port RFC-5952 4.2.3 conformance equal runs of 0 bits shortened": { + addr: "username@[2001:db8:0:0:1:0:0:1]:8200", + expected: "username@[2001:db8::1:0:0:1]:8200", + }, + "ipv6 destination address no port RFC-5952 4.2.3 conformance equal runs of 0 bits shortened": { + addr: "username@2001:db8:0:0:1:0:0:1", + expected: "username@2001:db8::1:0:0:1", + }, + "ipv6 RFC-5952 4.3 conformance downcase hex letters": { + addr: "2001:DB8:AC3:FE4::1", + expected: "2001:db8:ac3:fe4::1", + }, + "ipv6 URL RFC-5952 4.3 conformance downcase hex letters": { + addr: "https://[2001:DB8:AC3:FE4::1]:8200", + expected: "https://[2001:db8:ac3:fe4::1]:8200", + }, + "ipv6 destination address with port RFC-5952 4.3 conformance downcase hex letters": { + addr: "username@[2001:DB8:AC3:FE4::1]:8200", + expected: "username@[2001:db8:ac3:fe4::1]:8200", + }, + "ipv6 destination address no port RFC-5952 4.3 conformance downcase hex letters": { + addr: "username@2001:DB8:AC3:FE4::1", + expected: "username@2001:db8:ac3:fe4::1", + }, + } + for name, tc := range tests { + name := name + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + must.Eq(t, tc.expected, NormalizeAddr(tc.addr)) }) } }