ipv6: normalize addrs per RFC-5942 §4 (#25921)

https://datatracker.ietf.org/doc/html/rfc5952#section-4

* copy NormalizeAddr func from vault
  * PRs hashicorp/vault#29228 & hashicorp/vault#29517
* normalize bind/advertise addrs
* normalize consul/vault addrs
This commit is contained in:
Daniel Bennett
2025-05-22 14:21:30 -04:00
committed by GitHub
parent cfe6349378
commit 15c01e5a49
10 changed files with 384 additions and 29 deletions

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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))
})
}
}