diff --git a/cmd/root.go b/cmd/root.go index cd194475..e0b2aa40 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -35,6 +35,8 @@ func init() { fs.String("service-name", "", "NamespacedName of the Contour LoadBalancer Service") fs.String("default-issuer-name", "", "Issuer name used by default") fs.String("default-issuer-kind", controllers.ClusterIssuerKind, "Issuer kind used by default") + fs.String("default-delegated-domain", "", "Delegated domain used by default") + fs.Bool("allow-custom-delegations", false, "Allow custom delegated domains via annotations") fs.Uint("csr-revision-limit", 0, "Maximum number of CertificateRequest revisions to keep") fs.String("ingress-class-name", "", "Ingress class name that watched by Contour Plus. If not specified, then all classes are watched") fs.Bool("leader-election", true, "Enable/disable leader election") @@ -60,15 +62,17 @@ var rootCmd = &cobra.Command{ In addition to flags, the following environment variables are read: - CP_METRICS_ADDR Bind address for the metrics endpoint - CP_CRDS Comma-separated list of CRD names - CP_NAME_PREFIX Prefix of CRD names to be created - CP_SERVICE_NAME NamespacedName of the Contour LoadBalancer Service - CP_DEFAULT_ISSUER_NAME Issuer name used by default - CP_DEFAULT_ISSUER_KIND Issuer kind used by default - CP_CSR_REVISION_LIMIT Maximum number of CertificateRequest revisions to keep - CP_LEADER_ELECTION Disable leader election if set to "false" - CP_INGRESS_CLASS_NAME Ingress class name that watched by Contour Plus. If not specified, then all classes are watched`, + CP_METRICS_ADDR Bind address for the metrics endpoint + CP_CRDS Comma-separated list of CRD names + CP_NAME_PREFIX Prefix of CRD names to be created + CP_SERVICE_NAME NamespacedName of the Contour LoadBalancer Service + CP_DEFAULT_ISSUER_NAME Issuer name used by default + CP_DEFAULT_ISSUER_KIND Issuer kind used by default + CP_DEFAULT_DELEGATED_DOMAIN Delegation domain used by default + CP_ALLOW_CUSTOM_DELEGATIONS Allow custom delegated domains via annotations + CP_CSR_REVISION_LIMIT Maximum number of CertificateRequest revisions to keep + CP_LEADER_ELECTION Disable leader election if set to "false" + CP_INGRESS_CLASS_NAME Ingress class name that watched by Contour Plus. If not specified, then all classes are watched`, RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true return run() diff --git a/controllers/httpproxy_controller.go b/controllers/httpproxy_controller.go index 1211ca42..9f97c130 100644 --- a/controllers/httpproxy_controller.go +++ b/controllers/httpproxy_controller.go @@ -3,6 +3,7 @@ package controllers import ( "context" "net" + "strings" "github.com/go-logr/logr" projectcontourv1 "github.com/projectcontour/contour/apis/projectcontour/v1" @@ -26,22 +27,25 @@ const ( clusterIssuerNameAnnotation = "cert-manager.io/cluster-issuer" ingressClassNameAnnotation = "kubernetes.io/ingress.class" contourIngressClassNameAnnotation = "projectcontour.io/ingress.class" + delegatedDomainAnnotation = "contour-plus.cybozu.com/delegated-domain" ) // HTTPProxyReconciler reconciles a HTTPProxy object type HTTPProxyReconciler struct { client.Client - Log logr.Logger - Scheme *runtime.Scheme - ServiceKey client.ObjectKey - IssuerKey client.ObjectKey - Prefix string - DefaultIssuerName string - DefaultIssuerKind string - CSRRevisionLimit uint - CreateDNSEndpoint bool - CreateCertificate bool - IngressClassName string + Log logr.Logger + Scheme *runtime.Scheme + ServiceKey client.ObjectKey + IssuerKey client.ObjectKey + Prefix string + DefaultIssuerName string + DefaultIssuerKind string + DefaultDelegatedDomain string + AllowCustomDelegations bool + CSRRevisionLimit uint + CreateDNSEndpoint bool + CreateCertificate bool + IngressClassName string } // +kubebuilder:rbac:groups=projectcontour.io,resources=httpproxies,verbs=get;list;watch @@ -89,6 +93,11 @@ func (r *HTTPProxyReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, err } + if err := r.reconcileDelegationDNSEndpoint(ctx, hp, log); err != nil { + log.Error(err, "unable to reconcile delegation DNSEndpoint") + return ctrl.Result{}, err + } + if err := r.reconcileCertificate(ctx, hp, log); err != nil { log.Error(err, "unable to reconcile Certificate") return ctrl.Result{}, err @@ -182,6 +191,51 @@ func (r *HTTPProxyReconciler) reconcileDNSEndpoint(ctx context.Context, hp *proj return nil } +func (r *HTTPProxyReconciler) reconcileDelegationDNSEndpoint(ctx context.Context, hp *projectcontourv1.HTTPProxy, log logr.Logger) error { + if !r.CreateDNSEndpoint { + return nil + } + + delegatedDomain := r.DefaultDelegatedDomain + if hp.Annotations[delegatedDomainAnnotation] != "" && r.AllowCustomDelegations { + delegatedDomain = hp.Annotations[delegatedDomainAnnotation] + } + + if delegatedDomain == "" { + return nil + } + + if hp.Spec.VirtualHost == nil { + return nil + } + fqdn := hp.Spec.VirtualHost.Fqdn + if len(fqdn) == 0 { + return nil + } + + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(externalDNSGroupVersion.WithKind(DNSEndpointKind)) + obj.SetName(r.Prefix + hp.Name + "-delegation") + obj.SetNamespace(hp.Namespace) + obj.UnstructuredContent()["spec"] = map[string]interface{}{ + "endpoints": makeDelegationEndpoint(fqdn, delegatedDomain), + } + + if err := ctrl.SetControllerReference(hp, obj, r.Scheme); err != nil { + return err + } + + if err := r.Patch(ctx, obj, client.Apply, &client.PatchOptions{ + Force: ptr.To(true), + FieldManager: "contour-plus", + }); err != nil { + return err + } + + log.Info("Delegation DNSEndpoint successfully reconciled") + return nil +} + func (r *HTTPProxyReconciler) reconcileCertificate(ctx context.Context, hp *projectcontourv1.HTTPProxy, log logr.Logger) error { if !r.CreateCertificate { return nil @@ -336,3 +390,15 @@ func ipsToTargets(ips []net.IP) ([]string, []string) { } return ipv4Targets, ipv6Targets } + +func makeDelegationEndpoint(hostname, delegatedDomain string) []map[string]interface{} { + fqdn := strings.Trim(hostname, ".") + return []map[string]interface{}{ + { + "dnsName": "_acme-challenge." + fqdn, + "targets": []string{"_acme-challenge." + fqdn + "." + delegatedDomain}, + "recordType": "CNAME", + "recordTTL": 3600, + }, + } +} diff --git a/controllers/httpproxy_controller_test.go b/controllers/httpproxy_controller_test.go index 9d0d8f80..0e50d303 100644 --- a/controllers/httpproxy_controller_test.go +++ b/controllers/httpproxy_controller_test.go @@ -17,8 +17,9 @@ import ( ) const ( - dnsName = "test.example.com" - testSecretName = "test-secret" + dnsName = "test.example.com" + testSecretName = "test-secret" + testDelegationName = "acme.example.com" ) func certificate() *unstructured.Unstructured { @@ -86,6 +87,16 @@ func testHTTPProxyReconcile() { Expect(endPoint["targets"]).Should(Equal([]interface{}{"10.0.0.0"})) Expect(endPoint["dnsName"]).Should(Equal(dnsName)) + By("ensuring additional DNSEndpoint does not exist") + dde := dnsEndpoint() + dObjKey := client.ObjectKey{ + Name: prefix + hpKey.Name + "-delegation", + Namespace: hpKey.Namespace, + } + Consistently(func() error { + return k8sClient.Get(context.Background(), dObjKey, dde) + }, 5*time.Second).ShouldNot(Succeed()) + By("getting Certificate with prefixed name") crt := certificate() Eventually(func() error { @@ -143,6 +154,187 @@ func testHTTPProxyReconcile() { Expect(crtList.Items).Should(BeEmpty()) }) + It("should create delegation DNSEndpoint if requested", func() { + ns := testNamespacePrefix + randomString(10) + Expect(k8sClient.Create(context.Background(), &corev1.Namespace{ + ObjectMeta: ctrl.ObjectMeta{Name: ns}, + })).ShouldNot(HaveOccurred()) + + scm, mgr := setupManager() + + prefix := "test-" + Expect(SetupReconciler(mgr, scm, ReconcilerOptions{ + ServiceKey: testServiceKey, + Prefix: prefix, + DefaultIssuerName: "test-issuer", + DefaultIssuerKind: IssuerKind, + DefaultDelegatedDomain: testDelegationName, + CreateDNSEndpoint: true, + CreateCertificate: true, + })).ShouldNot(HaveOccurred()) + + stopMgr := startTestManager(mgr) + defer stopMgr() + + By("creating HTTPProxy") + hpKey := client.ObjectKey{Name: "foo", Namespace: ns} + Expect(k8sClient.Create(context.Background(), newDummyHTTPProxy(hpKey))).ShouldNot(HaveOccurred()) + + By("getting DNSEndpoint with prefixed name") + de := dnsEndpoint() + objKey := client.ObjectKey{ + Name: prefix + hpKey.Name, + Namespace: hpKey.Namespace, + } + Eventually(func() error { + return k8sClient.Get(context.Background(), objKey, de) + }, 5*time.Second).Should(Succeed()) + deSpec := de.UnstructuredContent()["spec"].(map[string]interface{}) + endPoints := deSpec["endpoints"].([]interface{}) + endPoint := endPoints[0].(map[string]interface{}) + Expect(endPoint["targets"]).Should(Equal([]interface{}{"10.0.0.0"})) + Expect(endPoint["dnsName"]).Should(Equal(dnsName)) + + By("ensuring additional DNSEndpoint has been created") + dde := dnsEndpoint() + dObjKey := client.ObjectKey{ + Name: prefix + hpKey.Name + "-delegation", + Namespace: hpKey.Namespace, + } + Eventually(func() error { + return k8sClient.Get(context.Background(), dObjKey, dde) + }, 5*time.Second).Should(Succeed()) + ddeSpec := dde.UnstructuredContent()["spec"].(map[string]interface{}) + dEndPoints := ddeSpec["endpoints"].([]interface{}) + dEndPoint := dEndPoints[0].(map[string]interface{}) + Expect(dEndPoint["targets"]).Should(Equal([]interface{}{"_acme-challenge." + dnsName + "." + testDelegationName})) + Expect(dEndPoint["dnsName"]).Should(Equal("_acme-challenge." + dnsName)) + Expect(dEndPoint["recordType"]).Should(Equal("CNAME")) + }) + + It("should create delegation DNSEndpoint if requested via annotation", func() { + ns := testNamespacePrefix + randomString(10) + Expect(k8sClient.Create(context.Background(), &corev1.Namespace{ + ObjectMeta: ctrl.ObjectMeta{Name: ns}, + })).ShouldNot(HaveOccurred()) + + scm, mgr := setupManager() + + prefix := "test-" + customDelegationName := "test." + testDelegationName + Expect(SetupReconciler(mgr, scm, ReconcilerOptions{ + ServiceKey: testServiceKey, + Prefix: prefix, + DefaultIssuerName: "test-issuer", + DefaultIssuerKind: IssuerKind, + DefaultDelegatedDomain: testDelegationName, + AllowCustomDelegations: true, + CreateDNSEndpoint: true, + CreateCertificate: true, + })).ShouldNot(HaveOccurred()) + + stopMgr := startTestManager(mgr) + defer stopMgr() + + By("creating HTTPProxy") + hpKey := client.ObjectKey{Name: "foo", Namespace: ns} + hp := newDummyHTTPProxy(hpKey) + hp.Annotations[delegatedDomainAnnotation] = customDelegationName + Expect(k8sClient.Create(context.Background(), hp)).ShouldNot(HaveOccurred()) + + By("getting DNSEndpoint with prefixed name") + de := dnsEndpoint() + objKey := client.ObjectKey{ + Name: prefix + hpKey.Name, + Namespace: hpKey.Namespace, + } + Eventually(func() error { + return k8sClient.Get(context.Background(), objKey, de) + }, 5*time.Second).Should(Succeed()) + deSpec := de.UnstructuredContent()["spec"].(map[string]interface{}) + endPoints := deSpec["endpoints"].([]interface{}) + endPoint := endPoints[0].(map[string]interface{}) + Expect(endPoint["targets"]).Should(Equal([]interface{}{"10.0.0.0"})) + Expect(endPoint["dnsName"]).Should(Equal(dnsName)) + + By("ensuring additional DNSEndpoint has been created") + dde := dnsEndpoint() + dObjKey := client.ObjectKey{ + Name: prefix + hpKey.Name + "-delegation", + Namespace: hpKey.Namespace, + } + Eventually(func() error { + return k8sClient.Get(context.Background(), dObjKey, dde) + }, 5*time.Second).Should(Succeed()) + ddeSpec := dde.UnstructuredContent()["spec"].(map[string]interface{}) + dEndPoints := ddeSpec["endpoints"].([]interface{}) + dEndPoint := dEndPoints[0].(map[string]interface{}) + Expect(dEndPoint["targets"]).Should(Equal([]interface{}{"_acme-challenge." + dnsName + "." + customDelegationName})) + Expect(dEndPoint["dnsName"]).Should(Equal("_acme-challenge." + dnsName)) + Expect(dEndPoint["recordType"]).Should(Equal("CNAME")) + }) + + It("should ignore custom delegated domain if not permitted", func() { + ns := testNamespacePrefix + randomString(10) + Expect(k8sClient.Create(context.Background(), &corev1.Namespace{ + ObjectMeta: ctrl.ObjectMeta{Name: ns}, + })).ShouldNot(HaveOccurred()) + + scm, mgr := setupManager() + + prefix := "test-" + customDelegationName := "test." + testDelegationName + Expect(SetupReconciler(mgr, scm, ReconcilerOptions{ + ServiceKey: testServiceKey, + Prefix: prefix, + DefaultIssuerName: "test-issuer", + DefaultIssuerKind: IssuerKind, + DefaultDelegatedDomain: testDelegationName, + CreateDNSEndpoint: true, + CreateCertificate: true, + })).ShouldNot(HaveOccurred()) + + stopMgr := startTestManager(mgr) + defer stopMgr() + + By("creating HTTPProxy") + hpKey := client.ObjectKey{Name: "foo", Namespace: ns} + hp := newDummyHTTPProxy(hpKey) + hp.Annotations[delegatedDomainAnnotation] = customDelegationName + Expect(k8sClient.Create(context.Background(), hp)).ShouldNot(HaveOccurred()) + + By("getting DNSEndpoint with prefixed name") + de := dnsEndpoint() + objKey := client.ObjectKey{ + Name: prefix + hpKey.Name, + Namespace: hpKey.Namespace, + } + Eventually(func() error { + return k8sClient.Get(context.Background(), objKey, de) + }, 5*time.Second).Should(Succeed()) + deSpec := de.UnstructuredContent()["spec"].(map[string]interface{}) + endPoints := deSpec["endpoints"].([]interface{}) + endPoint := endPoints[0].(map[string]interface{}) + Expect(endPoint["targets"]).Should(Equal([]interface{}{"10.0.0.0"})) + Expect(endPoint["dnsName"]).Should(Equal(dnsName)) + + By("ensuring additional DNSEndpoint has been created") + dde := dnsEndpoint() + dObjKey := client.ObjectKey{ + Name: prefix + hpKey.Name + "-delegation", + Namespace: hpKey.Namespace, + } + Eventually(func() error { + return k8sClient.Get(context.Background(), dObjKey, dde) + }, 5*time.Second).Should(Succeed()) + ddeSpec := dde.UnstructuredContent()["spec"].(map[string]interface{}) + dEndPoints := ddeSpec["endpoints"].([]interface{}) + dEndPoint := dEndPoints[0].(map[string]interface{}) + Expect(dEndPoint["targets"]).Should(Equal([]interface{}{"_acme-challenge." + dnsName + "." + testDelegationName})) + Expect(dEndPoint["dnsName"]).Should(Equal("_acme-challenge." + dnsName)) + Expect(dEndPoint["recordType"]).Should(Equal("CNAME")) + }) + It("should create Certificate with specified IssuerKind", func() { ns := testNamespacePrefix + randomString(10) Expect(k8sClient.Create(context.Background(), &corev1.Namespace{ @@ -776,3 +968,47 @@ func TestIsClassNameMatched(t *testing.T) { }) } } + +func TestMakeDelegationEndpoint(t *testing.T) { + tests := []struct { + name string + hostname string + delegatedDomain string + expectDNSName string + expectTarget string + }{ + { + name: "Hostname without trailing dot", + hostname: "example.com", + delegatedDomain: "delegated.com", + expectDNSName: "_acme-challenge.example.com", + expectTarget: "_acme-challenge.example.com.delegated.com", + }, + { + name: "Fully-qualified domain name", + hostname: "example.com.", + delegatedDomain: "delegated.com", + expectDNSName: "_acme-challenge.example.com", + expectTarget: "_acme-challenge.example.com.delegated.com", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + actuals := makeDelegationEndpoint(tc.hostname, tc.delegatedDomain) + if len(actuals) != 1 { + t.Errorf("HTTPProxyReconciler.makeDelegationEndpoint() = %v, want 1 item", len(actuals)) + } + actual := actuals[0] + if actual["dnsName"] != tc.expectDNSName { + t.Errorf("HTTPProxyReconciler.makeDelegationEndpoint() dnsName = %v, want %v", actual["dnsName"], tc.expectDNSName) + } + actualTargets := actual["targets"].([]string) + if len(actualTargets) != 1 { + t.Errorf("HTTPProxyReconciler.makeDelegationEndpoint() targets = %v, want 1 item", len(actualTargets)) + } + if actualTargets[0] != tc.expectTarget { + t.Errorf("HTTPProxyReconciler.makeDelegationEndpoint() target = %v, want %v", actualTargets[0], tc.expectTarget) + } + }) + } +} diff --git a/controllers/setup.go b/controllers/setup.go index d249841d..6f0b54a7 100644 --- a/controllers/setup.go +++ b/controllers/setup.go @@ -13,14 +13,16 @@ import ( // ReconcilerOptions is a set of options for reconcilers type ReconcilerOptions struct { - ServiceKey client.ObjectKey - Prefix string - DefaultIssuerName string - DefaultIssuerKind string - CSRRevisionLimit uint - CreateDNSEndpoint bool - CreateCertificate bool - IngressClassName string + ServiceKey client.ObjectKey + Prefix string + DefaultIssuerName string + DefaultIssuerKind string + DefaultDelegatedDomain string + AllowCustomDelegations bool + CSRRevisionLimit uint + CreateDNSEndpoint bool + CreateCertificate bool + IngressClassName string } // SetupScheme initializes a schema @@ -34,17 +36,19 @@ func SetupScheme(scm *runtime.Scheme) { // SetupReconciler initializes reconcilers func SetupReconciler(mgr manager.Manager, scheme *runtime.Scheme, opts ReconcilerOptions) error { httpProxyReconciler := &HTTPProxyReconciler{ - Client: mgr.GetClient(), - Log: ctrl.Log.WithName("controllers").WithName("HTTPProxy"), - Scheme: scheme, - ServiceKey: opts.ServiceKey, - Prefix: opts.Prefix, - DefaultIssuerName: opts.DefaultIssuerName, - DefaultIssuerKind: opts.DefaultIssuerKind, - CSRRevisionLimit: opts.CSRRevisionLimit, - CreateDNSEndpoint: opts.CreateDNSEndpoint, - CreateCertificate: opts.CreateCertificate, - IngressClassName: opts.IngressClassName, + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controllers").WithName("HTTPProxy"), + Scheme: scheme, + ServiceKey: opts.ServiceKey, + Prefix: opts.Prefix, + DefaultIssuerName: opts.DefaultIssuerName, + DefaultIssuerKind: opts.DefaultIssuerKind, + DefaultDelegatedDomain: opts.DefaultDelegatedDomain, + AllowCustomDelegations: opts.AllowCustomDelegations, + CSRRevisionLimit: opts.CSRRevisionLimit, + CreateDNSEndpoint: opts.CreateDNSEndpoint, + CreateCertificate: opts.CreateCertificate, + IngressClassName: opts.IngressClassName, } err := httpProxyReconciler.SetupWithManager(mgr) if err != nil { diff --git a/docs/usage.md b/docs/usage.md index f4675725..b64470b2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -19,12 +19,16 @@ If both is specified, command-line flags take precedence. | `service-name` | `CP_SERVICE_NAME` | "" | NamespacedName of the Contour LoadBalancer Service | | `default-issuer-name` | `CP_DEFAULT_ISSUER_NAME` | "" | Issuer name used by default | | `default-issuer-kind` | `CP_DEFAULT_ISSUER_KIND` | `ClusterIssuer` | Issuer kind used by default | +| `default-delegated-domain` | `CP_DEFAULT_DELEGATED_DOMAIN` | "" | Domain to which DNS-01 validation is delegated to | +| `allow-custom-delegations` | `CP_ALLOW_CUSTOM_DELEGATIONS` | `false` | Allow users to specify a custom delegated domain | | `leader-election` | `CP_LEADER_ELECTION` | `true` | Enable / disable leader election | | `ingress-class-name` | `CP_INGRESS_CLASS_NAME` | "" | Ingress class name that watched by Contour Plus. If not specified, then all classes are watched | By default, contour-plus creates [DNSEndpoint][] when `spec.virtualhost.fqdn` of an HTTPProxy is not empty, and creates [Certificate][] when `spec.virtualhost.tls.secretName` is not empty and not namespaced. +When a delegated domain is specified, either via `default-delegated-domain` or the `contour-plus.cybozu.com/delegated-domain` annotation, contour-plus creates an additional [DNSEndpoint][] delegating DNS-01 validation to the given delegation domain. The delegation record will not be created if the DNSEndpoint for `spec.virtualhost.fqdn` cannot be created. + To disable CRD creation, specify `crds` command-line flag or `CP_CRDS` environment variable. `service-name` is a required flag/envvar that must be the namespaced name of Service for Contour. @@ -122,6 +126,7 @@ contour-plus interprets following annotations for HTTPProxy. - `cert-manager.io/issuer` - The name of an [Issuer][] to acquire the certificate required for this HTTPProxy from. The Issuer must be in the same namespace as the HTTPProxy. - `cert-manager.io/cluster-issuer` - The name of a [ClusterIssuer][Issuer] to acquire the certificate required for this ingress from. It does not matter which namespace your Ingress resides, as ClusterIssuers are non-namespaced resources. - `kubernetes.io/tls-acme: "true"` - With this, contour-plus generates Certificate automatically from HTTPProxy. +- `contour-plus.cybozu.com/delegated-domain: "acme.example.com"` - With this, contour-plus generates a [DNSEndpoint][] to create a CNAME record pointing to the delegation domain for use when performing DNS-01 DCV during the Certificate creation. If both of `cert-manager.io/issuer` and `cert-manager.io/cluster-issuer` exist, `cluster-issuer` takes precedence.