Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement upstream tls backend verification with ca cert and required san #1045

Merged
merged 1 commit into from
May 9, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apis/contour/v1beta1/ingressroute.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ type Service struct {
HealthCheck *HealthCheck `json:"healthCheck,omitempty"`
// LB Algorithm to apply (see https://github.com/heptio/contour/blob/master/design/ingressroute-design.md#load-balancing)
Strategy string `json:"strategy,omitempty"`
// UpstreamValidation defines how to verify the backend service's certificate
UpstreamValidation *UpstreamValidation `json:"validation,omitempty"`
}

// Delegate allows for delegating VHosts to other IngressRoutes
Expand Down Expand Up @@ -141,6 +143,14 @@ type RetryPolicy struct {
PerTryTimeout string `json:"perTryTimeout,omitempty"`
}

// UpstreamValidation defines how to verify the backend service's certificate
type UpstreamValidation struct {
// Name of the Kubernetes secret be used to validate the certificate presented by the backend
CACertificate string `json:"caSecret"`
// Key which is expected to be present in the 'subjectAltName' of the presented certificate
SubjectName string `json:"subjectName"`
}

// Status reports the current state of the IngressRoute
type Status struct {
CurrentStatus string `json:"currentStatus"`
Expand Down
2 changes: 1 addition & 1 deletion apis/contour/v1beta1/tlscertificatedelegation.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ type CertificateDelegation struct {
// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

// TLSCertificateDelefgation is an TLS Certificate Delegation CRD specificiation.
// TLSCertificateDelegation is an TLS Certificate Delegation CRD specificiation.
// See design/tls-certificate-delegation.md for details.
type TLSCertificateDelegation struct {
metav1.TypeMeta `json:",inline"`
Expand Down
21 changes: 21 additions & 0 deletions apis/contour/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions docs/ingressroute.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,47 @@ spec:
permitInsecure: true
```

#### Upstream TLS

An IngressRoute route can proxy to an upstream TLS connection by first annotating the upstream Kubernetes service with: `contour.heptio.com/upstream-protocol.tls: "443,https"`.
This annoation tells Contour which port should be used for the TLS connection.
In this example, the upstream service is named `https` and uses port `443`.
Additionally, it is possible for Envoy to verify the backend service's certificate.
The service of an `IngressRoute` can optionally specify a `validation` struct which has a manditory `caSecret` key as well as an manditory `subjectName`.

Note: If spec.routes.services[].validation is present, spec.routes.services[].{name,port} must point to a service with a matching contour.heptio.com/upstream-protocol.tls Service annotation.

##### Sample YAML

```yaml
apiVersion: contour.heptio.com/v1beta1
kind: IngressRoute
metadata:
name: secure-backend
spec:
virtualhost:
fqdn: www.example.com
routes:
- match: /
services:
- name: service
port: 8443
validation:
caSecret: my-certificate-authority
subjectName: backend.example.com
```

##### Error conditions

If the `validation` spec is defined on a service, but the secret which it references does not exist, Contour will rejct the update and set the status of the `IngressRoute` object accordingly.
This is to help prevent the case of proxying to an upstream where validation is requested, but not yet available.

```yaml
Status:
Current Status: invalid
Description: route "/": service "tls-nginx": upstreamValidation requested but secret not found or misconfigured
```

#### TLS Certificate Delegation

In order to support wildcard certificates, TLS certificates for a `*.somedomain.com`, which are stored in a namespace controlled by the cluster administrator, Contour supports a facility known as TLS Certificate Delegation.
Expand Down
9 changes: 8 additions & 1 deletion internal/dag/annotations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ func TestParseUpstreamProtocols(t *testing.T) {
"80": "h2",
},
},
"tls": {
a: map[string]string{fmt.Sprintf("%s.%s", annotationUpstreamProtocol, "tls"): "https,80"},
want: map[string]string{
"80": "tls",
"https": "tls",
},
},
"multiple value": {
a: map[string]string{fmt.Sprintf("%s.%s", annotationUpstreamProtocol, "h2"): "80,http,443,https"},
want: map[string]string{
Expand All @@ -102,7 +109,7 @@ func TestParseUpstreamProtocols(t *testing.T) {

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got := parseUpstreamProtocols(tc.a, annotationUpstreamProtocol, "h2")
got := parseUpstreamProtocols(tc.a, annotationUpstreamProtocol, "h2", "tls")
if !reflect.DeepEqual(tc.want, got) {
t.Fatalf("parseUpstreamProtocols(%q): want: %v, got: %v", tc.a, tc.want, got)
}
Expand Down
29 changes: 26 additions & 3 deletions internal/dag/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (
"strconv"
"strings"

v1 "k8s.io/api/core/v1"
"k8s.io/api/core/v1"
"k8s.io/api/extensions/v1beta1"
"k8s.io/apimachinery/pkg/util/intstr"

Expand Down Expand Up @@ -468,7 +468,7 @@ func (b *builder) computeIngresses() {
be := httppath.Backend
m := meta{name: be.ServiceName, namespace: ing.Namespace}
if s := b.lookupHTTPService(m, be.ServicePort, "", nil); s != nil {
r.addHTTPService(s, 0)
r.addHTTPService(s, 0, nil)
}

// should we create port 80 routes for this ingress
Expand Down Expand Up @@ -668,7 +668,20 @@ func (b *builder) processRoutes(ir *ingressroutev1.IngressRoute, prefixMatch str
}
m := meta{name: service.Name, namespace: ir.Namespace}
if s := b.lookupHTTPService(m, intstr.FromInt(service.Port), service.Strategy, service.HealthCheck); s != nil {
r.addHTTPService(s, service.Weight)
uv := b.lookupUpstreamValidation(service.UpstreamValidation, ir.Namespace)
if service.UpstreamValidation != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If service.UpstreamValidation is nil then uv will be nil, so the check should probably be

if service.UpstreamValidation != nil && uv == nil {
// upstream validation requested, some components were missing

if uv.CACertificate == nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but you do need to do this check because uv can be nil if lookupUpstreamValidation returned nil because there was a problem

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

theproblem is probably that lookupUPstreamValidation cannot be factored out of this call, we probably need to construct the uv inline while checking each value and setting status.

// UpstreamValidation is requested, but cert is missing or not configured
b.setStatus(Status{Object: ir, Status: StatusInvalid, Description: fmt.Sprintf("route %q: service %q: upstreamValidation requested but secret not found or misconfigured", route.Match, service.Name), Vhost: host})
return
}
if len(uv.SubjectName) == 0 {
// UpstreamValidation is requested, but SAN is not provided
b.setStatus(Status{Object: ir, Status: StatusInvalid, Description: fmt.Sprintf("route %q: service %q: upstreamValidation requested but subject alt name not found or misconfigured", route.Match, service.Name), Vhost: host})
return
}
}
r.addHTTPService(s, service.Weight, uv)
}
}

Expand Down Expand Up @@ -714,6 +727,16 @@ func (b *builder) processRoutes(ir *ingressroutev1.IngressRoute, prefixMatch str
b.setStatus(Status{Object: ir, Status: StatusValid, Description: "valid IngressRoute", Vhost: host})
}

func (b *builder) lookupUpstreamValidation(uv *ingressroutev1.UpstreamValidation, namespace string) *UpstreamValidation {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could do with some unit tests. Its annoying to test as written because of all the parameters but I have an idea to really DRY up the testing of lookup helpers without the horror show of buidler_test.go

I'll leave this as a TODO

if uv == nil {
return nil
}
return &UpstreamValidation{
CACertificate: b.lookupSecret(meta{name: uv.CACertificate, namespace: namespace}),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the secret is not found then we should return nil here,

also if the subjectname is blank we should return nil here.

SubjectName: uv.SubjectName,
}
}

func (b *builder) processTCPProxy(ir *ingressroutev1.IngressRoute, visited []*ingressroutev1.IngressRoute, host string) {
visited = append(visited, ir)

Expand Down
23 changes: 20 additions & 3 deletions internal/dag/dag.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ type Route struct {

// Indicates that during forwarding, the matched prefix (or path) should be swapped with this value
PrefixRewrite string

// UpstreamValidation defines how to verify the backend service's certificate
UpstreamValidation *UpstreamValidation
}

// TimeoutPolicy defines the timeout request/idle
Expand All @@ -93,10 +96,21 @@ type RetryPolicy struct {
PerTryTimeout time.Duration
}

func (r *Route) addHTTPService(s *HTTPService, weight int) {
// UpstreamValidation defines how to validate the certificate on the upstream service
type UpstreamValidation struct {
// CACertificate holds a reference to the Secret containing the CA to be used to
// verify the upstream connection.
CACertificate *Secret
// SubjectName holds an optional subject name which Envoy will check against the
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/optional/required

// certificate presented by the upstream.
SubjectName string
}

func (r *Route) addHTTPService(s *HTTPService, weight int, uv *UpstreamValidation) {
r.Clusters = append(r.Clusters, &Cluster{
Upstream: s,
Weight: weight,
Upstream: s,
Weight: weight,
UpstreamValidation: uv,
})
}

Expand Down Expand Up @@ -262,6 +276,9 @@ type Cluster struct {

// The relative weight of this Cluster compared to its siblings.
Weight int

// UpstreamValidation defines how to verify the backend service's certificate
UpstreamValidation *UpstreamValidation
}

func (c Cluster) Visit(f func(Vertex)) {
Expand Down
117 changes: 114 additions & 3 deletions internal/e2e/cds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -801,7 +801,7 @@ func TestClusterServiceTLSBackend(t *testing.T) {
}
rh.OnAdd(s1)

want := tlscluster("default/kuard/443/da39a3ee5e", "default/kuard/securebackend", "default_kuard_443")
want := tlscluster("default/kuard/443/da39a3ee5e", "default/kuard/securebackend", "default_kuard_443", nil, nil)

assertEqual(t, &v2.DiscoveryResponse{
VersionInfo: "2",
Expand All @@ -813,6 +813,117 @@ func TestClusterServiceTLSBackend(t *testing.T) {
}, streamCDS(t, cc))
}

// Test that contour correctly recognizes the "contour.heptio.com/upstream-protocol.tls"
// service annotation.
func TestClusterServiceTLSBackendCAValidation(t *testing.T) {
rh, cc, done := setup(t)
defer done()

rh.OnAdd(&v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "kuard",
Namespace: "default",
Annotations: map[string]string{
"contour.heptio.com/upstream-protocol.tls": "securebackend,443",
},
},
Spec: v1.ServiceSpec{
Ports: []v1.ServicePort{{
Name: "securebackend",
Protocol: "TCP",
Port: 443,
TargetPort: intstr.FromInt(8080),
}},
},
})

secret := &v1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
Namespace: "default",
},
Data: secretdata(envoy.CACertificateKey, "key"),
}

rh.OnAdd(secret)

ir1 := &ingressroutev1.IngressRoute{
ObjectMeta: metav1.ObjectMeta{
Name: "simple",
Namespace: "default",
},
Spec: ingressroutev1.IngressRouteSpec{
VirtualHost: &ingressroutev1.VirtualHost{Fqdn: "www.example.com"},
Routes: []ingressroutev1.Route{{
Match: "/a",
Services: []ingressroutev1.Service{{
Name: "kuard",
Port: 443,
}},
}},
},
}

rh.OnAdd(ir1)

assertEqual(t, &v2.DiscoveryResponse{
VersionInfo: "3",
Resources: []types.Any{
any(t, tlscluster(
"default/kuard/443/da39a3ee5e",
"default/kuard/securebackend",
"default_kuard_443",
nil,
nil)),
},
TypeUrl: clusterType,
Nonce: "3",
}, streamCDS(t, cc))

ir2 := &ingressroutev1.IngressRoute{
ObjectMeta: metav1.ObjectMeta{
Name: "simple",
Namespace: "default",
},
Spec: ingressroutev1.IngressRouteSpec{
VirtualHost: &ingressroutev1.VirtualHost{Fqdn: "www.example.com"},
Routes: []ingressroutev1.Route{{
Match: "/a",
Services: []ingressroutev1.Service{{
Name: "kuard",
Port: 443,
UpstreamValidation: &ingressroutev1.UpstreamValidation{
CACertificate: "foo",
SubjectName: "subjname",
},
}},
}},
},
}

rh.OnUpdate(ir1, ir2)

assertEqual(t, &v2.DiscoveryResponse{
VersionInfo: "4",
Resources: []types.Any{
any(t, tlscluster(
"default/kuard/443/98c0f31c72",
"default/kuard/securebackend",
"default_kuard_443",
[]byte("key"),
[]string{"subjname"})),
},
TypeUrl: clusterType,
Nonce: "4",
}, streamCDS(t, cc))
}

func secretdata(key, cert string) map[string][]byte {
return map[string][]byte{
key: []byte(cert),
}
}

func serviceWithAnnotations(ns, name string, annotations map[string]string, ports ...v1.ServicePort) *v1.Service {
return &v1.Service{
ObjectMeta: metav1.ObjectMeta{
Expand Down Expand Up @@ -852,9 +963,9 @@ func cluster(name, servicename, statName string) *v2.Cluster {
}
}

func tlscluster(name, servicename, statsName string) *v2.Cluster {
func tlscluster(name, servicename, statsName string, cert []byte, subjalt []string) *v2.Cluster {
c := cluster(name, servicename, statsName)
c.TlsContext = envoy.UpstreamTLSContext()
c.TlsContext = envoy.UpstreamTLSContext(cert, subjalt)
return c
}

Expand Down
Loading