diff --git a/design/tls-certificate-delegation.md b/design/tls-certificate-delegation.md index cfb871d9a79..ba67d41a11f 100644 --- a/design/tls-certificate-delegation.md +++ b/design/tls-certificate-delegation.md @@ -33,7 +33,7 @@ The implementation of this design is in three parts; the addition of a TLSCertif ### TLSCertificateDelegation CRD -The TLSCertificateDelegation object records the permission to reference a Secret object from the namespace of the TLSCertificateDelegation object to Ingress or IngressRoute objects in the target namespaces. +The TLSCertificateDelegation object records the permission to reference a Secret object from the namespace of the TLSCertificateDelegation object to Ingress or IngressRoute objects in the target namespaces. This permission is managed by the Ingress controller which has the RBAC permissions to read all the relevant Secrets but currently only allows an Ingress or IngressRoute object to reference secrets from its own namespace. ``` diff --git a/docs/ingressroute.md b/docs/ingressroute.md index d19e0fed509..1b32129d75c 100644 --- a/docs/ingressroute.md +++ b/docs/ingressroute.md @@ -240,6 +240,9 @@ spec: port: 80 ``` +If the `tls.secretName` property contains a slash, eg. `somenamespace/somesecret` then, subject to TLS Certificate Delegation, the TLS certificate will be read from `somesecret` in `somenamespace`. +See TLS Certificate Delegation below for more information. + The TLS **Minimum Protocol Version** a vhost should negotiate can be specified by setting the `spec.virtualhost.tls.minimumProtocolVersion`: - 1.3 - 1.2 @@ -270,6 +273,42 @@ spec: permitInsecure: true ``` +#### 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. +This facility allows the owner of a TLS certificate to delegate, for the purposes of reference the TLS certificate, the when processing an IngressRoute to Contour will reference the Secret object from another namespace. + +```yaml +apiVersion: contour.heptio.com/v1beta1 +kind: TLSCertificateDelegation +metadata: + name: example-com-wildcard + namespace: www-admin +spec: + delegations: + secretName: example-com-wildcard + targetNamespaces: + - example-com +--- +apiVersion: contour.heptio.com/v1beta1 +kind: IngressRoute +metadata: + name: www + namespace: example-com +spec: + virtualhost: + fqdn: foo2.bar.com + tls: + secretName: www-admin/example-com-wildcard + routes: + - match: / + services: + - name: s1 + port: 80 +``` + +In this example, the permission for Contour to reference the Secret `example-com-wildcard` in the `admin` namespace has been delegated to IngressRoute objects in the `example-com` namespace. + ### Routing Each route entry in an IngressRoute must start with a prefix match. diff --git a/internal/dag/builder.go b/internal/dag/builder.go index f27c3d00913..5d189ee02f3 100644 --- a/internal/dag/builder.go +++ b/internal/dag/builder.go @@ -384,121 +384,12 @@ func (b *builder) compute() *DAG { // setup secure vhosts if there is a matching secret // we do this first so that the set of active secure vhosts is stable - // during the second ingress pass - for _, ing := range b.source.ingresses { - for _, tls := range ing.Spec.TLS { - m := meta{name: tls.SecretName, namespace: ing.Namespace} - if sec := b.lookupSecret(m); sec != nil { - for _, host := range tls.Hosts { - svhost := b.lookupSecureVirtualHost(host) - svhost.Secret = sec - svhost.MinProtoVersion = minProtoVersion(ing) - } - } - } - } - - // deconstruct each ingress into routes and virtualhost entries - for _, ing := range b.source.ingresses { - // rewrite the default ingress to a stock ingress rule. - rules := ing.Spec.Rules - if backend := ing.Spec.Backend; backend != nil { - rule := v1beta1.IngressRule{ - IngressRuleValue: v1beta1.IngressRuleValue{ - HTTP: &v1beta1.HTTPIngressRuleValue{ - Paths: []v1beta1.HTTPIngressPath{{ - Backend: v1beta1.IngressBackend{ - ServiceName: backend.ServiceName, - ServicePort: backend.ServicePort, - }, - }}, - }, - }, - } - rules = append(rules, rule) - } - - for _, rule := range rules { - host := rule.Host - if host == "" { - host = "*" - } - for _, httppath := range httppaths(rule) { - prefix := httppath.Path - if prefix == "" { - prefix = "/" - } - - r := prefixRoute(ing, prefix) - m := meta{name: httppath.Backend.ServiceName, namespace: ing.Namespace} - if s := b.lookupHTTPService(m, httppath.Backend.ServicePort, 0, "", nil); s != nil { - - r.addHTTPService(s) - } - - // should we create port 80 routes for this ingress - if httpAllowed(ing) { - b.lookupVirtualHost(host).addRoute(r) - } - if _, ok := b.listener(b.externalSecurePort()).VirtualHosts[host]; ok && host != "*" { - b.lookupSecureVirtualHost(host).addRoute(r) - } - } - } - } + // during computeIngresses. + b.computeSecureVirtualhosts() - // process ingressroute documents - for _, ir := range b.validIngressRoutes() { - if ir.Spec.VirtualHost == nil { - // mark delegate ingressroute orphaned. - b.setOrphaned(ir) - continue - } + b.computeIngresses() - // ensure root ingressroute lives in allowed namespace - if !b.rootAllowed(ir) { - b.setStatus(Status{Object: ir, Status: StatusInvalid, Description: "root IngressRoute cannot be defined in this namespace"}) - continue - } - - host := ir.Spec.VirtualHost.Fqdn - if isBlank(host) { - b.setStatus(Status{Object: ir, Status: StatusInvalid, Description: "Spec.VirtualHost.Fqdn must be specified"}) - continue - } - - var enforceTLS, passthrough bool - if tls := ir.Spec.VirtualHost.TLS; tls != nil { - // attach secrets to TLS enabled vhosts - m := meta{name: tls.SecretName, namespace: ir.Namespace} - if sec := b.lookupSecret(m); sec != nil { - svhost := b.lookupSecureVirtualHost(host) - svhost.Secret = sec - enforceTLS = true - - // process min protocol version - switch ir.Spec.VirtualHost.TLS.MinimumProtocolVersion { - case "1.3": - svhost.MinProtoVersion = auth.TlsParameters_TLSv1_3 - case "1.2": - svhost.MinProtoVersion = auth.TlsParameters_TLSv1_2 - default: - // any other value is interpreted as TLS/1.1 - svhost.MinProtoVersion = auth.TlsParameters_TLSv1_1 - } - } - // passthrough is true if tls.secretName is not present, and - // tls.passthrough is set to true. - passthrough = tls.SecretName == "" && tls.Passthrough - } - - switch { - case ir.Spec.TCPProxy != nil && (passthrough || enforceTLS): - b.processTCPProxy(ir, nil, host) - case ir.Spec.Routes != nil: - b.processRoutes(ir, "", nil, host, enforceTLS) - } - } + b.computeIngressRoutes() return b.DAG() } @@ -532,8 +423,8 @@ func isBlank(s string) bool { // minProtoVersion returns the TLS protocol version specified by an ingress annotation // or default if non present. -func minProtoVersion(i *v1beta1.Ingress) auth.TlsParameters_TlsProtocol { - switch i.Annotations["contour.heptio.com/tls-minimum-protocol-version"] { +func minProtoVersion(version string) auth.TlsParameters_TlsProtocol { + switch version { case "1.3": return auth.TlsParameters_TLSv1_3 case "1.2": @@ -579,6 +470,190 @@ func (b *builder) validIngressRoutes() []*ingressroutev1.IngressRoute { return valid } +// computeSecureVirtualhosts populates tls parameters of +// secure virtual hosts. +func (b *builder) computeSecureVirtualhosts() { + for _, ing := range b.source.ingresses { + for _, tls := range ing.Spec.TLS { + m := splitSecret(tls.SecretName, ing.Namespace) + if sec := b.lookupSecret(m); sec != nil && b.delegationPermitted(m, ing.Namespace) { + for _, host := range tls.Hosts { + svhost := b.lookupSecureVirtualHost(host) + svhost.Secret = sec + version := ing.Annotations["contour.heptio.com/tls-minimum-protocol-version"] + svhost.MinProtoVersion = minProtoVersion(version) + } + } + } + } +} + +// splitSecret splits a secretName into its namespace and name components. +// If there is no namespace prefix, the default namespace is returned. +func splitSecret(secret, defns string) meta { + v := strings.SplitN(secret, "/", 2) + switch len(v) { + case 1: + // no prefix + return meta{ + name: v[0], + namespace: defns, + } + default: + return meta{ + name: v[1], + namespace: stringOrDefault(v[0], defns), + } + } +} + +func (b *builder) delegationPermitted(secret meta, to string) bool { + contains := func(haystack []string, needle string) bool { + if len(haystack) == 1 && haystack[0] == "*" { + return true + } + for _, h := range haystack { + if h == needle { + return true + } + } + return false + } + + if secret.namespace == to { + // secret is in the same namespace as target + return true + } + for _, d := range b.source.delegations { + if d.Namespace != secret.namespace { + continue + } + for _, d := range d.Spec.Delegations { + if contains(d.TargetNamespaces, to) { + if secret.name == d.SecretName { + return true + } + } + } + } + return false +} + +func (b *builder) computeIngresses() { + // deconstruct each ingress into routes and virtualhost entries + for _, ing := range b.source.ingresses { + + // rewrite the default ingress to a stock ingress rule. + rules := rulesFromSpec(ing.Spec) + + for _, rule := range rules { + host := stringOrDefault(rule.Host, "*") + for _, httppath := range httppaths(rule) { + prefix := stringOrDefault(httppath.Path, "/") + r := prefixRoute(ing, prefix) + be := httppath.Backend + m := meta{name: be.ServiceName, namespace: ing.Namespace} + if s := b.lookupHTTPService(m, be.ServicePort, 0, "", nil); s != nil { + + r.addHTTPService(s) + } + + // should we create port 80 routes for this ingress + if httpAllowed(ing) { + b.lookupVirtualHost(host).addRoute(r) + } + + if b.secureVirtualhostExists(host) && host != "*" { + b.lookupSecureVirtualHost(host).addRoute(r) + } + } + } + } +} + +func stringOrDefault(s, def string) string { + if s == "" { + return def + } + return s +} + +func (b *builder) computeIngressRoutes() { + for _, ir := range b.validIngressRoutes() { + if ir.Spec.VirtualHost == nil { + // mark delegate ingressroute orphaned. + b.setOrphaned(ir) + continue + } + + // ensure root ingressroute lives in allowed namespace + if !b.rootAllowed(ir) { + b.setStatus(Status{Object: ir, Status: StatusInvalid, Description: "root IngressRoute cannot be defined in this namespace"}) + continue + } + + host := ir.Spec.VirtualHost.Fqdn + if isBlank(host) { + b.setStatus(Status{Object: ir, Status: StatusInvalid, Description: "Spec.VirtualHost.Fqdn must be specified"}) + continue + } + + var enforceTLS, passthrough bool + if tls := ir.Spec.VirtualHost.TLS; tls != nil { + // attach secrets to TLS enabled vhosts + m := splitSecret(tls.SecretName, ir.Namespace) + if sec := b.lookupSecret(m); sec != nil && b.delegationPermitted(m, ir.Namespace) { + svhost := b.lookupSecureVirtualHost(host) + svhost.Secret = sec + svhost.MinProtoVersion = minProtoVersion(ir.Spec.VirtualHost.TLS.MinimumProtocolVersion) + enforceTLS = true + } + // passthrough is true if tls.secretName is not present, and + // tls.passthrough is set to true. + passthrough = tls.SecretName == "" && tls.Passthrough + } + + switch { + case ir.Spec.TCPProxy != nil && (passthrough || enforceTLS): + b.processTCPProxy(ir, nil, host) + case ir.Spec.Routes != nil: + b.processRoutes(ir, "", nil, host, enforceTLS) + } + } +} + +func (b *builder) secureVirtualhostExists(host string) bool { + _, ok := b.listener(b.externalSecurePort()).VirtualHosts[host] + return ok +} + +// rulesFromSpec merges the IngressSpec's Rules with a synthetic +// rule representing the default backend. +func rulesFromSpec(spec v1beta1.IngressSpec) []v1beta1.IngressRule { + rules := spec.Rules + if backend := spec.Backend; backend != nil { + rule := defaultBackendRule(backend) + rules = append(rules, rule) + } + return rules +} + +// defaultBackendRule returns an IngressRule that represents the IngressBackend. +func defaultBackendRule(be *v1beta1.IngressBackend) v1beta1.IngressRule { + return v1beta1.IngressRule{ + IngressRuleValue: v1beta1.IngressRuleValue{ + HTTP: &v1beta1.HTTPIngressRuleValue{ + Paths: []v1beta1.HTTPIngressPath{{ + Backend: v1beta1.IngressBackend{ + ServiceName: be.ServiceName, + ServicePort: be.ServicePort, + }, + }}, + }, + }, + } +} + // DAG returns a *DAG representing the current state of this builder. func (b *builder) DAG() *DAG { var dag DAG diff --git a/internal/dag/builder_test.go b/internal/dag/builder_test.go index 6d5bfe6b7ee..063cf60fe4a 100644 --- a/internal/dag/builder_test.go +++ b/internal/dag/builder_test.go @@ -3930,6 +3930,58 @@ func TestEnforceRoute(t *testing.T) { } } +func TestSplitSecret(t *testing.T) { + tests := map[string]struct { + secret, defns string + want meta + }{ + "no namespace": { + secret: "secret", + defns: "default", + want: meta{ + name: "secret", + namespace: "default", + }, + }, + "with namespace": { + secret: "ns1/secret", + defns: "default", + want: meta{ + name: "secret", + namespace: "ns1", + }, + }, + "missing namespace": { + secret: "/secret", + defns: "default", + want: meta{ + name: "secret", + namespace: "default", + }, + }, + "missing secret name": { + secret: "secret/", + defns: "default", + want: meta{ + name: "", + namespace: "secret", + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := splitSecret(tc.secret, tc.defns) + opts := []cmp.Option{ + cmp.AllowUnexported(meta{}), + } + if diff := cmp.Diff(tc.want, got, opts...); diff != "" { + t.Fatal(diff) + } + }) + } +} + func routemap(routes ...*Route) map[string]*Route { m := make(map[string]*Route) for _, r := range routes { diff --git a/internal/e2e/lds_test.go b/internal/e2e/lds_test.go index aa4b756067c..bf837f74522 100644 --- a/internal/e2e/lds_test.go +++ b/internal/e2e/lds_test.go @@ -1159,6 +1159,187 @@ func TestLDSIngressRouteTCPForward(t *testing.T) { }, streamLDS(t, cc)) } +// Test that TLS Cerfiticate delegation works correctly. +func TestIngressRouteTLSCertificateDelegation(t *testing.T) { + rh, cc, done := setup(t) + defer done() + + // assert that there are no active listeners + assertEqual(t, &v2.DiscoveryResponse{ + VersionInfo: "0", + Resources: []types.Any{}, + TypeUrl: listenerType, + Nonce: "0", + }, streamLDS(t, cc)) + + // add a secret object secret/wildcard. + rh.OnAdd(&v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "wildcard", + Namespace: "secret", + }, + Data: map[string][]byte{ + v1.TLSCertKey: []byte("certificate"), + v1.TLSPrivateKeyKey: []byte("key"), + }, + }) + + // add an ingressroute in a different namespace mentioning secret/wildcard. + rh.OnAdd(&ingressroutev1.IngressRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "simple", + Namespace: "default", + }, + Spec: ingressroutev1.IngressRouteSpec{ + VirtualHost: &ingressroutev1.VirtualHost{ + Fqdn: "example.com", + TLS: &ingressroutev1.TLS{ + SecretName: "secret/wildcard", + }, + }, + Routes: []ingressroutev1.Route{{ + Match: "/", + Services: []ingressroutev1.Service{{ + Name: "kuard", + Port: 8080, + }}, + }}, + }, + }) + + ingress_http := &v2.Listener{ + Name: "ingress_http", + Address: *envoy.SocketAddress("0.0.0.0", 8080), + FilterChains: filterchain(envoy.HTTPConnectionManager("ingress_http", "/dev/stdout")), + } + + // assert there is no ingress_https because there is no matching secret. + assertEqual(t, &v2.DiscoveryResponse{ + VersionInfo: "0", + Resources: []types.Any{ + any(t, ingress_http), + }, + TypeUrl: listenerType, + Nonce: "0", + }, streamLDS(t, cc)) + + // t1 is a TLSCertificateDelegation that permits default to access secret/wildcard + t1 := &ingressroutev1.TLSCertificateDelegation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "delegation", + Namespace: "secret", + }, + Spec: ingressroutev1.TLSCertificateDelegationSpec{ + Delegations: []ingressroutev1.CertificateDelegation{{ + SecretName: "wildcard", + TargetNamespaces: []string{ + "default", + }, + }}, + }, + } + rh.OnAdd(t1) + + ingress_https := &v2.Listener{ + Name: "ingress_https", + Address: *envoy.SocketAddress("0.0.0.0", 8443), + ListenerFilters: []listener.ListenerFilter{ + envoy.TLSInspector(), + }, + FilterChains: filterchaintls("example.com", envoy.HTTPConnectionManager("ingress_https", "/dev/stdout"), "h2", "http/1.1"), + } + + assertEqual(t, &v2.DiscoveryResponse{ + VersionInfo: "0", + Resources: []types.Any{ + any(t, ingress_http), + any(t, ingress_https), + }, + TypeUrl: listenerType, + Nonce: "0", + }, streamLDS(t, cc)) + + // t2 is a TLSCertificateDelegation that permits access to secret/wildcard from all namespaces. + t2 := &ingressroutev1.TLSCertificateDelegation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "delegation", + Namespace: "secret", + }, + Spec: ingressroutev1.TLSCertificateDelegationSpec{ + Delegations: []ingressroutev1.CertificateDelegation{{ + SecretName: "wildcard", + TargetNamespaces: []string{ + "*", + }, + }}, + }, + } + rh.OnUpdate(t1, t2) + + assertEqual(t, &v2.DiscoveryResponse{ + VersionInfo: "0", + Resources: []types.Any{ + any(t, ingress_http), + any(t, ingress_https), + }, + TypeUrl: listenerType, + Nonce: "0", + }, streamLDS(t, cc)) + + // t3 is a TLSCertificateDelegation that permits access to secret/different all namespaces. + t3 := &ingressroutev1.TLSCertificateDelegation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "delegation", + Namespace: "secret", + }, + Spec: ingressroutev1.TLSCertificateDelegationSpec{ + Delegations: []ingressroutev1.CertificateDelegation{{ + SecretName: "different", + TargetNamespaces: []string{ + "*", + }, + }}, + }, + } + rh.OnUpdate(t2, t3) + + assertEqual(t, &v2.DiscoveryResponse{ + VersionInfo: "0", + Resources: []types.Any{ + any(t, ingress_http), + }, + TypeUrl: listenerType, + Nonce: "0", + }, streamLDS(t, cc)) + + // t4 is a TLSCertificateDelegation that permits access to secret/wildcard from the kube-secret namespace. + t4 := &ingressroutev1.TLSCertificateDelegation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "delegation", + Namespace: "secret", + }, + Spec: ingressroutev1.TLSCertificateDelegationSpec{ + Delegations: []ingressroutev1.CertificateDelegation{{ + SecretName: "wildcard", + TargetNamespaces: []string{ + "kube-secret", + }, + }}, + }, + } + rh.OnUpdate(t3, t4) + + assertEqual(t, &v2.DiscoveryResponse{ + VersionInfo: "0", + Resources: []types.Any{ + any(t, ingress_http), + }, + TypeUrl: listenerType, + Nonce: "0", + }, streamLDS(t, cc)) + +} + func streamLDS(t *testing.T, cc *grpc.ClientConn, rn ...string) *v2.DiscoveryResponse { t.Helper() rds := v2.NewListenerDiscoveryServiceClient(cc)