grpc: Implement a static multiple IP address gRPC resolver (#6270)

- Implement a static resolver for the gPRC dialer under the scheme `static:///`
  which allows the dialer to resolve a backend from a static list of IPv4/IPv6
  addresses passed via the existing JSON config.
- Add config key `serverAddresses` to the `GRPCClientConfig` which, when
  populated, enables static IP resolution of gRPC server backends.
- Set `config-next` to use static gRPC backend resolution for all SA clients.
- Generate a new SA certificate which adds `10.77.77.77` and `10.88.88.88` to
  the SANs.

Resolves #6255
This commit is contained in:
Samantha 2022-08-05 10:20:57 -07:00 committed by GitHub
parent b6c4d9bc21
commit 576b6777b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 292 additions and 61 deletions

View File

@ -245,7 +245,16 @@ func (d *ConfigDuration) UnmarshalYAML(unmarshal func(interface{}) error) error
// GRPCClientConfig contains the information needed to talk to the gRPC service // GRPCClientConfig contains the information needed to talk to the gRPC service
type GRPCClientConfig struct { type GRPCClientConfig struct {
// ServerAddress is a single host:port combination that the gRPC client
// will, if necessary, resolve via DNS and then connect to. This field
// cannot be used in combination with `ServerIPAddresses` field.
ServerAddress string ServerAddress string
// ServerIPAddresses is a list of IPv4/6 addresses, in the format IPv4:port,
// [IPv6]:port or :port, that the gRPC client will connect to. Note that the
// server's certificate will be validated against these IP addresses, so
// they must be present in the SANs of the server certificate. This field
// cannot be used in combination with `ServerAddress`.
ServerIPAddresses []string
Timeout ConfigDuration Timeout ConfigDuration
} }

View File

@ -4,6 +4,7 @@ import (
"crypto/tls" "crypto/tls"
"errors" "errors"
"net" "net"
"strings"
grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus" grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus"
"github.com/honeycombio/beeline-go/wrappers/hnygrpc" "github.com/honeycombio/beeline-go/wrappers/hnygrpc"
@ -26,8 +27,10 @@ func ClientSetup(c *cmd.GRPCClientConfig, tlsConfig *tls.Config, metrics clientM
if c == nil { if c == nil {
return nil, errors.New("nil gRPC client config provided. JSON config is probably missing a fooService section.") return nil, errors.New("nil gRPC client config provided. JSON config is probably missing a fooService section.")
} }
if c.ServerAddress == "" { if c.ServerIPAddresses != nil && c.ServerAddress != "" {
return nil, errors.New("ServerAddress must not be empty") return nil, errors.New(
"both 'serverIPAddresses' and 'serverAddress' are set in gRPC client config provided. Only one should be set.",
)
} }
if tlsConfig == nil { if tlsConfig == nil {
return nil, errNilTLS return nil, errNilTLS
@ -40,17 +43,27 @@ func ClientSetup(c *cmd.GRPCClientConfig, tlsConfig *tls.Config, metrics clientM
hnygrpc.UnaryClientInterceptor(), hnygrpc.UnaryClientInterceptor(),
} }
allInterceptors = append(interceptors, allInterceptors...) allInterceptors = append(interceptors, allInterceptors...)
host, _, err := net.SplitHostPort(c.ServerAddress)
if err != nil { var target string
return nil, err var hostOverride string
if c.ServerAddress != "" {
var splitHostPortErr error
hostOverride, _, splitHostPortErr = net.SplitHostPort(c.ServerAddress)
if splitHostPortErr != nil {
return nil, splitHostPortErr
} }
creds := bcreds.NewClientCredentials(tlsConfig.RootCAs, tlsConfig.Certificates, host) target = "dns:///" + c.ServerAddress
} else {
target = "static:///" + strings.Join(c.ServerIPAddresses, ",")
}
creds := bcreds.NewClientCredentials(tlsConfig.RootCAs, tlsConfig.Certificates, hostOverride)
return grpc.Dial( return grpc.Dial(
"dns:///"+c.ServerAddress, target,
grpc.WithBalancerName("round_robin"), grpc.WithBalancerName("round_robin"),
grpc.WithTransportCredentials(creds), grpc.WithTransportCredentials(creds),
grpc.WithChainUnaryInterceptor(allInterceptors...), grpc.WithChainUnaryInterceptor(allInterceptors...),
) )
} }
type registry interface { type registry interface {

43
grpc/client_test.go Normal file
View File

@ -0,0 +1,43 @@
package grpc
import (
"crypto/tls"
"testing"
"github.com/jmhodges/clock"
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/test"
"google.golang.org/grpc"
_ "google.golang.org/grpc/health"
)
func TestClientSetup(t *testing.T) {
tests := []struct {
name string
cfg *cmd.GRPCClientConfig
expectTarget string
wantErr bool
}{
{"valid, address provided", &cmd.GRPCClientConfig{ServerAddress: "localhost:8080"}, "dns:///localhost:8080", false},
{"valid, implicit localhost with port provided", &cmd.GRPCClientConfig{ServerAddress: ":8080"}, "dns:///:8080", false},
{"valid, IPv6 address provided", &cmd.GRPCClientConfig{ServerAddress: "[::1]:8080"}, "dns:///[::1]:8080", false},
{"valid, two addresses provided", &cmd.GRPCClientConfig{ServerIPAddresses: []string{"127.0.0.1:8080", "127.0.0.2:8080"}}, "static:///127.0.0.1:8080,127.0.0.2:8080", false},
{"valid, two addresses provided, one has an implicit localhost, ", &cmd.GRPCClientConfig{ServerIPAddresses: []string{":8080", "127.0.0.2:8080"}}, "static:///:8080,127.0.0.2:8080", false},
{"valid, two addresses provided, one is IPv6, ", &cmd.GRPCClientConfig{ServerIPAddresses: []string{"[::1]:8080", "127.0.0.2:8080"}}, "static:///[::1]:8080,127.0.0.2:8080", false},
{"invalid, both address and addresses provided", &cmd.GRPCClientConfig{ServerAddress: "localhost:8080", ServerIPAddresses: []string{"127.0.0.1:8080"}}, "", true},
{"invalid, no address or addresses provided", &cmd.GRPCClientConfig{}, "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := ClientSetup(tt.cfg, &tls.Config{}, clientMetrics{}, clock.NewFake(), []grpc.UnaryClientInterceptor{}...)
if tt.wantErr {
test.AssertError(t, err, "expected error, got nil")
} else {
test.AssertNotError(t, err, "unexpected error")
}
if tt.expectTarget != "" {
test.AssertEquals(t, client.Target(), tt.expectTarget)
}
})
}
}

101
grpc/resolver.go Normal file
View File

@ -0,0 +1,101 @@
package grpc
import (
"fmt"
"net"
"strings"
"google.golang.org/grpc/resolver"
)
// staticBuilder implements the `resolver.Builder` interface.
type staticBuilder struct{}
// newStaticBuilder creates a `staticBuilder` used to construct static DNS
// resolvers.
func newStaticBuilder() resolver.Builder {
return &staticBuilder{}
}
// Build implements the `resolver.Builder` interface and is usually called by
// the gRPC dialer. It takes a target containing a comma separated list of
// IPv4/6 addresses and a `resolver.ClientConn` and returns a `staticResolver`
// which implements the `resolver.Resolver` interface.
func (sb *staticBuilder) Build(target resolver.Target, cc resolver.ClientConn, _ resolver.BuildOptions) (resolver.Resolver, error) {
var resolverAddrs []resolver.Address
for _, address := range strings.Split(target.Endpoint, ",") {
parsedAddress, err := parseResolverIPAddress(address)
if err != nil {
return nil, err
}
resolverAddrs = append(resolverAddrs, *parsedAddress)
}
return newStaticResolver(cc, resolverAddrs), nil
}
// Scheme returns the scheme that `staticBuilder` will be registered for, for
// example: `static:///`.
func (sb *staticBuilder) Scheme() string {
return "static"
}
// staticResolver is used to wrap an inner `resolver.ClientConn` and implements
// the `resolver.Resolver` interface.
type staticResolver struct {
cc resolver.ClientConn
}
// newStaticResolver takes a `resolver.ClientConn` and a list of
// `resolver.Addresses`. It updates the state of the `resolver.ClientConn` with
// the provided addresses and returns a `staticResolver` which wraps the
// `resolver.ClientConn` and implements the `resolver.Resolver` interface.
func newStaticResolver(cc resolver.ClientConn, resolverAddrs []resolver.Address) resolver.Resolver {
cc.UpdateState(resolver.State{Addresses: resolverAddrs})
return &staticResolver{cc: cc}
}
// ResolveNow is a no-op necessary for `staticResolver` to implement the
// `resolver.Resolver` interface. This resolver is constructed once by
// staticBuilder.Build and the state of the inner `resolver.ClientConn` is never
// updated.
func (sr *staticResolver) ResolveNow(_ resolver.ResolveNowOptions) {}
// Close is a no-op necessary for `staticResolver` to implement the
// `resolver.Resolver` interface.
func (sr *staticResolver) Close() {}
// parseResolverIPAddress takes an IPv4/6 address (ip:port, [ip]:port, or :port)
// and returns a properly formatted `resolver.Address` object. The `Addr` and
// `ServerName` fields of the returned `resolver.Address` will both be set to
// host:port or [host]:port if the host is an IPv6 address.
func parseResolverIPAddress(addr string) (*resolver.Address, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("splitting host and port for address %q: %w", addr, err)
}
if port == "" {
// If the port field is empty the address ends with colon (e.g.
// "[::1]:").
return nil, fmt.Errorf("address %q missing port after port-separator colon", addr)
}
if host == "" {
// Address only has a port (i.e ipv4-host:port, [ipv6-host]:port,
// host-name:port). Keep consistent with net.Dial(); if the host is
// empty (e.g. :80), the local system is assumed.
host = "127.0.0.1"
}
if net.ParseIP(host) == nil {
// Host is a DNS name or an IPv6 address without brackets.
return nil, fmt.Errorf("address %q is not an IP address", addr)
}
parsedAddr := net.JoinHostPort(host, port)
return &resolver.Address{
Addr: parsedAddr,
ServerName: parsedAddr,
}, nil
}
// init registers the `staticBuilder` with the gRPC resolver registry.
func init() {
resolver.Register(newStaticBuilder())
}

34
grpc/resolver_test.go Normal file
View File

@ -0,0 +1,34 @@
package grpc
import (
"testing"
"github.com/letsencrypt/boulder/test"
"google.golang.org/grpc/resolver"
)
func Test_parseResolverIPAddress(t *testing.T) {
tests := []struct {
name string
addr string
expectTarget *resolver.Address
wantErr bool
}{
{"valid, IPv4 address", "127.0.0.1:1337", &resolver.Address{Addr: "127.0.0.1:1337", ServerName: "127.0.0.1:1337"}, false},
{"valid, IPv6 address", "[::1]:1337", &resolver.Address{Addr: "[::1]:1337", ServerName: "[::1]:1337"}, false},
{"valid, port only", ":1337", &resolver.Address{Addr: "127.0.0.1:1337", ServerName: "127.0.0.1:1337"}, false},
{"invalid, hostname address", "localhost:1337", nil, true},
{"invalid, IPv6 address, no brackets", "::1:1337", nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseResolverIPAddress(tt.addr)
if tt.wantErr {
test.AssertError(t, err, "expected error, got nil")
} else {
test.AssertNotError(t, err, "unexpected error")
}
test.AssertDeepEquals(t, got, tt.expectTarget)
})
}
}

View File

@ -19,8 +19,8 @@ for you, you may need to create a combined bundle containing
The gRPC PKI is under test/grpc-creds/. Each Boulder component has two The gRPC PKI is under test/grpc-creds/. Each Boulder component has two
hostnames, each resolving to a different IP address in our test environment, hostnames, each resolving to a different IP address in our test environment,
plus a third hostname that resolves to both IP addresses. Certificates for these plus a third hostname that resolves to both IP addresses. Certificates for these
components contain all three hostnames, and are stored under components contain all three hostnames, both test IP addresses, and are stored
test/grpc-creds/SERVICE.boulder. under test/grpc-creds/SERVICE.boulder.
To issue new certificates in the WFE or gRPC PKI, install To issue new certificates in the WFE or gRPC PKI, install
https://github.com/jsha/minica, cd to the directory containing minica.pem for https://github.com/jsha/minica, cd to the directory containing minica.pem for

View File

@ -14,7 +14,10 @@
"timeout": "15s" "timeout": "15s"
}, },
"saService": { "saService": {
"serverAddress": "sa.boulder:9095", "serverIPAddresses": [
"10.77.77.77:9095",
"10.88.88.88:9095"
],
"timeout": "15s" "timeout": "15s"
}, },
"features": { "features": {

View File

@ -33,7 +33,10 @@
] ]
}, },
"saService": { "saService": {
"serverAddress": "sa.boulder:9095", "serverIPAddresses": [
"10.77.77.77:9095",
"10.88.88.88:9095"
],
"timeout": "15s" "timeout": "15s"
}, },
"issuance": { "issuance": {

View File

@ -33,7 +33,10 @@
] ]
}, },
"saService": { "saService": {
"serverAddress": "sa.boulder:9095", "serverIPAddresses": [
"10.77.77.77:9095",
"10.88.88.88:9095"
],
"timeout": "15s" "timeout": "15s"
}, },
"issuance": { "issuance": {

View File

@ -11,7 +11,10 @@
"timeout": "15s" "timeout": "15s"
}, },
"saService": { "saService": {
"serverAddress": "sa.boulder:9095", "serverIPAddresses": [
"10.77.77.77:9095",
"10.88.88.88:9095"
],
"timeout": "15s" "timeout": "15s"
}, },
"issuerCerts": [ "issuerCerts": [

View File

@ -20,7 +20,10 @@
"keyFile": "test/grpc-creds/expiration-mailer.boulder/key.pem" "keyFile": "test/grpc-creds/expiration-mailer.boulder/key.pem"
}, },
"saService": { "saService": {
"serverAddress": "sa.boulder:9095", "serverIPAddresses": [
"10.77.77.77:9095",
"10.88.88.88:9095"
],
"timeout": "15s" "timeout": "15s"
}, },
"SMTPTrustedRootFile": "test/mail-test-srv/minica.pem", "SMTPTrustedRootFile": "test/mail-test-srv/minica.pem",

