diff --git a/apis/projectcontour/v1/httpproxy.go b/apis/projectcontour/v1/httpproxy.go index c7ae244da59..7068357c768 100644 --- a/apis/projectcontour/v1/httpproxy.go +++ b/apis/projectcontour/v1/httpproxy.go @@ -130,6 +130,10 @@ type TLS struct { // 3. Specifies how the client certificate will be validated. // +optional ClientValidation *DownstreamValidation `json:"clientValidation,omitempty"` + + // EnableFallbackCertificate defines if the vhost should allow a default certificate to + // be applied which handles all requests which don't match the SNI defined in this vhost. + EnableFallbackCertificate bool `json:"enableFallbackCertificate,omitempty"` } // Route contains the set of routes for a virtual host. diff --git a/cmd/contour/serve.go b/cmd/contour/serve.go index 2cd06991b3f..e5ab5c7322b 100644 --- a/cmd/contour/serve.go +++ b/cmd/contour/serve.go @@ -138,9 +138,23 @@ func doServe(log logrus.FieldLogger, ctx *serveContext) error { // Create a set of SharedInformerFactories for each root-ingressroute namespace (if defined) namespacedInformerFactories := map[string]coreinformers.SharedInformerFactory{} - for _, namespace := range ctx.ingressRouteRootNamespaces() { - if _, ok := namespacedInformerFactories[namespace]; !ok { - namespacedInformerFactories[namespace] = clients.NewInformerFactoryForNamespace(namespace) + // Validate fallback certificate parameters + fallbackCert, err := ctx.fallbackCertificate() + if err != nil { + log.WithField("context", "fallback-certificate").Fatalf("invalid fallback certificate configuration: %q", err) + } + + if rootNamespaces := ctx.ingressRouteRootNamespaces(); len(rootNamespaces) > 0 { + // Add the FallbackCertificateNamespace to the root-namespaces if not already + if !contains(rootNamespaces, ctx.TLSConfig.FallbackCertificate.Namespace) && fallbackCert != nil { + rootNamespaces = append(rootNamespaces, ctx.FallbackCertificate.Namespace) + log.WithField("context", "fallback-certificate").Infof("fallback certificate namespace %q not defined in 'root-namespaces', adding namespace to watch", ctx.FallbackCertificate.Namespace) + } + + for _, namespace := range rootNamespaces { + if _, ok := namespacedInformerFactories[namespace]; !ok { + namespacedInformerFactories[namespace] = clients.NewInformerFactoryForNamespace(namespace) + } } } @@ -194,6 +208,13 @@ func doServe(log logrus.FieldLogger, ctx *serveContext) error { FieldLogger: log.WithField("context", "contourEventHandler"), } + // Set the fallbackcertificate if configured + if fallbackCert != nil { + log.WithField("context", "fallback-certificate").Infof("enabled fallback certificate with secret: %q", fallbackCert) + + eventHandler.FallbackCertificate = fallbackCert + } + // wrap eventHandler in a converter for objects from the dynamic client. // and an EventRecorder which tracks API server events. dynamicHandler := &k8s.DynamicClientHandler{ @@ -378,6 +399,15 @@ func doServe(log logrus.FieldLogger, ctx *serveContext) error { return g.Run() } +func contains(namespaces []string, ns string) bool { + for _, namespace := range namespaces { + if ns == namespace { + return true + } + } + return false +} + type informer interface { Start(stopCh <-chan struct{}) } diff --git a/cmd/contour/servecontext.go b/cmd/contour/servecontext.go index 8e49834c57d..1b4aa9b42ef 100644 --- a/cmd/contour/servecontext.go +++ b/cmd/contour/servecontext.go @@ -25,6 +25,8 @@ import ( "strings" "time" + "github.com/projectcontour/contour/internal/k8s" + "github.com/projectcontour/contour/internal/contour" "google.golang.org/grpc" "google.golang.org/grpc/credentials" @@ -172,6 +174,38 @@ func newServeContext() *serveContext { // TLSConfig holds configuration file TLS configuration details. type TLSConfig struct { MinimumProtocolVersion string `yaml:"minimum-protocol-version"` + + // FallbackCertificate defines the namespace/name of the Kubernetes secret to + // use as fallback when a non-SNI request is received. + FallbackCertificate FallbackCertificate `yaml:"fallback-certificate,omitempty"` +} + +// FallbackCertificate defines the namespace/name of the Kubernetes secret to +// use as fallback when a non-SNI request is received. +type FallbackCertificate struct { + Name string `yaml:"name"` + Namespace string `yaml:"namespace"` +} + +func (ctx *serveContext) fallbackCertificate() (*k8s.FullName, error) { + if len(strings.TrimSpace(ctx.TLSConfig.FallbackCertificate.Name)) == 0 && len(strings.TrimSpace(ctx.TLSConfig.FallbackCertificate.Namespace)) == 0 { + return nil, nil + } + + // Validate namespace is defined + if len(strings.TrimSpace(ctx.TLSConfig.FallbackCertificate.Namespace)) == 0 { + return nil, errors.New("namespace must be defined") + } + + // Validate name is defined + if len(strings.TrimSpace(ctx.TLSConfig.FallbackCertificate.Name)) == 0 { + return nil, errors.New("name must be defined") + } + + return &k8s.FullName{ + Name: ctx.TLSConfig.FallbackCertificate.Name, + Namespace: ctx.TLSConfig.FallbackCertificate.Namespace, + }, nil } // LeaderElectionConfig holds the config bits for leader election inside the @@ -272,6 +306,7 @@ func (ctx *serveContext) verifyTLSFlags() error { if !(ctx.caFile != "" && ctx.contourCert != "" && ctx.contourKey != "") { return errors.New("you must supply all three TLS parameters - --contour-cafile, --contour-cert-file, --contour-key-file, or none of them") } + return nil } diff --git a/cmd/contour/servecontext_test.go b/cmd/contour/servecontext_test.go index 477efdafe98..08fac2bb254 100644 --- a/cmd/contour/servecontext_test.go +++ b/cmd/contour/servecontext_test.go @@ -25,6 +25,8 @@ import ( "testing" "time" + "github.com/projectcontour/contour/internal/k8s" + "github.com/google/go-cmp/cmp" "github.com/projectcontour/contour/internal/assert" "google.golang.org/grpc" @@ -200,6 +202,72 @@ leaderelection: } } +func TestFallbackCertificateParams(t *testing.T) { + tests := map[string]struct { + ctx serveContext + want *k8s.FullName + expecterror bool + }{ + "fallback cert params passed correctly": { + ctx: serveContext{ + TLSConfig: TLSConfig{ + FallbackCertificate: FallbackCertificate{ + Name: "fallbacksecret", + Namespace: "root-namespace", + }, + }, + }, + want: &k8s.FullName{ + Name: "fallbacksecret", + Namespace: "root-namespace", + }, + expecterror: false, + }, + "missing namespace": { + ctx: serveContext{ + TLSConfig: TLSConfig{ + FallbackCertificate: FallbackCertificate{ + Name: "fallbacksecret", + }, + }, + }, + want: nil, + expecterror: true, + }, + "missing name": { + ctx: serveContext{ + TLSConfig: TLSConfig{ + FallbackCertificate: FallbackCertificate{ + Namespace: "root-namespace", + }, + }, + }, + want: nil, + expecterror: true, + }, + "fallback cert not defined": { + ctx: serveContext{}, + want: nil, + expecterror: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got, err := tc.ctx.fallbackCertificate() + + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatal(diff) + } + + goterror := err != nil + if goterror != tc.expecterror { + t.Errorf("Expected Fallback Certificate error: %s", err) + } + }) + } +} + // Testdata for this test case can be re-generated by running: // make gencerts // cp certs/*.pem cmd/contour/testdata/X/ diff --git a/examples/contour/01-contour-config.yaml b/examples/contour/01-contour-config.yaml index 80f20c319e0..bc475969110 100644 --- a/examples/contour/01-contour-config.yaml +++ b/examples/contour/01-contour-config.yaml @@ -21,8 +21,14 @@ data: # disable ingressroute permitInsecure field disablePermitInsecure: false tls: - # minimum TLS version that Contour will negotiate - # minimum-protocol-version: "1.1" + # minimum TLS version that Contour will negotiate + # minimum-protocol-version: "1.1" + # Defines the Kubernetes name/namespace matching a secret to use + # as the fallback certificate when requests which don't match the + # SNI defined for a vhost. + fallback-certificate: + # name: fallback-secret-name + # namespace: projectcontour # The following config shows the defaults for the leader election. # leaderelection: # configmap-name: leader-elect diff --git a/examples/contour/01-crds.yaml b/examples/contour/01-crds.yaml index 7a872814174..cda4555fdc1 100644 --- a/examples/contour/01-crds.yaml +++ b/examples/contour/01-crds.yaml @@ -1135,6 +1135,11 @@ spec: required: - caSecret type: object + enableFallbackCertificate: + description: EnableFallbackCertificate defines if the vhost + should allow a default certificate to be applied which handles + all requests which don't match the SNI defined in this vhost. + type: boolean minimumProtocolVersion: description: Minimum TLS version this vhost should negotiate type: string diff --git a/examples/render/contour.yaml b/examples/render/contour.yaml index 0bc7cddedf8..db6411a9bcf 100644 --- a/examples/render/contour.yaml +++ b/examples/render/contour.yaml @@ -53,8 +53,14 @@ data: # disable ingressroute permitInsecure field disablePermitInsecure: false tls: - # minimum TLS version that Contour will negotiate - # minimum-protocol-version: "1.1" + # minimum TLS version that Contour will negotiate + # minimum-protocol-version: "1.1" + # Defines the Kubernetes name/namespace matching a secret to use + # as the fallback certificate when requests which don't match the + # SNI defined for a vhost. + fallback-certificate: + # name: fallback-secret-name + # namespace: projectcontour # The following config shows the defaults for the leader election. # leaderelection: # configmap-name: leader-elect @@ -1227,6 +1233,11 @@ spec: required: - caSecret type: object + enableFallbackCertificate: + description: EnableFallbackCertificate defines if the vhost + should allow a default certificate to be applied which handles + all requests which don't match the SNI defined in this vhost. + type: boolean minimumProtocolVersion: description: Minimum TLS version this vhost should negotiate type: string diff --git a/internal/contour/listener.go b/internal/contour/listener.go index 5ca7dfb0f4a..c1331aba4cc 100644 --- a/internal/contour/listener.go +++ b/internal/contour/listener.go @@ -33,6 +33,7 @@ import ( const ( ENVOY_HTTP_LISTENER = "ingress_http" + ENVOY_FALLBACK_ROUTECONFIG = "ingress_fallbackcert" ENVOY_HTTPS_LISTENER = "ingress_https" DEFAULT_HTTP_ACCESS_LOG = "/dev/stdout" DEFAULT_HTTP_LISTENER_ADDRESS = "0.0.0.0" @@ -294,8 +295,8 @@ func visitListeners(root dag.Vertex, lvc *ListenerVisitorConfig) map[string]*v2. lv.visit(root) - // Add a listener if there are vhosts bound to http. if lv.http { + // Add a listener if there are vhosts bound to http. cm := envoy.HTTPConnectionManagerBuilder(). RouteConfigName(ENVOY_HTTP_LISTENER). MetricsPrefix(ENVOY_HTTP_LISTENER). @@ -310,7 +311,6 @@ func visitListeners(root dag.Vertex, lvc *ListenerVisitorConfig) map[string]*v2. proxyProtocol(lvc.UseProxyProto), cm, ) - } // Remove the https listener if there are no vhosts bound to it. @@ -403,6 +403,32 @@ func (v *listenerVisitor) visit(vertex dag.Vertex) { v.listeners[ENVOY_HTTPS_LISTENER].FilterChains = append(v.listeners[ENVOY_HTTPS_LISTENER].FilterChains, envoy.FilterChainTLS(vh.VirtualHost.Name, downstreamTLS, filters)) + // If this VirtualHost has enabled the fallback certificate then set a default FilterChain which will allow + // routes with this vhost to accept non SNI TLS requests + if vh.FallbackCertificate != nil && !envoy.ContainsFallbackFilterChain(v.listeners[ENVOY_HTTPS_LISTENER].FilterChains) { + + // Construct the downstreamTLSContext passing the configured fallbackCertificate. The TLS minProtocolVersion will use + // the value defined in the Contour Configuration file if defined. + downstreamTLS = envoy.DownstreamTLSContext( + vh.FallbackCertificate, + v.ListenerVisitorConfig.minProtoVersion(), + vh.DownstreamValidation, + alpnProtos...) + + // Default filter chain + filters = envoy.Filters( + envoy.HTTPConnectionManagerBuilder(). + RouteConfigName(ENVOY_FALLBACK_ROUTECONFIG). + MetricsPrefix(ENVOY_HTTPS_LISTENER). + AccessLoggers(v.ListenerVisitorConfig.newSecureAccessLog()). + RequestTimeout(v.ListenerVisitorConfig.requestTimeout()). + Get(), + ) + + v.listeners[ENVOY_HTTPS_LISTENER].FilterChains = append(v.listeners[ENVOY_HTTPS_LISTENER].FilterChains, + envoy.FilterChainTLSFallback(downstreamTLS, filters)) + } + default: // recurse vertex.Visit(v.visit) diff --git a/internal/contour/listener_test.go b/internal/contour/listener_test.go index 52c21a34b8d..2d970392224 100644 --- a/internal/contour/listener_test.go +++ b/internal/contour/listener_test.go @@ -17,12 +17,15 @@ import ( "path" "testing" + "github.com/projectcontour/contour/internal/k8s" + v2 "github.com/envoyproxy/go-control-plane/envoy/api/v2" envoy_api_v2_auth "github.com/envoyproxy/go-control-plane/envoy/api/v2/auth" envoy_api_v2_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core" envoy_api_v2_listener "github.com/envoyproxy/go-control-plane/envoy/api/v2/listener" "github.com/golang/protobuf/proto" ingressroutev1 "github.com/projectcontour/contour/apis/contour/v1beta1" + projcontour "github.com/projectcontour/contour/apis/projectcontour/v1" "github.com/projectcontour/contour/internal/assert" "github.com/projectcontour/contour/internal/dag" "github.com/projectcontour/contour/internal/envoy" @@ -132,10 +135,17 @@ func TestListenerVisit(t *testing.T) { Get() } + fallbackCertFilter := envoy.HTTPConnectionManagerBuilder(). + MetricsPrefix(ENVOY_HTTPS_LISTENER). + RouteConfigName(ENVOY_FALLBACK_ROUTECONFIG). + AccessLoggers(envoy.FileAccessLogEnvoy(DEFAULT_HTTP_ACCESS_LOG)). + Get() + tests := map[string]struct { ListenerVisitorConfig - objs []interface{} - want map[string]*v2.Listener + fallbackCertificate *k8s.FullName + objs []interface{} + want map[string]*v2.Listener }{ "nothing": { objs: nil, @@ -273,7 +283,7 @@ func TestListenerVisit(t *testing.T) { FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ ServerNames: []string{"whatever.example.com"}, }, - TransportSocket: transportSocket(envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), Filters: envoy.Filters(httpsFilterFor("whatever.example.com")), }}, }), @@ -360,13 +370,13 @@ func TestListenerVisit(t *testing.T) { FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ ServerNames: []string{"sortedfirst.example.com"}, }, - TransportSocket: transportSocket(envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), Filters: envoy.Filters(httpsFilterFor("sortedfirst.example.com")), }, { FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ ServerNames: []string{"sortedsecond.example.com"}, }, - TransportSocket: transportSocket(envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), Filters: envoy.Filters(httpsFilterFor("sortedsecond.example.com")), }}, }), @@ -482,7 +492,7 @@ func TestListenerVisit(t *testing.T) { FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ ServerNames: []string{"www.example.com"}, }, - TransportSocket: transportSocket(envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), Filters: envoy.Filters(httpsFilterFor("www.example.com")), }}, ListenerFilters: envoy.ListenerFilters( @@ -563,7 +573,7 @@ func TestListenerVisit(t *testing.T) { FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ ServerNames: []string{"www.example.com"}, }, - TransportSocket: transportSocket(envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), Filters: envoy.Filters(httpsFilterFor("www.example.com")), }}, ListenerFilters: envoy.ListenerFilters( @@ -637,7 +647,7 @@ func TestListenerVisit(t *testing.T) { FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ ServerNames: []string{"whatever.example.com"}, }, - TransportSocket: transportSocket(envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), Filters: envoy.Filters(httpsFilterFor("whatever.example.com")), }}, }), @@ -709,7 +719,7 @@ func TestListenerVisit(t *testing.T) { FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ ServerNames: []string{"whatever.example.com"}, }, - TransportSocket: transportSocket(envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), Filters: envoy.Filters(httpsFilterFor("whatever.example.com")), }}, }), @@ -778,7 +788,7 @@ func TestListenerVisit(t *testing.T) { FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ ServerNames: []string{"whatever.example.com"}, }, - TransportSocket: transportSocket(envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), Filters: envoy.Filters(envoy.HTTPConnectionManagerBuilder(). MetricsPrefix(ENVOY_HTTPS_LISTENER). RouteConfigName(path.Join("https", "whatever.example.com")). @@ -847,7 +857,7 @@ func TestListenerVisit(t *testing.T) { FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ ServerNames: []string{"whatever.example.com"}, }, - TransportSocket: transportSocket(envoy_api_v2_auth.TlsParameters_TLSv1_3, "h2", "http/1.1"), + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_3, "h2", "http/1.1"), Filters: envoy.Filters(httpsFilterFor("whatever.example.com")), }}, ListenerFilters: envoy.ListenerFilters( @@ -918,7 +928,7 @@ func TestListenerVisit(t *testing.T) { FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ ServerNames: []string{"whatever.example.com"}, }, - TransportSocket: transportSocket(envoy_api_v2_auth.TlsParameters_TLSv1_3, "h2", "http/1.1"), // note, cannot downgrade from the configured version + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_3, "h2", "http/1.1"), // note, cannot downgrade from the configured version Filters: envoy.Filters(httpsFilterFor("whatever.example.com")), }}, ListenerFilters: envoy.ListenerFilters( @@ -989,7 +999,7 @@ func TestListenerVisit(t *testing.T) { FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ ServerNames: []string{"whatever.example.com"}, }, - TransportSocket: transportSocket(envoy_api_v2_auth.TlsParameters_TLSv1_3, "h2", "http/1.1"), // note, cannot downgrade from the configured version + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_3, "h2", "http/1.1"), // note, cannot downgrade from the configured version Filters: envoy.Filters(httpsFilterFor("whatever.example.com")), }}, ListenerFilters: envoy.ListenerFilters( @@ -1060,7 +1070,342 @@ func TestListenerVisit(t *testing.T) { FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ ServerNames: []string{"www.example.com"}, }, - TransportSocket: transportSocket(envoy_api_v2_auth.TlsParameters_TLSv1_3, "h2", "http/1.1"), // note, cannot downgrade from the configured version + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_3, "h2", "http/1.1"), // note, cannot downgrade from the configured version + Filters: envoy.Filters(httpsFilterFor("www.example.com")), + }}, + ListenerFilters: envoy.ListenerFilters( + envoy.TLSInspector(), + ), + }), + }, + "httpproxy with fallback certificate": { + fallbackCertificate: &k8s.FullName{ + Name: "fallbacksecret", + Namespace: "default", + }, + objs: []interface{}{ + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "www.example.com", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: true, + }, + }, + Routes: []projcontour.Route{ + { + Services: []projcontour.Service{ + { + Name: "backend", + Port: 80, + }, + }, + }, + }, + }, + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fallbacksecret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backend", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Name: "http", + Protocol: "TCP", + Port: 80, + }}, + }, + }, + }, + want: listenermap(&v2.Listener{ + Name: ENVOY_HTTP_LISTENER, + Address: envoy.SocketAddress("0.0.0.0", 8080), + FilterChains: envoy.FilterChains(envoy.HTTPConnectionManager(ENVOY_HTTP_LISTENER, envoy.FileAccessLogEnvoy(DEFAULT_HTTP_ACCESS_LOG), 0)), + }, &v2.Listener{ + Name: ENVOY_HTTPS_LISTENER, + Address: envoy.SocketAddress("0.0.0.0", 8443), + FilterChains: []*envoy_api_v2_listener.FilterChain{{ + FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ + ServerNames: []string{"www.example.com"}, + }, + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), + Filters: envoy.Filters(httpsFilterFor("www.example.com")), + }, { + FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ + TransportProtocol: "tls", + }, + TransportSocket: transportSocket("fallbacksecret", envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), + Filters: envoy.Filters(fallbackCertFilter), + Name: "fallback-certificate", + }}, + ListenerFilters: envoy.ListenerFilters( + envoy.TLSInspector(), + ), + }), + }, + "multiple httpproxies with fallback certificate": { + fallbackCertificate: &k8s.FullName{ + Name: "fallbacksecret", + Namespace: "default", + }, + objs: []interface{}{ + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple2", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "www.another.com", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: true, + }, + }, + Routes: []projcontour.Route{ + { + Services: []projcontour.Service{ + { + Name: "backend", + Port: 80, + }, + }, + }, + }, + }, + }, + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "www.example.com", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: true, + }, + }, + Routes: []projcontour.Route{ + { + Services: []projcontour.Service{ + { + Name: "backend", + Port: 80, + }, + }, + }, + }, + }, + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fallbacksecret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backend", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Name: "http", + Protocol: "TCP", + Port: 80, + }}, + }, + }, + }, + want: listenermap(&v2.Listener{ + Name: ENVOY_HTTP_LISTENER, + Address: envoy.SocketAddress("0.0.0.0", 8080), + FilterChains: envoy.FilterChains(envoy.HTTPConnectionManager(ENVOY_HTTP_LISTENER, envoy.FileAccessLogEnvoy(DEFAULT_HTTP_ACCESS_LOG), 0)), + }, &v2.Listener{ + Name: ENVOY_HTTPS_LISTENER, + Address: envoy.SocketAddress("0.0.0.0", 8443), + FilterChains: []*envoy_api_v2_listener.FilterChain{ + { + FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ + ServerNames: []string{"www.another.com"}, + }, + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), + Filters: envoy.Filters(httpsFilterFor("www.another.com")), + }, + { + FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ + ServerNames: []string{"www.example.com"}, + }, + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), + Filters: envoy.Filters(httpsFilterFor("www.example.com")), + }, + { + FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ + TransportProtocol: "tls", + }, + TransportSocket: transportSocket("fallbacksecret", envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), + Filters: envoy.Filters(fallbackCertFilter), + Name: "fallback-certificate", + }}, + ListenerFilters: envoy.ListenerFilters( + envoy.TLSInspector(), + ), + }), + }, + "httpproxy with fallback certificate - no cert passed": { + fallbackCertificate: &k8s.FullName{ + Name: "", + Namespace: "", + }, + objs: []interface{}{ + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "www.example.com", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: true, + }, + }, + Routes: []projcontour.Route{ + { + Services: []projcontour.Service{ + { + Name: "backend", + Port: 80, + }, + }, + }, + }, + }, + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backend", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Name: "http", + Protocol: "TCP", + Port: 80, + }}, + }, + }, + }, + want: listenermap(), + }, + "httpproxy with fallback certificate - cert passed but vhost not enabled": { + fallbackCertificate: &k8s.FullName{ + Name: "fallbackcert", + Namespace: "default", + }, + objs: []interface{}{ + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "www.example.com", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: false, + }, + }, + Routes: []projcontour.Route{ + { + Services: []projcontour.Service{ + { + Name: "backend", + Port: 80, + }, + }, + }, + }, + }, + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backend", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Name: "http", + Protocol: "TCP", + Port: 80, + }}, + }, + }, + }, + want: listenermap(&v2.Listener{ + Name: ENVOY_HTTP_LISTENER, + Address: envoy.SocketAddress("0.0.0.0", 8080), + FilterChains: envoy.FilterChains(envoy.HTTPConnectionManager(ENVOY_HTTP_LISTENER, envoy.FileAccessLogEnvoy(DEFAULT_HTTP_ACCESS_LOG), 0)), + }, &v2.Listener{ + Name: ENVOY_HTTPS_LISTENER, + Address: envoy.SocketAddress("0.0.0.0", 8443), + FilterChains: []*envoy_api_v2_listener.FilterChain{{ + FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ + ServerNames: []string{"www.example.com"}, + }, + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_1, "h2", "http/1.1"), Filters: envoy.Filters(httpsFilterFor("www.example.com")), }}, ListenerFilters: envoy.ListenerFilters( @@ -1072,18 +1417,18 @@ func TestListenerVisit(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { - root := buildDAG(t, tc.objs...) + root := buildDAGFallback(t, tc.fallbackCertificate, tc.objs...) got := visitListeners(root, &tc.ListenerVisitorConfig) assert.Equal(t, tc.want, got) }) } } -func transportSocket(tlsMinProtoVersion envoy_api_v2_auth.TlsParameters_TlsProtocol, alpnprotos ...string) *envoy_api_v2_core.TransportSocket { +func transportSocket(secretname string, tlsMinProtoVersion envoy_api_v2_auth.TlsParameters_TlsProtocol, alpnprotos ...string) *envoy_api_v2_core.TransportSocket { secret := &dag.Secret{ Object: &v1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: "secret", + Name: secretname, Namespace: "default", }, Type: v1.SecretTypeTLS, diff --git a/internal/contour/route.go b/internal/contour/route.go index 02695d393cd..1634010b766 100644 --- a/internal/contour/route.go +++ b/internal/contour/route.go @@ -189,6 +189,19 @@ func (v *routeVisitor) onSecureVirtualHost(svh *dag.SecureVirtualHost) { v.routes[name].VirtualHosts = append(v.routes[name].VirtualHosts, envoy.VirtualHost(svh.VirtualHost.Name, routes...)) + + // A fallback route configuration contains routes for all the vhosts that have the fallback certificate enabled. + // When a request is received, the default TLS filterchain will accept the connection, + // and this routing table in RDS defines where the request proxies next. + if svh.FallbackCertificate != nil { + // Add fallback route if not already + if _, ok := v.routes[ENVOY_FALLBACK_ROUTECONFIG]; !ok { + v.routes[ENVOY_FALLBACK_ROUTECONFIG] = envoy.RouteConfiguration(ENVOY_FALLBACK_ROUTECONFIG) + } + + v.routes[ENVOY_FALLBACK_ROUTECONFIG].VirtualHosts = append(v.routes[ENVOY_FALLBACK_ROUTECONFIG].VirtualHosts, + envoy.VirtualHost(svh.Name, routes...)) + } } } diff --git a/internal/contour/route_test.go b/internal/contour/route_test.go index 93bdb22b688..6ba876401f8 100644 --- a/internal/contour/route_test.go +++ b/internal/contour/route_test.go @@ -27,6 +27,7 @@ import ( "github.com/projectcontour/contour/internal/assert" "github.com/projectcontour/contour/internal/dag" "github.com/projectcontour/contour/internal/envoy" + "github.com/projectcontour/contour/internal/k8s" "github.com/projectcontour/contour/internal/protobuf" v1 "k8s.io/api/core/v1" "k8s.io/api/networking/v1beta1" @@ -135,8 +136,9 @@ func TestRouteCacheQuery(t *testing.T) { func TestRouteVisit(t *testing.T) { tests := map[string]struct { - objs []interface{} - want map[string]*v2.RouteConfiguration + objs []interface{} + fallbackCertificate *k8s.FullName + want map[string]*v2.RouteConfiguration }{ "nothing": { objs: nil, @@ -2027,11 +2029,727 @@ func TestRouteVisit(t *testing.T) { ), ), }, + "httpproxy with fallback certificate": { + fallbackCertificate: &k8s.FullName{ + Name: "fallbacksecret", + Namespace: "default", + }, + objs: []interface{}{ + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "www.example.com", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: true, + }, + }, + Routes: []projcontour.Route{{ + Conditions: []projcontour.Condition{{ + Prefix: "/", + }}, + Services: []projcontour.Service{{ + Name: "backend", + Port: 80, + }, { + Name: "backendtwo", + Port: 80, + }}, + }}, + }, + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fallbacksecret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backend", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Protocol: "TCP", + Port: 80, + TargetPort: intstr.FromInt(8080), + }}, + }, + }, + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backendtwo", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Protocol: "TCP", + Port: 80, + TargetPort: intstr.FromInt(8080), + }}, + }, + }, + }, + want: routeConfigurations( + envoy.RouteConfiguration("ingress_http", + envoy.VirtualHost("www.example.com", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Redirect{ + Redirect: &envoy_api_v2_route.RedirectAction{ + SchemeRewriteSpecifier: &envoy_api_v2_route.RedirectAction_HttpsRedirect{ + HttpsRedirect: true, + }, + }, + }, + }, + ), + ), + envoy.RouteConfiguration("https/www.example.com", + envoy.VirtualHost("www.example.com", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Route{ + Route: &envoy_api_v2_route.RouteAction{ + ClusterSpecifier: &envoy_api_v2_route.RouteAction_WeightedClusters{ + WeightedClusters: &envoy_api_v2_route.WeightedCluster{ + Clusters: weightedClusters( + weightedCluster("default/backend/80/da39a3ee5e", 1), + weightedCluster("default/backendtwo/80/da39a3ee5e", 1), + ), + TotalWeight: protobuf.UInt32(2), + }, + }, + }, + }, + }, + )), + envoy.RouteConfiguration(ENVOY_FALLBACK_ROUTECONFIG, + envoy.VirtualHost("www.example.com", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Route{ + Route: &envoy_api_v2_route.RouteAction{ + ClusterSpecifier: &envoy_api_v2_route.RouteAction_WeightedClusters{ + WeightedClusters: &envoy_api_v2_route.WeightedCluster{ + Clusters: weightedClusters( + weightedCluster("default/backend/80/da39a3ee5e", 1), + weightedCluster("default/backendtwo/80/da39a3ee5e", 1), + ), + TotalWeight: protobuf.UInt32(2), + }, + }, + }, + }, + }, + )), + ), + }, + "httpproxy with fallback certificate - one enabled": { + fallbackCertificate: &k8s.FullName{ + Name: "fallbacksecret", + Namespace: "default", + }, + objs: []interface{}{ + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "www.example.com", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: false, + }, + }, + Routes: []projcontour.Route{{ + Conditions: []projcontour.Condition{{ + Prefix: "/", + }}, + Services: []projcontour.Service{{ + Name: "backend", + Port: 80, + }, { + Name: "backendtwo", + Port: 80, + }}, + }}, + }, + }, + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple-enabled", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "projectcontour.io", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: true, + }, + }, + Routes: []projcontour.Route{{ + Conditions: []projcontour.Condition{{ + Prefix: "/", + }}, + Services: []projcontour.Service{{ + Name: "backend", + Port: 80, + }, { + Name: "backendtwo", + Port: 80, + }}, + }}, + }, + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fallbacksecret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backend", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Protocol: "TCP", + Port: 80, + TargetPort: intstr.FromInt(8080), + }}, + }, + }, + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backendtwo", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Protocol: "TCP", + Port: 80, + TargetPort: intstr.FromInt(8080), + }}, + }, + }, + }, + want: routeConfigurations( + envoy.RouteConfiguration("ingress_http", + envoy.VirtualHost("projectcontour.io", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Redirect{ + Redirect: &envoy_api_v2_route.RedirectAction{ + SchemeRewriteSpecifier: &envoy_api_v2_route.RedirectAction_HttpsRedirect{ + HttpsRedirect: true, + }, + }, + }, + }, + ), + envoy.VirtualHost("www.example.com", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Redirect{ + Redirect: &envoy_api_v2_route.RedirectAction{ + SchemeRewriteSpecifier: &envoy_api_v2_route.RedirectAction_HttpsRedirect{ + HttpsRedirect: true, + }, + }, + }, + }, + ), + ), + envoy.RouteConfiguration("https/projectcontour.io", + envoy.VirtualHost("projectcontour.io", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Route{ + Route: &envoy_api_v2_route.RouteAction{ + ClusterSpecifier: &envoy_api_v2_route.RouteAction_WeightedClusters{ + WeightedClusters: &envoy_api_v2_route.WeightedCluster{ + Clusters: weightedClusters( + weightedCluster("default/backend/80/da39a3ee5e", 1), + weightedCluster("default/backendtwo/80/da39a3ee5e", 1), + ), + TotalWeight: protobuf.UInt32(2), + }, + }, + }, + }, + }, + )), + envoy.RouteConfiguration("https/www.example.com", + envoy.VirtualHost("www.example.com", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Route{ + Route: &envoy_api_v2_route.RouteAction{ + ClusterSpecifier: &envoy_api_v2_route.RouteAction_WeightedClusters{ + WeightedClusters: &envoy_api_v2_route.WeightedCluster{ + Clusters: weightedClusters( + weightedCluster("default/backend/80/da39a3ee5e", 1), + weightedCluster("default/backendtwo/80/da39a3ee5e", 1), + ), + TotalWeight: protobuf.UInt32(2), + }, + }, + }, + }, + }, + )), + envoy.RouteConfiguration(ENVOY_FALLBACK_ROUTECONFIG, + envoy.VirtualHost("projectcontour.io", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Route{ + Route: &envoy_api_v2_route.RouteAction{ + ClusterSpecifier: &envoy_api_v2_route.RouteAction_WeightedClusters{ + WeightedClusters: &envoy_api_v2_route.WeightedCluster{ + Clusters: weightedClusters( + weightedCluster("default/backend/80/da39a3ee5e", 1), + weightedCluster("default/backendtwo/80/da39a3ee5e", 1), + ), + TotalWeight: protobuf.UInt32(2), + }, + }, + }, + }, + }, + )), + ), + }, + "httpproxy with fallback certificate - two enabled": { + fallbackCertificate: &k8s.FullName{ + Name: "fallbacksecret", + Namespace: "default", + }, + objs: []interface{}{ + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "www.example.com", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: true, + }, + }, + Routes: []projcontour.Route{{ + Conditions: []projcontour.Condition{{ + Prefix: "/", + }}, + Services: []projcontour.Service{{ + Name: "backend", + Port: 80, + }, { + Name: "backendtwo", + Port: 80, + }}, + }}, + }, + }, + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple-enabled", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "projectcontour.io", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: true, + }, + }, + Routes: []projcontour.Route{{ + Conditions: []projcontour.Condition{{ + Prefix: "/", + }}, + Services: []projcontour.Service{{ + Name: "backend", + Port: 80, + }, { + Name: "backendtwo", + Port: 80, + }}, + }}, + }, + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fallbacksecret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backend", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Protocol: "TCP", + Port: 80, + TargetPort: intstr.FromInt(8080), + }}, + }, + }, + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backendtwo", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Protocol: "TCP", + Port: 80, + TargetPort: intstr.FromInt(8080), + }}, + }, + }, + }, + want: routeConfigurations( + envoy.RouteConfiguration("ingress_http", + envoy.VirtualHost("projectcontour.io", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Redirect{ + Redirect: &envoy_api_v2_route.RedirectAction{ + SchemeRewriteSpecifier: &envoy_api_v2_route.RedirectAction_HttpsRedirect{ + HttpsRedirect: true, + }, + }, + }, + }, + ), + envoy.VirtualHost("www.example.com", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Redirect{ + Redirect: &envoy_api_v2_route.RedirectAction{ + SchemeRewriteSpecifier: &envoy_api_v2_route.RedirectAction_HttpsRedirect{ + HttpsRedirect: true, + }, + }, + }, + }, + ), + ), + envoy.RouteConfiguration("https/projectcontour.io", + envoy.VirtualHost("projectcontour.io", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Route{ + Route: &envoy_api_v2_route.RouteAction{ + ClusterSpecifier: &envoy_api_v2_route.RouteAction_WeightedClusters{ + WeightedClusters: &envoy_api_v2_route.WeightedCluster{ + Clusters: weightedClusters( + weightedCluster("default/backend/80/da39a3ee5e", 1), + weightedCluster("default/backendtwo/80/da39a3ee5e", 1), + ), + TotalWeight: protobuf.UInt32(2), + }, + }, + }, + }, + }, + )), + envoy.RouteConfiguration("https/www.example.com", + envoy.VirtualHost("www.example.com", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Route{ + Route: &envoy_api_v2_route.RouteAction{ + ClusterSpecifier: &envoy_api_v2_route.RouteAction_WeightedClusters{ + WeightedClusters: &envoy_api_v2_route.WeightedCluster{ + Clusters: weightedClusters( + weightedCluster("default/backend/80/da39a3ee5e", 1), + weightedCluster("default/backendtwo/80/da39a3ee5e", 1), + ), + TotalWeight: protobuf.UInt32(2), + }, + }, + }, + }, + }, + )), + envoy.RouteConfiguration(ENVOY_FALLBACK_ROUTECONFIG, + envoy.VirtualHost("projectcontour.io", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Route{ + Route: &envoy_api_v2_route.RouteAction{ + ClusterSpecifier: &envoy_api_v2_route.RouteAction_WeightedClusters{ + WeightedClusters: &envoy_api_v2_route.WeightedCluster{ + Clusters: weightedClusters( + weightedCluster("default/backend/80/da39a3ee5e", 1), + weightedCluster("default/backendtwo/80/da39a3ee5e", 1), + ), + TotalWeight: protobuf.UInt32(2), + }, + }, + }, + }, + }, + ), envoy.VirtualHost("www.example.com", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Route{ + Route: &envoy_api_v2_route.RouteAction{ + ClusterSpecifier: &envoy_api_v2_route.RouteAction_WeightedClusters{ + WeightedClusters: &envoy_api_v2_route.WeightedCluster{ + Clusters: weightedClusters( + weightedCluster("default/backend/80/da39a3ee5e", 1), + weightedCluster("default/backendtwo/80/da39a3ee5e", 1), + ), + TotalWeight: protobuf.UInt32(2), + }, + }, + }, + }, + }, + )), + ), + }, + "httpproxy with fallback certificate - bad global cert": { + fallbackCertificate: &k8s.FullName{ + Name: "fallbacksecret", + Namespace: "badnamespace", + }, + objs: []interface{}{ + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "www.example.com", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: true, + }, + }, + Routes: []projcontour.Route{{ + Conditions: []projcontour.Condition{{ + Prefix: "/", + }}, + Services: []projcontour.Service{{ + Name: "backend", + Port: 80, + }, { + Name: "backendtwo", + Port: 80, + }}, + }}, + }, + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fallbacksecret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backend", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Protocol: "TCP", + Port: 80, + TargetPort: intstr.FromInt(8080), + }}, + }, + }, + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backendtwo", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Protocol: "TCP", + Port: 80, + TargetPort: intstr.FromInt(8080), + }}, + }, + }, + }, + want: routeConfigurations(envoy.RouteConfiguration("ingress_http")), + }, + "httpproxy with fallback certificate - no fqdn enabled": { + fallbackCertificate: &k8s.FullName{ + Name: "fallbacksecret", + Namespace: "default", + }, + objs: []interface{}{ + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "www.example.com", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: false, + }, + }, + Routes: []projcontour.Route{{ + Conditions: []projcontour.Condition{{ + Prefix: "/", + }}, + Services: []projcontour.Service{{ + Name: "backend", + Port: 80, + }, { + Name: "backendtwo", + Port: 80, + }}, + }}, + }, + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fallbacksecret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + }, + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backend", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Protocol: "TCP", + Port: 80, + TargetPort: intstr.FromInt(8080), + }}, + }, + }, + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backendtwo", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Protocol: "TCP", + Port: 80, + TargetPort: intstr.FromInt(8080), + }}, + }, + }, + }, + want: routeConfigurations( + envoy.RouteConfiguration("ingress_http", + envoy.VirtualHost("www.example.com", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Redirect{ + Redirect: &envoy_api_v2_route.RedirectAction{ + SchemeRewriteSpecifier: &envoy_api_v2_route.RedirectAction_HttpsRedirect{ + HttpsRedirect: true, + }, + }, + }, + }, + ), + ), + envoy.RouteConfiguration("https/www.example.com", + envoy.VirtualHost("www.example.com", + &envoy_api_v2_route.Route{ + Match: routePrefix("/"), + Action: &envoy_api_v2_route.Route_Route{ + Route: &envoy_api_v2_route.RouteAction{ + ClusterSpecifier: &envoy_api_v2_route.RouteAction_WeightedClusters{ + WeightedClusters: &envoy_api_v2_route.WeightedCluster{ + Clusters: weightedClusters( + weightedCluster("default/backend/80/da39a3ee5e", 1), + weightedCluster("default/backendtwo/80/da39a3ee5e", 1), + ), + TotalWeight: protobuf.UInt32(2), + }, + }, + }, + }, + }, + )), + ), + }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { - root := buildDAG(t, tc.objs...) + root := buildDAGFallback(t, tc.fallbackCertificate, tc.objs...) got := visitRoutes(root) assert.Equal(t, tc.want, got) }) diff --git a/internal/contour/secret_test.go b/internal/contour/secret_test.go index a383d389abb..b27376dce56 100644 --- a/internal/contour/secret_test.go +++ b/internal/contour/secret_test.go @@ -22,6 +22,7 @@ import ( ingressroutev1 "github.com/projectcontour/contour/apis/contour/v1beta1" "github.com/projectcontour/contour/internal/assert" "github.com/projectcontour/contour/internal/dag" + "github.com/projectcontour/contour/internal/k8s" v1 "k8s.io/api/core/v1" "k8s.io/api/networking/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -488,6 +489,21 @@ func buildDAG(t *testing.T, objs ...interface{}) *dag.DAG { return builder.Build() } +// buildDAGFallback produces a dag.DAG from the supplied objects with a fallback cert configured. +func buildDAGFallback(t *testing.T, fallbackCertificate *k8s.FullName, objs ...interface{}) *dag.DAG { + builder := dag.Builder{ + Source: dag.KubernetesCache{ + FieldLogger: testLogger(t), + }, + FallbackCertificate: fallbackCertificate, + } + + for _, o := range objs { + builder.Source.Insert(o) + } + return builder.Build() +} + func secretmap(secrets ...*envoy_api_v2_auth.Secret) map[string]*envoy_api_v2_auth.Secret { m := make(map[string]*envoy_api_v2_auth.Secret) for _, s := range secrets { diff --git a/internal/contour/visitor_test.go b/internal/contour/visitor_test.go index 3ff889f82b6..be8d47bb035 100644 --- a/internal/contour/visitor_test.go +++ b/internal/contour/visitor_test.go @@ -129,7 +129,7 @@ func TestVisitListeners(t *testing.T) { FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ ServerNames: []string{"tcpproxy.example.com"}, }, - TransportSocket: transportSocket(envoy_api_v2_auth.TlsParameters_TLSv1_1), + TransportSocket: transportSocket("secret", envoy_api_v2_auth.TlsParameters_TLSv1_1), Filters: envoy.Filters(envoy.TCPProxy(ENVOY_HTTPS_LISTENER, p1, envoy.FileAccessLogEnvoy(DEFAULT_HTTPS_ACCESS_LOG))), }}, ListenerFilters: envoy.ListenerFilters( diff --git a/internal/dag/builder.go b/internal/dag/builder.go index 548f3120158..a036f627a6d 100644 --- a/internal/dag/builder.go +++ b/internal/dag/builder.go @@ -50,6 +50,8 @@ type Builder struct { orphaned map[k8s.FullName]bool + FallbackCertificate *k8s.FullName + StatusWriter } @@ -502,6 +504,7 @@ func (b *Builder) computeHTTPProxy(proxy *projcontour.HTTPProxy) { // Attach secrets to TLS enabled vhosts. if !tls.Passthrough { + secretName := splitSecret(tls.SecretName, proxy.Namespace) sec, err := b.lookupSecret(secretName, validSecret) if err != nil { @@ -516,7 +519,28 @@ func (b *Builder) computeHTTPProxy(proxy *projcontour.HTTPProxy) { svhost := b.lookupSecureVirtualHost(host) svhost.Secret = sec - svhost.MinProtoVersion = annotation.MinProtoVersion(proxy.Spec.VirtualHost.TLS.MinimumProtocolVersion) + svhost.MinProtoVersion = annotation.MinProtoVersion(tls.MinimumProtocolVersion) + + // Check if FallbackCertificate && ClientValidation are both enabled in the same vhost + if tls.EnableFallbackCertificate && tls.ClientValidation != nil { + sw.SetInvalid("Spec.Virtualhost.TLS fallback & client validation are incompatible together") + return + } + + // If FallbackCertificate is enabled, but no cert passed, set error + if tls.EnableFallbackCertificate { + if b.FallbackCertificate == nil { + sw.SetInvalid("Spec.Virtualhost.TLS enabled fallback but the fallback Certificate Secret is not configured in Contour configuration file") + return + } + + sec, err = b.lookupSecret(k8s.FullName{Name: b.FallbackCertificate.Name, Namespace: b.FallbackCertificate.Namespace}, validSecret) + if err != nil { + sw.SetInvalid("Spec.Virtualhost.TLS fallback certificate Secret %q is invalid: %s", b.FallbackCertificate, err) + return + } + svhost.FallbackCertificate = sec + } // Fill in DownstreamValidation when external client validation is enabled. if tls.ClientValidation != nil { diff --git a/internal/dag/builder_test.go b/internal/dag/builder_test.go index 5bc0630972a..4d60a5d321e 100644 --- a/internal/dag/builder_test.go +++ b/internal/dag/builder_test.go @@ -67,6 +67,15 @@ func TestDAGInsert(t *testing.T) { }, } + fallbackCertificateSecret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fallbacksecret", + Namespace: "default", + }, + Type: v1.SecretTypeTLS, + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + } + cert1 := &v1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "ca", @@ -3303,9 +3312,11 @@ func TestDAGInsert(t *testing.T) { } tests := map[string]struct { - objs []interface{} - disablePermitInsecure bool - want []Vertex + objs []interface{} + disablePermitInsecure bool + fallbackCertificateName string + fallbackCertificateNamespace string + want []Vertex }{ "insert ingress w/ default backend w/o matching service": { objs: []interface{}{ @@ -6375,12 +6386,317 @@ func TestDAGInsert(t *testing.T) { }, ), }, + "httpproxy with fallback certificate enabled": { + fallbackCertificateName: "fallbacksecret", + fallbackCertificateNamespace: "default", + objs: []interface{}{ + sec1, + s9, + fallbackCertificateSecret, + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nginx", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "example.com", + TLS: &projcontour.TLS{ + SecretName: sec1.Name, + EnableFallbackCertificate: true, + }, + }, + Routes: []projcontour.Route{{ + Services: []projcontour.Service{{ + Name: "nginx", + Port: 80, + }}, + }}, + }, + }, + }, + want: listeners( + &Listener{ + Port: 80, + VirtualHosts: virtualhosts( + virtualhost("example.com", routeUpgrade("/", service(s9))), + ), + }, + &Listener{ + Port: 443, + VirtualHosts: virtualhosts( + &SecureVirtualHost{ + VirtualHost: VirtualHost{ + Name: "example.com", + routes: routes(routeUpgrade("/", service(s9))), + }, + MinProtoVersion: envoy_api_v2_auth.TlsParameters_TLSv1_1, + Secret: secret(sec1), + FallbackCertificate: secret(fallbackCertificateSecret), + }, + ), + }, + ), + }, + "httpproxy with fallback certificate enabled - no tls secret": { + fallbackCertificateName: "fallbacksecret", + fallbackCertificateNamespace: "default", + objs: []interface{}{ + sec1, + s9, + fallbackCertificateSecret, + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nginx", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "example.com", + TLS: &projcontour.TLS{ + EnableFallbackCertificate: true, + }, + }, + Routes: []projcontour.Route{{ + Services: []projcontour.Service{{ + Name: "nginx", + Port: 80, + }}, + }}, + }, + }, + }, + want: nil, + }, + "httpproxy with fallback certificate enabled along with ClientValidation": { + fallbackCertificateName: "fallbacksecret", + fallbackCertificateNamespace: "default", + objs: []interface{}{ + sec1, + s9, + fallbackCertificateSecret, + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nginx", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "example.com", + TLS: &projcontour.TLS{ + EnableFallbackCertificate: true, + ClientValidation: &projcontour.DownstreamValidation{ + CACertificate: cert1.Name, + }, + }, + }, + Routes: []projcontour.Route{{ + Services: []projcontour.Service{{ + Name: "nginx", + Port: 80, + }}, + }}, + }, + }, + }, + want: nil, + }, + "httpproxy with fallback certificate enabled - another not enabled": { + fallbackCertificateName: "fallbacksecret", + fallbackCertificateNamespace: "default", + objs: []interface{}{ + sec1, + s9, + fallbackCertificateSecret, + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nginx", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "example.com", + TLS: &projcontour.TLS{ + SecretName: sec1.Name, + EnableFallbackCertificate: true, + }, + }, + Routes: []projcontour.Route{{ + Services: []projcontour.Service{{ + Name: "nginx", + Port: 80, + }}, + }}, + }, + }, + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nginx-disabled", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "projectcontour.io", + TLS: &projcontour.TLS{ + SecretName: sec1.Name, + EnableFallbackCertificate: false, + }, + }, + Routes: []projcontour.Route{{ + Services: []projcontour.Service{{ + Name: "nginx", + Port: 80, + }}, + }}, + }, + }, + }, + want: listeners( + &Listener{ + Port: 80, + VirtualHosts: virtualhosts( + virtualhost("example.com", routeUpgrade("/", service(s9))), + virtualhost("projectcontour.io", routeUpgrade("/", service(s9))), + ), + }, + &Listener{ + Port: 443, + VirtualHosts: virtualhosts( + &SecureVirtualHost{ + VirtualHost: VirtualHost{ + Name: "example.com", + routes: routes(routeUpgrade("/", service(s9))), + }, + MinProtoVersion: envoy_api_v2_auth.TlsParameters_TLSv1_1, + Secret: secret(sec1), + FallbackCertificate: secret(fallbackCertificateSecret), + }, + &SecureVirtualHost{ + VirtualHost: VirtualHost{ + Name: "projectcontour.io", + routes: routes(routeUpgrade("/", service(s9))), + }, + MinProtoVersion: envoy_api_v2_auth.TlsParameters_TLSv1_1, + Secret: secret(sec1), + FallbackCertificate: nil, + }, + ), + }, + ), + }, + "httpproxy with fallback certificate enabled - bad fallback cert": { + fallbackCertificateName: "fallbacksecret", + fallbackCertificateNamespace: "badnamespaces", + objs: []interface{}{ + sec1, + s9, + fallbackCertificateSecret, + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nginx", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "example.com", + TLS: &projcontour.TLS{ + SecretName: sec1.Name, + }, + }, + Routes: []projcontour.Route{{ + Services: []projcontour.Service{{ + Name: "nginx", + Port: 80, + }}, + }}, + }, + }, + }, + want: listeners( + &Listener{ + Port: 80, + VirtualHosts: virtualhosts( + virtualhost("example.com", routeUpgrade("/", service(s9))), + ), + }, + &Listener{ + Port: 443, + VirtualHosts: virtualhosts( + &SecureVirtualHost{ + VirtualHost: VirtualHost{ + Name: "example.com", + routes: routes(routeUpgrade("/", service(s9))), + }, + MinProtoVersion: envoy_api_v2_auth.TlsParameters_TLSv1_1, + Secret: secret(sec1), + FallbackCertificate: nil, + }, + ), + }, + ), + }, + "httpproxy with fallback certificate disabled - fallback cert specified": { + fallbackCertificateName: "fallbacksecret", + fallbackCertificateNamespace: "default", + objs: []interface{}{ + sec1, + s9, + fallbackCertificateSecret, + &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nginx", + Namespace: "default", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "example.com", + TLS: &projcontour.TLS{ + SecretName: sec1.Name, + EnableFallbackCertificate: false, + }, + }, + Routes: []projcontour.Route{{ + Services: []projcontour.Service{{ + Name: "nginx", + Port: 80, + }}, + }}, + }, + }, + }, + want: listeners( + &Listener{ + Port: 80, + VirtualHosts: virtualhosts( + virtualhost("example.com", routeUpgrade("/", service(s9))), + ), + }, + &Listener{ + Port: 443, + VirtualHosts: virtualhosts( + &SecureVirtualHost{ + VirtualHost: VirtualHost{ + Name: "example.com", + routes: routes(routeUpgrade("/", service(s9))), + }, + MinProtoVersion: envoy_api_v2_auth.TlsParameters_TLSv1_1, + Secret: secret(sec1), + FallbackCertificate: nil, + }, + ), + }, + ), + }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { builder := Builder{ DisablePermitInsecure: tc.disablePermitInsecure, + FallbackCertificate: &k8s.FullName{ + Name: tc.fallbackCertificateName, + Namespace: tc.fallbackCertificateNamespace, + }, Source: KubernetesCache{ FieldLogger: testLogger(t), }, diff --git a/internal/dag/dag.go b/internal/dag/dag.go index bd26d9c4d5b..1de2d76ca94 100644 --- a/internal/dag/dag.go +++ b/internal/dag/dag.go @@ -261,6 +261,9 @@ type SecureVirtualHost struct { // The cert and key for this host. Secret *Secret + // FallbackCertificate + FallbackCertificate *Secret + // Service to TCP proxy all incoming connections. *TCPProxy diff --git a/internal/dag/status_test.go b/internal/dag/status_test.go index 8e8b8c4ab58..cbff75cb481 100644 --- a/internal/dag/status_test.go +++ b/internal/dag/status_test.go @@ -27,7 +27,7 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" ) -func TestDAGIngressRouteStatus(t *testing.T) { +func TestDAGStatus(t *testing.T) { sec1 := &v1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "ssl-cert", @@ -46,6 +46,15 @@ func TestDAGIngressRouteStatus(t *testing.T) { Data: sec1.Data, } + fallbackSecret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fallbacksecret", + Namespace: "roots", + }, + Type: v1.SecretTypeTLS, + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + } + s1 := &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "kuard", @@ -1885,9 +1894,63 @@ func TestDAGIngressRouteStatus(t *testing.T) { }, } + fallbackCertificate := &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "roots", + Name: "example", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "example.com", + TLS: &projcontour.TLS{ + SecretName: "ssl-cert", + EnableFallbackCertificate: true, + }, + }, + Routes: []projcontour.Route{{ + Conditions: []projcontour.Condition{{ + Prefix: "/foo", + }}, + Services: []projcontour.Service{{ + Name: "home", + Port: 8080, + }}, + }}, + }, + } + + fallbackCertificateWithClientValidation := &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "roots", + Name: "example", + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "example.com", + TLS: &projcontour.TLS{ + SecretName: "ssl-cert", + EnableFallbackCertificate: true, + ClientValidation: &projcontour.DownstreamValidation{ + CACertificate: "something", + }, + }, + }, + Routes: []projcontour.Route{{ + Conditions: []projcontour.Condition{{ + Prefix: "/foo", + }}, + Services: []projcontour.Service{{ + Name: "home", + Port: 8080, + }}, + }}, + }, + } + tests := map[string]struct { - objs []interface{} - want map[k8s.FullName]Status + objs []interface{} + fallbackCertificate *k8s.FullName + want map[k8s.FullName]Status }{ "valid ingressroute": { objs: []interface{}{ir1, s4}, @@ -2583,11 +2646,34 @@ func TestDAGIngressRouteStatus(t *testing.T) { }, }, }, + "invalid fallback certificate passed to contour": { + fallbackCertificate: &k8s.FullName{ + Name: "invalid", + Namespace: "invalid", + }, + objs: []interface{}{fallbackCertificate, fallbackSecret, sec1, s4}, + want: map[k8s.FullName]Status{ + {Name: fallbackCertificate.Name, Namespace: fallbackCertificate.Namespace}: {Object: fallbackCertificate, Status: "invalid", Description: "Spec.Virtualhost.TLS fallback certificate Secret \"invalid/invalid\" is invalid: Secret not found", Vhost: "example.com"}, + }, + }, + "fallback certificate requested but cert not configured in contour": { + objs: []interface{}{fallbackCertificate, fallbackSecret, sec1, s4}, + want: map[k8s.FullName]Status{ + {Name: fallbackCertificate.Name, Namespace: fallbackCertificate.Namespace}: {Object: fallbackCertificate, Status: "invalid", Description: "Spec.Virtualhost.TLS enabled fallback but the fallback Certificate Secret is not configured in Contour configuration file", Vhost: "example.com"}, + }, + }, + "fallback certificate requested and clientValidation also configured": { + objs: []interface{}{fallbackCertificateWithClientValidation, fallbackSecret, sec1, s4}, + want: map[k8s.FullName]Status{ + {Name: fallbackCertificateWithClientValidation.Name, Namespace: fallbackCertificateWithClientValidation.Namespace}: {Object: fallbackCertificateWithClientValidation, Status: "invalid", Description: "Spec.Virtualhost.TLS fallback & client validation are incompatible together", Vhost: "example.com"}, + }, + }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { builder := Builder{ + FallbackCertificate: tc.fallbackCertificate, Source: KubernetesCache{ RootNamespaces: []string{"roots", "marketing"}, FieldLogger: testLogger(t), diff --git a/internal/envoy/listener.go b/internal/envoy/listener.go index 73a5333ac6f..ab14490200a 100644 --- a/internal/envoy/listener.go +++ b/internal/envoy/listener.go @@ -281,7 +281,7 @@ func FilterChains(filters ...*envoy_api_v2_listener.Filter) []*envoy_api_v2_list } } -// FilterChainTLS returns a TLS enabled envoy_api_v2_listener.FilterChain, +// FilterChainTLS returns a TLS enabled envoy_api_v2_listener.FilterChain. func FilterChainTLS(domain string, downstream *envoy_api_v2_auth.DownstreamTlsContext, filters []*envoy_api_v2_listener.Filter) *envoy_api_v2_listener.FilterChain { fc := &envoy_api_v2_listener.FilterChain{ Filters: filters, @@ -297,7 +297,32 @@ func FilterChainTLS(domain string, downstream *envoy_api_v2_auth.DownstreamTlsCo return fc } +// FilterChainTLSFallback returns a TLS enabled envoy_api_v2_listener.FilterChain conifgured for FallbackCertificate. +func FilterChainTLSFallback(downstream *envoy_api_v2_auth.DownstreamTlsContext, filters []*envoy_api_v2_listener.Filter) *envoy_api_v2_listener.FilterChain { + fc := &envoy_api_v2_listener.FilterChain{ + Name: "fallback-certificate", + Filters: filters, + FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{ + TransportProtocol: "tls", + }, + } + // Attach TLS data to this listener if provided. + if downstream != nil { + fc.TransportSocket = DownstreamTLSTransportSocket(downstream) + } + return fc +} + // ListenerFilters returns a []*envoy_api_v2_listener.ListenerFilter for the supplied listener filters. func ListenerFilters(filters ...*envoy_api_v2_listener.ListenerFilter) []*envoy_api_v2_listener.ListenerFilter { return filters } + +func ContainsFallbackFilterChain(filterchains []*envoy_api_v2_listener.FilterChain) bool { + for _, fc := range filterchains { + if fc.Name == "fallback-certificate" { + return true + } + } + return false +} diff --git a/internal/featuretests/envoy.go b/internal/featuretests/envoy.go index 110a0fc26ff..77d6312d155 100644 --- a/internal/featuretests/envoy.go +++ b/internal/featuretests/envoy.go @@ -26,6 +26,7 @@ import ( envoy_config_v2_tcpproxy "github.com/envoyproxy/go-control-plane/envoy/config/filter/network/tcp_proxy/v2" "github.com/envoyproxy/go-control-plane/pkg/wellknown" "github.com/golang/protobuf/proto" + "github.com/projectcontour/contour/internal/contour" "github.com/projectcontour/contour/internal/dag" "github.com/projectcontour/contour/internal/envoy" "github.com/projectcontour/contour/internal/protobuf" @@ -251,3 +252,32 @@ func tcpproxy(statPrefix, cluster string) *envoy_api_v2_listener.Filter { func staticListener() *v2.Listener { return envoy.StatsListener("0.0.0.0", 8002) } + +func filterchaintlsfallback(domain string, domainSecret, fallbackSecret *v1.Secret, filter *envoy_api_v2_listener.Filter, peerValidationContext *dag.PeerValidationContext, alpn ...string) []*envoy_api_v2_listener.FilterChain { + return []*envoy_api_v2_listener.FilterChain{ + envoy.FilterChainTLS( + domain, + envoy.DownstreamTLSContext( + &dag.Secret{Object: domainSecret}, + envoy_api_v2_auth.TlsParameters_TLSv1_1, + peerValidationContext, + alpn...), + envoy.Filters(filter), + ), + envoy.FilterChainTLSFallback( + envoy.DownstreamTLSContext( + &dag.Secret{Object: fallbackSecret}, + envoy_api_v2_auth.TlsParameters_TLSv1_1, + peerValidationContext, + alpn...), + envoy.Filters( + envoy.HTTPConnectionManagerBuilder(). + RouteConfigName(contour.ENVOY_FALLBACK_ROUTECONFIG). + MetricsPrefix(contour.ENVOY_HTTPS_LISTENER). + AccessLoggers(envoy.FileAccessLogEnvoy("/dev/stdout")). + RequestTimeout(0). + Get(), + ), + ), + } +} diff --git a/internal/featuretests/fallbackcert_test.go b/internal/featuretests/fallbackcert_test.go new file mode 100644 index 00000000000..c4d2e9b0925 --- /dev/null +++ b/internal/featuretests/fallbackcert_test.go @@ -0,0 +1,253 @@ +// Copyright © 2020 VMware +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package featuretests + +import ( + "testing" + + projcontour "github.com/projectcontour/contour/apis/projectcontour/v1" + + v2 "github.com/envoyproxy/go-control-plane/envoy/api/v2" + "github.com/projectcontour/contour/internal/contour" + "github.com/projectcontour/contour/internal/envoy" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestFallbackCertificate(t *testing.T) { + rh, c, done := setupWithFallbackCert(t, "fallbacksecret", "admin") + defer done() + + sec1 := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + } + rh.OnAdd(sec1) + + fallbackSecret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fallbacksecret", + Namespace: "admin", + }, + Type: "kubernetes.io/tls", + Data: secretdata(CERTIFICATE, RSA_PRIVATE_KEY), + } + + rh.OnAdd(fallbackSecret) + + s1 := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backend", + Namespace: sec1.Namespace, + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Name: "http", + Protocol: "TCP", + Port: 80, + }}, + }, + } + rh.OnAdd(s1) + + // Valid HTTPProxy without FallbackCertificate enabled + proxy1 := &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple", + Namespace: s1.Namespace, + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "fallback.example.com", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: false, + }, + }, + Routes: []projcontour.Route{{ + Services: []projcontour.Service{{ + Name: s1.Name, + Port: 80, + }}, + }}, + }, + } + rh.OnAdd(proxy1) + + c.Request(listenerType, "ingress_https").Equals(&v2.DiscoveryResponse{ + Resources: resources(t, + &v2.Listener{ + Name: "ingress_https", + Address: envoy.SocketAddress("0.0.0.0", 8443), + ListenerFilters: envoy.ListenerFilters( + envoy.TLSInspector(), + ), + FilterChains: filterchaintls("fallback.example.com", sec1, + envoy.HTTPConnectionManagerBuilder(). + RouteConfigName("https/fallback.example.com"). + MetricsPrefix(contour.ENVOY_HTTPS_LISTENER). + AccessLoggers(envoy.FileAccessLogEnvoy("/dev/stdout")). + Get(), + nil, + "h2", "http/1.1"), + }, + ), + TypeUrl: listenerType, + }) + + // Valid HTTPProxy with FallbackCertificate enabled + proxy2 := &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple", + Namespace: s1.Namespace, + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "fallback.example.com", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: true, + }, + }, + Routes: []projcontour.Route{{ + Services: []projcontour.Service{{ + Name: s1.Name, + Port: 80, + }}, + }}, + }, + } + + rh.OnUpdate(proxy1, proxy2) + + c.Request(listenerType, "ingress_https").Equals(&v2.DiscoveryResponse{ + Resources: resources(t, + &v2.Listener{ + Name: "ingress_https", + Address: envoy.SocketAddress("0.0.0.0", 8443), + ListenerFilters: envoy.ListenerFilters( + envoy.TLSInspector(), + ), + FilterChains: filterchaintlsfallback("fallback.example.com", sec1, fallbackSecret, + envoy.HTTPConnectionManagerBuilder(). + RouteConfigName("https/fallback.example.com"). + MetricsPrefix(contour.ENVOY_HTTPS_LISTENER). + AccessLoggers(envoy.FileAccessLogEnvoy("/dev/stdout")). + Get(), + nil, + "h2", "http/1.1"), + }, + ), + TypeUrl: listenerType, + }) + + // InValid HTTPProxy with FallbackCertificate enabled along with ClientValidation + proxy3 := &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple", + Namespace: s1.Namespace, + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "fallback.example.com", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: true, + ClientValidation: &projcontour.DownstreamValidation{ + CACertificate: "something", + }, + }, + }, + Routes: []projcontour.Route{{ + Services: []projcontour.Service{{ + Name: s1.Name, + Port: 80, + }}, + }}, + }, + } + + rh.OnUpdate(proxy2, proxy3) + + c.Request(listenerType, "ingress_https").Equals(&v2.DiscoveryResponse{ + Resources: nil, + TypeUrl: listenerType, + }) + + // Valid HTTPProxy with FallbackCertificate enabled + proxy4 := &projcontour.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple-two", + Namespace: s1.Namespace, + }, + Spec: projcontour.HTTPProxySpec{ + VirtualHost: &projcontour.VirtualHost{ + Fqdn: "anotherfallback.example.com", + TLS: &projcontour.TLS{ + SecretName: "secret", + EnableFallbackCertificate: true, + }, + }, + Routes: []projcontour.Route{{ + Services: []projcontour.Service{{ + Name: s1.Name, + Port: 80, + }}, + }}, + }, + } + + rh.OnUpdate(proxy3, proxy2) // proxy3 is invalid, resolve that to test two valid proxies + rh.OnAdd(proxy4) + + c.Request(listenerType, "ingress_https").Equals(&v2.DiscoveryResponse{ + Resources: resources(t, + &v2.Listener{ + Name: "ingress_https", + Address: envoy.SocketAddress("0.0.0.0", 8443), + ListenerFilters: envoy.ListenerFilters( + envoy.TLSInspector(), + ), + FilterChains: append(filterchaintls("anotherfallback.example.com", sec1, + envoy.HTTPConnectionManagerBuilder(). + RouteConfigName("https/anotherfallback.example.com"). + MetricsPrefix(contour.ENVOY_HTTPS_LISTENER). + AccessLoggers(envoy.FileAccessLogEnvoy("/dev/stdout")). + Get(), + nil, + "h2", "http/1.1"), filterchaintlsfallback("fallback.example.com", sec1, fallbackSecret, + envoy.HTTPConnectionManagerBuilder(). + RouteConfigName("https/fallback.example.com"). + MetricsPrefix(contour.ENVOY_HTTPS_LISTENER). + AccessLoggers(envoy.FileAccessLogEnvoy("/dev/stdout")). + Get(), + nil, + "h2", "http/1.1")...), + }, + ), + TypeUrl: listenerType, + }) + + rh.OnDelete(fallbackSecret) + + c.Request(listenerType, "ingress_https").Equals(&v2.DiscoveryResponse{ + Resources: nil, + TypeUrl: listenerType, + }) +} diff --git a/internal/featuretests/featuretests.go b/internal/featuretests/featuretests.go index 3f4c2513e8b..8fcc1d2dc45 100644 --- a/internal/featuretests/featuretests.go +++ b/internal/featuretests/featuretests.go @@ -61,6 +61,10 @@ func (d *discardWriter) Write(buf []byte) (int, error) { } func setup(t *testing.T, opts ...func(*contour.EventHandler)) (cache.ResourceEventHandler, *Contour, func()) { + return setupWithFallbackCert(t, "", "", opts...) +} + +func setupWithFallbackCert(t *testing.T, fallbackCertName, fallbackCertNamespace string, opts ...func(*contour.EventHandler)) (cache.ResourceEventHandler, *Contour, func()) { t.Parallel() log := logrus.New() @@ -93,6 +97,10 @@ func setup(t *testing.T, opts ...func(*contour.EventHandler)) (cache.ResourceEve Source: dag.KubernetesCache{ FieldLogger: log, }, + FallbackCertificate: &k8s.FullName{ + Name: fallbackCertName, + Namespace: fallbackCertNamespace, + }, }, } diff --git a/internal/k8s/status.go b/internal/k8s/status.go index 3a50cb15d67..4ebdebceada 100644 --- a/internal/k8s/status.go +++ b/internal/k8s/status.go @@ -186,5 +186,6 @@ func (irs *StatusWriter) setHTTPProxyStatus(updated *projcontour.HTTPProxy) erro _, err = irs.Client.Resource(projcontour.HTTPProxyGVR).Namespace(updated.GetNamespace()). UpdateStatus(context.TODO(), usUpdated, metav1.UpdateOptions{}) + return err } diff --git a/internal/sorter/sorter.go b/internal/sorter/sorter.go index dd6ba6a0a94..ba6fe6192ef 100644 --- a/internal/sorter/sorter.go +++ b/internal/sorter/sorter.go @@ -176,6 +176,17 @@ type filterChainSorter []*envoy_api_v2_listener.FilterChain func (s filterChainSorter) Len() int { return len(s) } func (s filterChainSorter) Swap(i, j int) { s[i], s[j] = s[j], s[i] } func (s filterChainSorter) Less(i, j int) bool { + + // If i's ServerNames aren't defined, then it should not swap + if len(s[i].FilterChainMatch.ServerNames) == 0 { + return false + } + + // If j's ServerNames aren't defined, then it should not swap + if len(s[j].FilterChainMatch.ServerNames) == 0 { + return true + } + // The ServerNames field will only ever have a single entry // in our FilterChain config, so it's okay to only sort // on the first slice entry. diff --git a/internal/sorter/sorter_test.go b/internal/sorter/sorter_test.go index e7f7e742d5c..4fc1ed342d0 100644 --- a/internal/sorter/sorter_test.go +++ b/internal/sorter/sorter_test.go @@ -318,7 +318,7 @@ func TestSortFilterChains(t *testing.T) { } want := []*envoy_api_v2_listener.FilterChain{ - &envoy_api_v2_listener.FilterChain{ + { FilterChainMatch: names("first"), }, @@ -326,17 +326,20 @@ func TestSortFilterChains(t *testing.T) { // in "have" because we are doing a stable sort, and // they are equal since we only compare the first // server name. - &envoy_api_v2_listener.FilterChain{ + { FilterChainMatch: names("second", "zzzzz"), }, - - &envoy_api_v2_listener.FilterChain{ + { FilterChainMatch: names("second", "aaaaa"), }, + { + FilterChainMatch: &envoy_api_v2_listener.FilterChainMatch{}, + }, } have := []*envoy_api_v2_listener.FilterChain{ want[1], // zzzzz + want[3], // blank want[2], // aaaaa want[0], } diff --git a/site/docs/master/api-reference.html b/site/docs/master/api-reference.html index fbf7aee9c84..a5184ffed35 100644 --- a/site/docs/master/api-reference.html +++ b/site/docs/master/api-reference.html @@ -1618,6 +1618,19 @@

TLS + + +enableFallbackCertificate +
+ +bool + + + +

EnableFallbackCertificate defines if the vhost should allow a default certificate to +be applied which handles all requests which don’t match the SNI defined in this vhost.

+ +

TLSCertificateDelegationSpec