diff --git a/internal/endpointaddr/endpointaddr.go b/internal/endpointaddr/endpointaddr.go new file mode 100644 index 00000000..fd927765 --- /dev/null +++ b/internal/endpointaddr/endpointaddr.go @@ -0,0 +1,71 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package endpointaddr implements parsing and validation of "[:]" strings for Pinniped APIs. +package endpointaddr + +import ( + "fmt" + "net" + "strconv" + + "k8s.io/apimachinery/pkg/util/validation" +) + +type HostPort struct { + // Host is the validated host part of the input, which may be a hostname or IP. + // + // This string can be be used as an x509 certificate SAN. + Host string + + // Port is the validated port number, which may be defaulted. + Port uint16 +} + +// Endpoint is the host:port validated from the input, where port may be a default value. +// +// This string can be passed to net.Dial. +func (h *HostPort) Endpoint() string { + return net.JoinHostPort(h.Host, strconv.Itoa(int(h.Port))) +} + +// Parse an "endpoint address" string, providing a default port. The input can be in several valid formats: +// +// - "" (DNS hostname) +// - "" (IPv4 address) +// - "" (IPv6 address) +// - ":" (DNS hostname with port) +// - ":" (IPv4 address with port) +// - "[]:" (IPv6 address with port, brackets are required) +// +// If the input does not not specify a port number, then defaultPort will be used. +func Parse(endpoint string, defaultPort uint16) (HostPort, error) { + // Try parsing it both with and without an implicit port 443 at the end. + host, port, err := net.SplitHostPort(endpoint) + + // If we got an error parsing the raw input, try adding the default port. + if err != nil { + host, port, err = net.SplitHostPort(net.JoinHostPort(endpoint, strconv.Itoa(int(defaultPort)))) + } + + // Give up if there's still an error splitting the host and port. + if err != nil { + return HostPort{}, err + } + + // Parse the port number is an integer in the range of valid ports. + integerPort, _ := strconv.Atoi(port) + if len(validation.IsValidPortNum(integerPort)) > 0 { + return HostPort{}, fmt.Errorf("invalid port %q", port) + } + + // Check if the host part is a IPv4 or IPv6 address or a valid hostname according to RFC 1123. + switch { + case len(validation.IsValidIP(host)) == 0: + case len(validation.IsDNS1123Subdomain(host)) == 0: + default: + return HostPort{}, fmt.Errorf("host %q is not a valid hostname or IP address", host) + } + + return HostPort{Host: host, Port: uint16(integerPort)}, nil +} diff --git a/internal/endpointaddr/endpointaddr_test.go b/internal/endpointaddr/endpointaddr_test.go new file mode 100644 index 00000000..736df312 --- /dev/null +++ b/internal/endpointaddr/endpointaddr_test.go @@ -0,0 +1,182 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package endpointaddr + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParse(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name string + input string + defaultPort uint16 + expectErr string + expect HostPort + expectEndpoint string + }{ + { + name: "plain IPv4", + input: "127.0.0.1", + defaultPort: 443, + expect: HostPort{Host: "127.0.0.1", Port: 443}, + expectEndpoint: "127.0.0.1:443", + }, + { + name: "IPv4 with port", + input: "127.0.0.1:8443", + defaultPort: 443, + expect: HostPort{Host: "127.0.0.1", Port: 8443}, + expectEndpoint: "127.0.0.1:8443", + }, + { + name: "IPv4 in brackets with port", + input: "[127.0.0.1]:8443", + defaultPort: 443, + expect: HostPort{Host: "127.0.0.1", Port: 8443}, + expectEndpoint: "127.0.0.1:8443", + }, + { + name: "IPv4 as IPv6 in brackets with port", + input: "[::127.0.0.1]:8443", + defaultPort: 443, + expect: HostPort{Host: "::127.0.0.1", Port: 8443}, + expectEndpoint: "[::127.0.0.1]:8443", + }, + { + name: "IPv4 as IPv6 without port", + input: "::127.0.0.1", + defaultPort: 443, + expect: HostPort{Host: "::127.0.0.1", Port: 443}, + expectEndpoint: "[::127.0.0.1]:443", + }, + { + name: "plain IPv6 without port", + input: "2001:db8::ffff", + defaultPort: 443, + expect: HostPort{Host: "2001:db8::ffff", Port: 443}, + expectEndpoint: "[2001:db8::ffff]:443", + }, + { + name: "IPv6 with port", + input: "[2001:db8::ffff]:8443", + defaultPort: 443, + expect: HostPort{Host: "2001:db8::ffff", Port: 8443}, + expectEndpoint: "[2001:db8::ffff]:8443", + }, + { + name: "plain hostname", + input: "host.example.com", + defaultPort: 443, + expect: HostPort{Host: "host.example.com", Port: 443}, + expectEndpoint: "host.example.com:443", + }, + { + name: "plain hostname with dash", + input: "host-dev.example.com", + defaultPort: 443, + expect: HostPort{Host: "host-dev.example.com", Port: 443}, + expectEndpoint: "host-dev.example.com:443", + }, + { + name: "hostname with port", + input: "host.example.com:8443", + defaultPort: 443, + expect: HostPort{Host: "host.example.com", Port: 8443}, + expectEndpoint: "host.example.com:8443", + }, + { + name: "hostname in brackets with port", + input: "[host.example.com]:8443", + defaultPort: 443, + expect: HostPort{Host: "host.example.com", Port: 8443}, + expectEndpoint: "host.example.com:8443", + }, + { + name: "hostname without dots", + input: "localhost", + defaultPort: 443, + expect: HostPort{Host: "localhost", Port: 443}, + expectEndpoint: "localhost:443", + }, + { + name: "hostname and port without dots", + input: "localhost:8443", + defaultPort: 443, + expect: HostPort{Host: "localhost", Port: 8443}, + expectEndpoint: "localhost:8443", + }, + { + name: "invalid empty string", + input: "", + defaultPort: 443, + expectErr: `host "" is not a valid hostname or IP address`, + }, + { + // IPv6 zone index specifiers are not yet supported. + name: "IPv6 with port and zone index", + input: "[2001:db8::ffff%lo0]:8443", + defaultPort: 443, + expectErr: `host "2001:db8::ffff%lo0" is not a valid hostname or IP address`, + }, + { + name: "IPv6 in brackets without port", + input: "[2001:db8::ffff]", + defaultPort: 443, + expectErr: `address [[2001:db8::ffff]]:443: missing port in address`, + }, + { + name: "invalid HTTPS URL", + input: "https://host.example.com", + defaultPort: 443, + expectErr: `invalid port "//host.example.com"`, + }, + { + name: "invalid host with URL path", + input: "host.example.com/some/path", + defaultPort: 443, + expectErr: `host "host.example.com/some/path" is not a valid hostname or IP address`, + }, + { + name: "invalid host with mismatched brackets", + input: "[host.example.com", + defaultPort: 443, + expectErr: "address [host.example.com:443: missing ']' in address", + }, + { + name: "invalid host with underscores", + input: "___.example.com:1234", + defaultPort: 443, + expectErr: `host "___.example.com" is not a valid hostname or IP address`, + }, + { + name: "invalid host with uppercase", + input: "HOST.EXAMPLE.COM", + defaultPort: 443, + expectErr: `host "HOST.EXAMPLE.COM" is not a valid hostname or IP address`, + }, + { + name: "invalid host with extra port", + input: "host.example.com:port1:port2", + defaultPort: 443, + expectErr: `host "host.example.com:port1:port2" is not a valid hostname or IP address`, + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + got, err := Parse(tt.input, tt.defaultPort) + if tt.expectErr == "" { + assert.NoError(t, err) + assert.Equal(t, tt.expect, got) + assert.Equal(t, tt.expectEndpoint, got.Endpoint()) + } else { + assert.EqualError(t, err, tt.expectErr) + assert.Equal(t, HostPort{}, got) + } + }) + } +}