View File

@ -22,7 +22,10 @@
"timeout": "15s" "timeout": "15s"
}, },
"saService": { "saService": {
"serverAddress": "sa.boulder:9095", "serverIPAddresses": [
"10.77.77.77:9095",
"10.88.88.88:9095"
],
"timeout": "15s" "timeout": "15s"
} }
} }

View File

@ -37,7 +37,10 @@
"timeout": "300s" "timeout": "300s"
}, },
"saService": { "saService": {
"serverAddress": "sa.boulder:9095", "serverIPAddresses": [
"10.77.77.77:9095",
"10.88.88.88:9095"
],
"timeout": "15s" "timeout": "15s"
}, },
"akamaiPurgerService": { "akamaiPurgerService": {

View File

@ -24,7 +24,10 @@
"timeout": "15s" "timeout": "15s"
}, },
"saService": { "saService": {
"serverAddress": "sa.boulder:9095", "serverIPAddresses": [
"10.77.77.77:9095",
"10.88.88.88:9095"
],
"timeout": "15s" "timeout": "15s"
}, },
"accountCache": { "accountCache": {

View File

@ -14,6 +14,12 @@ for SERVICE in admin-revoker expiration-mailer ocsp-updater ocsp-responder \
minica -domains "${SERVICE}.boulder" minica -domains "${SERVICE}.boulder"
done done
NEEDIPSANS=( "sa" )
for SERVICE in publisher nonce ra ca sa va ; do for SERVICE in publisher nonce ra ca sa va ; do
if [[ "${NEEDIPSANS[@]}" =~ "${SERVICE}" ]]; then
minica -domains "${SERVICE}.boulder,${SERVICE}1.boulder,${SERVICE}2.boulder" \
-ip-addresses "10.77.77.77,10.88.88.88"
else
minica -domains "${SERVICE}.boulder,${SERVICE}1.boulder,${SERVICE}2.boulder" minica -domains "${SERVICE}.boulder,${SERVICE}1.boulder,${SERVICE}2.boulder"
fi
done done

View File

@ -1,19 +1,20 @@
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
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
q7nLPXzpwA==
-----END CERTIFICATE----- -----END CERTIFICATE-----

View File

@ -1,27 +1,27 @@
-----BEGIN RSA PRIVATE KEY----- -----BEGIN RSA PRIVATE KEY-----
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
-----END RSA PRIVATE KEY----- -----END RSA PRIVATE KEY-----