diff --git a/docs/serving-api.md b/docs/serving-api.md index 08fe4a2fb611..db83df6bfa04 100644 --- a/docs/serving-api.md +++ b/docs/serving-api.md @@ -2110,6 +2110,20 @@ Service.

Knative Routes, and by Kubernetes Services.

+ + +tls
+ + +SecretTLS + + + + +(Optional) +

TLS allows the DomainMapping to terminate TLS traffic with an existing secret.

+ + @@ -2200,6 +2214,20 @@ Service.

Knative Routes, and by Kubernetes Services.

+ + +tls
+ + +SecretTLS + + + + +(Optional) +

TLS allows the DomainMapping to terminate TLS traffic with an existing secret.

+ +

DomainMappingStatus @@ -2263,6 +2291,35 @@ knative.dev/pkg/apis/duck/v1.Addressable +

SecretTLS +

+

+(Appears on:DomainMappingSpec) +

+

+

SecretTLS wrapper for TLS SecretName.

+

+ + + + + + + + + + + + + +
FieldDescription
+secretName
+ +string + +
+

SecretName is the name of the existing secret used to terminate TLS traffic.

+

Generated with gen-crd-api-reference-docs diff --git a/pkg/apis/serving/v1alpha1/domainmapping_lifecycle.go b/pkg/apis/serving/v1alpha1/domainmapping_lifecycle.go index 4ab88d4396f2..ed189c3dd66a 100644 --- a/pkg/apis/serving/v1alpha1/domainmapping_lifecycle.go +++ b/pkg/apis/serving/v1alpha1/domainmapping_lifecycle.go @@ -63,6 +63,9 @@ const ( // DomainMappingConditionCertificateProvisioned condition when it is set to True // because AutoTLS was not enabled. AutoTLSNotEnabledMessage = "autoTLS is not enabled" + // TLSCertificateProvidedExternally indicates that a TLS secret won't be created or managed + // instead a reference to an existing TLS secret should have been provided in the DomainMapping spec + TLSCertificateProvidedExternally = "TLS certificate was provided externally" ) // MarkTLSNotEnabled sets DomainMappingConditionCertificateProvisioned to true when @@ -72,6 +75,11 @@ func (dms *DomainMappingStatus) MarkTLSNotEnabled(msg string) { "TLSNotEnabled", msg) } +func (dms *DomainMappingStatus) MarkCertificateNotRequired(msg string) { + domainMappingCondSet.Manage(dms).MarkTrueWithReason(DomainMappingConditionCertificateProvisioned, + "CertificateExternallyProvided", msg) +} + // MarkCertificateReady marks the DomainMappingConditionCertificateProvisioned // condition to indicate that the Certificate is ready. func (dms *DomainMappingStatus) MarkCertificateReady(name string) { diff --git a/pkg/apis/serving/v1alpha1/domainmapping_types.go b/pkg/apis/serving/v1alpha1/domainmapping_types.go index bcbbf9204961..10d30a6b017f 100644 --- a/pkg/apis/serving/v1alpha1/domainmapping_types.go +++ b/pkg/apis/serving/v1alpha1/domainmapping_types.go @@ -70,6 +70,12 @@ type DomainMappingList struct { Items []DomainMapping `json:"items"` } +// SecretTLS wrapper for TLS SecretName. +type SecretTLS struct { + // SecretName is the name of the existing secret used to terminate TLS traffic. + SecretName string `json:"secretName"` +} + // DomainMappingSpec describes the DomainMapping the user wishes to exist. type DomainMappingSpec struct { // Ref specifies the target of the Domain Mapping. @@ -82,6 +88,10 @@ type DomainMappingSpec struct { // This contract is satisfied by Knative types such as Knative Services and // Knative Routes, and by Kubernetes Services. Ref duckv1.KReference `json:"ref"` + + // TLS allows the DomainMapping to terminate TLS traffic with an existing secret. + // +optional + TLS *SecretTLS `json:"tls,omitempty"` } // DomainMappingStatus describes the current state of the DomainMapping. diff --git a/pkg/apis/serving/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/serving/v1alpha1/zz_generated.deepcopy.go index 843511cdf1e5..8565d4ef31f4 100644 --- a/pkg/apis/serving/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/serving/v1alpha1/zz_generated.deepcopy.go @@ -47,7 +47,7 @@ func (in *DomainMapping) DeepCopyInto(out *DomainMapping) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) return } @@ -107,6 +107,11 @@ func (in *DomainMappingList) DeepCopyObject() runtime.Object { func (in *DomainMappingSpec) DeepCopyInto(out *DomainMappingSpec) { *out = *in out.Ref = in.Ref + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(SecretTLS) + **out = **in + } return } @@ -146,3 +151,19 @@ func (in *DomainMappingStatus) DeepCopy() *DomainMappingStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretTLS) DeepCopyInto(out *SecretTLS) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretTLS. +func (in *SecretTLS) DeepCopy() *SecretTLS { + if in == nil { + return nil + } + out := new(SecretTLS) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/reconciler/domainmapping/reconciler.go b/pkg/reconciler/domainmapping/reconciler.go index f832195ab417..cc6fe1e22f9a 100644 --- a/pkg/reconciler/domainmapping/reconciler.go +++ b/pkg/reconciler/domainmapping/reconciler.go @@ -199,6 +199,16 @@ func certClass(ctx context.Context) string { } func (r *Reconciler) tls(ctx context.Context, dm *v1alpha1.DomainMapping) ([]netv1alpha1.IngressTLS, []netv1alpha1.HTTP01Challenge, error) { + if dm.Spec.TLS != nil { + dm.Status.MarkCertificateNotRequired(v1alpha1.TLSCertificateProvidedExternally) + dm.Status.URL.Scheme = "https" + return []netv1alpha1.IngressTLS{{ + Hosts: []string{dm.Name}, + SecretName: dm.Spec.TLS.SecretName, + SecretNamespace: dm.Namespace, + }}, nil, nil + } + if !autoTLSEnabled(ctx, dm) { dm.Status.MarkTLSNotEnabled(v1.AutoTLSNotEnabledMessage) return nil, nil, nil diff --git a/pkg/reconciler/domainmapping/table_test.go b/pkg/reconciler/domainmapping/table_test.go index 0d4c42d275f6..4ebd729017f9 100644 --- a/pkg/reconciler/domainmapping/table_test.go +++ b/pkg/reconciler/domainmapping/table_test.go @@ -1108,6 +1108,49 @@ func TestReconcileTLSEnabled(t *testing.T) { WantEvents: []string{ Eventf(corev1.EventTypeNormal, "FinalizerUpdate", "Updated %q finalizers", "challenged.com"), }, + }, { + Name: "TLS secret provided", + Key: "default/certificateless.com", + Objects: []runtime.Object{ + ksvc("default", "ready", "ready.default.svc.cluster.local", ""), + domainMapping("default", "certificateless.com", + withTLSSecret("tls-secret"), + withRef("default", "ready"), + withURL("https", "certificateless.com"), + withAddress("https", "certificateless.com"), + ), + resources.MakeDomainClaim(domainMapping("default", "certificateless.com", withRef("default", "ready"))), + }, + WantCreates: []runtime.Object{ + ingress(domainMapping("default", "certificateless.com", withRef("default", "ready")), "the-ingress-class", + withIngressHTTPOption(netv1alpha1.HTTPOptionRedirected), + withIngressTLS(netv1alpha1.IngressTLS{ + Hosts: []string{"certificateless.com"}, + SecretName: "tls-secret", + SecretNamespace: "default", + })), + }, + WantStatusUpdates: []clientgotesting.UpdateActionImpl{{ + Object: domainMapping("default", "certificateless.com", + withRef("default", "ready"), + withURL("https", "certificateless.com"), + withAddress("https", "certificateless.com"), + withTLSSecret("tls-secret"), + withInitDomainMappingConditions, + withCertificateReady, + withDomainClaimed, + withReferenceResolved, + withCertificateNotRequired, + withIngressNotConfigured, + ), + }}, + WantPatches: []clientgotesting.PatchActionImpl{ + patchAddFinalizerAction("default", "certificateless.com"), + }, + WantEvents: []string{ + Eventf(corev1.EventTypeNormal, "FinalizerUpdate", "Updated %q finalizers", "certificateless.com"), + Eventf(corev1.EventTypeNormal, "Created", "Created Ingress %q", "certificateless.com"), + }, }} table.Test(t, MakeFactory(func(ctx context.Context, listers *Listers, cmw configmap.Watcher) controller.Reconciler { @@ -1276,6 +1319,18 @@ func withPropagatedStatus(status netv1alpha1.IngressStatus) domainMappingOption } } +func withTLSSecret(secretName string) domainMappingOption { + return func(r *v1alpha1.DomainMapping) { + r.Spec.TLS = &v1alpha1.SecretTLS{ + SecretName: secretName, + } + } +} + +func withCertificateNotRequired(dm *v1alpha1.DomainMapping) { + dm.Status.MarkCertificateNotRequired(v1alpha1.TLSCertificateProvidedExternally) +} + func withInitDomainMappingConditions(dm *v1alpha1.DomainMapping) { dm.Status.InitializeConditions() } diff --git a/test/e2e/domain_mapping_test.go b/test/e2e/domain_mapping_test.go new file mode 100644 index 000000000000..2a2b74ce0cdd --- /dev/null +++ b/test/e2e/domain_mapping_test.go @@ -0,0 +1,209 @@ +// +build e2e + +/* +Copyright 2021 The Knative Authors + +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 e2e + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http" + "net/url" + "testing" + "time" + + "knative.dev/pkg/test/spoof" + + corev1 "k8s.io/api/core/v1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + duckv1 "knative.dev/pkg/apis/duck/v1" + pkgTest "knative.dev/pkg/test" + "knative.dev/serving/pkg/apis/serving/v1alpha1" + "knative.dev/serving/test" + v1test "knative.dev/serving/test/v1" +) + +func TestBYOCertificate(t *testing.T) { + if !test.ServingFlags.EnableAlphaFeatures { + t.Skip("Alpha features not enabled") + } + t.Parallel() + + clients := test.Setup(t) + + names := test.ResourceNames{ + Service: test.ObjectNameForTest(t), + Image: test.PizzaPlanet1, + } + + test.EnsureTearDown(t, clients, &names) + + ctx := context.Background() + + ksvc, err := v1test.CreateServiceReady(t, clients, &names) + if err != nil { + t.Fatalf("Failed to create initial Service %v: %v", names.Service, err) + } + + host := ksvc.Service.Name + ".example.org" + + resolvableCustomDomain := false + + if test.ServingFlags.CustomDomain != "" { + host = ksvc.Service.Name + "." + test.ServingFlags.CustomDomain + resolvableCustomDomain = true + } + + cert, key := makeCertificateForDomain(t, host) + secret, err := clients.KubeClient.CoreV1().Secrets(test.ServingNamespace).Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: test.AppendRandomString("byocert-secret"), + }, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: cert, + corev1.TLSPrivateKeyKey: key, + }, + }, metav1.CreateOptions{}) + + if err != nil { + t.Fatalf("Secret creation could not be completed: %v", err) + } + + t.Cleanup(func() { + err = clients.KubeClient.CoreV1().Secrets(secret.Namespace).Delete(ctx, secret.Name, metav1.DeleteOptions{}) + if err != nil { + t.Fatalf("Secret %s/%s could not be deleted: %v", secret.Namespace, secret.Name, err) + } + }) + + dm := v1alpha1.DomainMapping{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: host, + Namespace: ksvc.Service.Namespace, + }, + Spec: v1alpha1.DomainMappingSpec{ + Ref: duckv1.KReference{ + APIVersion: "serving.knative.dev/v1", + Name: ksvc.Service.Name, + Namespace: ksvc.Service.Namespace, + Kind: "Service", + }, + TLS: &v1alpha1.SecretTLS{ + SecretName: secret.Name, + }}, + Status: v1alpha1.DomainMappingStatus{}, + } + + _, err = clients.ServingAlphaClient.DomainMappings.Create(ctx, &dm, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Problem creating DomainMapping %q: %v", host, err) + } + t.Cleanup(func() { + clients.ServingAlphaClient.DomainMappings.Delete(ctx, dm.Name, metav1.DeleteOptions{}) + }) + + err = wait.PollImmediate(test.PollInterval, test.PollTimeout, func() (bool, error) { + dm, err := clients.ServingAlphaClient.DomainMappings.Get(ctx, dm.Name, metav1.GetOptions{}) + if err != nil { + return false, err + } + ready := dm.Status.IsReady() && dm.Status.URL != nil && dm.Status.URL.Scheme == "https" + return ready, nil + }) + if err != nil { + t.Fatalf("Polling for DomainMapping state produced an error: %v", err) + } + + pemData := test.PemDataFromSecret(ctx, t.Logf, clients, secret.Namespace, secret.Name) + rootCAs := x509.NewCertPool() + if !rootCAs.AppendCertsFromPEM(pemData) { + t.Fatalf("Failed to add the certificate to the root CA") + } + + var trustSelfSigned spoof.TransportOption = func(transport *http.Transport) *http.Transport { + transport.TLSClientConfig = &tls.Config{ + RootCAs: rootCAs, + } + return transport + } + + _, err = pkgTest.WaitForEndpointState(ctx, + clients.KubeClient, + t.Logf, + &url.URL{Scheme: "HTTPS", Host: host}, + pkgTest.EventuallyMatchesBody(test.PizzaPlanetText1), + "DomainMappingBYOSSLCert", + resolvableCustomDomain, + trustSelfSigned, + ) + if err != nil { + t.Fatalf("Service response unavailable: %v", err) + } +} + +func makeCertificateForDomain(t *testing.T, domainName string) (cert []byte, key []byte) { + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + t.Fatalf("Failed to generate serial number: %v", err) + } + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("Failed to generate private key: %v", err) + } + public := &priv.PublicKey + now := time.Now() + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Acme Co"}, + }, + NotBefore: now, + NotAfter: now.Add(time.Hour * 12), + + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: []string{domainName}, + } + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, public, priv) + if err != nil { + t.Fatalf("Failed to create certificate: %v", err) + } + var certOut bytes.Buffer + pem.Encode(&certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + + privBytes, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + t.Fatalf("Failed to marshal private key: %v", err) + } + var keyOut bytes.Buffer + pem.Encode(&keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}) + return certOut.Bytes(), keyOut.Bytes() +} diff --git a/test/e2e_flags.go b/test/e2e_flags.go index 83975c9d6113..a804840ce71c 100644 --- a/test/e2e_flags.go +++ b/test/e2e_flags.go @@ -32,7 +32,7 @@ var ServingFlags = initializeServingFlags() // ServingEnvironmentFlags holds the e2e flags needed only by the serving repo. type ServingEnvironmentFlags struct { ResolvableDomain bool // Resolve Route controller's `domainSuffix` - CustomDomain string // Indicaates the `domainSuffix` for custom domain test. + CustomDomain string // Indicates the `domainSuffix` for custom domain test. HTTPS bool // Indicates where the test service will be created with https Buckets int // The number of reconciler buckets configured. Replicas int // The number of controlplane replicas being run.