diff --git a/PROJECT b/PROJECT index d45a60a..e7fe98b 100644 --- a/PROJECT +++ b/PROJECT @@ -30,4 +30,13 @@ resources: kind: ClusterTunnel path: github.com/adyanth/cloudflare-operator/api/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: cfargotunnel.com + group: networking + kind: TunnelBinding + path: github.com/adyanth/cloudflare-operator/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/v1alpha1/tunnelbinding_types.go b/api/v1alpha1/tunnelbinding_types.go new file mode 100644 index 0000000..ad632dd --- /dev/null +++ b/api/v1alpha1/tunnelbinding_types.go @@ -0,0 +1,112 @@ +/* +Copyright 2022. + +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 v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TunnelBindingSubject defines the subject TunnelBinding connects to the Tunnel +type TunnelBindingSubject struct { + // Kind can be Service + //+kubebuilder:validation:Required + //+kubebuilder:default:="Service" + Kind string `json:"kind"` + //+kubebuilder:validation:Required + Name string `json:"name"` + Spec TunnelBindingSubjectSpec `json:"spec"` +} + +type TunnelBindingSubjectSpec struct { + // Fqdn specifies the DNS name to access this service from. + // Defaults to the service.metadata.name + tunnel.spec.domain. + // If specifying this, make sure to use the same domain that the tunnel belongs to. + // This is not validated and used as provided + //+kubebuilder:validation:Optional + Fqdn string `json:"fqdn,omitempty"` + + // Protocol specifies the protocol for the service. Should be one of http, https, tcp, udp, ssh or rdp. + // Defaults to http, with the exceptions of https for 443, smb for 139 and 445, rdp for 3389 and ssh for 22 if the service has a TCP port. + // The only available option for a UDP port is udp, which is default. + //+kubebuilder:validation:Optional + Protocol string `json:"protocol,omitempty"` + + // Target specified where the tunnel should proxy to. + // Defaults to the form of ://..svc: + //+kubebuilder:validation:Optional + Target string `json:"target,omitempty"` + + // CaPool trusts the CA certificate referenced by the key in the secret specified in tunnel.spec.originCaPool. + // tls.crt is trusted globally and does not need to be specified. Only useful if the protocol is HTTPS. + //+kubebuilder:validation:Optional + CaPool string `json:"caPool,omitempty"` + + // NoTlsVerify sisables TLS verification for this service. + // Only useful if the protocol is HTTPS. + //+kubebuilder:validation:Optional + //+kubebuilder:default:="false" + NoTlsVerify bool `json:"noTlsVerify"` +} + +// TunnelRef defines the Tunnel TunnelBinding connects to +type TunnelRef struct { + // Kind can be Tunnel or ClusterTunnel + //+kubebuilder:validation:Required + Kind string `json:"kind"` + // Name of the tunnel resource + //+kubebuilder:validation:Required + Name string `json:"name"` +} + +// ServiceInfo stores the Hostname and Target for each service +type ServiceInfo struct { + // FQDN of the service + Hostname string `json:"hostname"` + // Target for cloudflared + Target string `json:"target"` +} + +// TunnelBindingStatus defines the observed state of TunnelBinding +type TunnelBindingStatus struct { + Services []ServiceInfo `json:"services"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// TunnelBinding is the Schema for the tunnelbindings API +type TunnelBinding struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Subjects []TunnelBindingSubject `json:"subjects"` + TunnelRef TunnelRef `json:"tunnelRef"` + Status TunnelBindingStatus `json:"status"` +} + +//+kubebuilder:object:root=true + +// TunnelBindingList contains a list of TunnelBinding +type TunnelBindingList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []TunnelBinding `json:"items"` +} + +func init() { + SchemeBuilder.Register(&TunnelBinding{}, &TunnelBindingList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 31d69be..153967f 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -129,6 +129,21 @@ func (in *NewTunnel) DeepCopy() *NewTunnel { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceInfo) DeepCopyInto(out *ServiceInfo) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceInfo. +func (in *ServiceInfo) DeepCopy() *ServiceInfo { + if in == nil { + return nil + } + out := new(ServiceInfo) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Tunnel) DeepCopyInto(out *Tunnel) { *out = *in @@ -156,6 +171,121 @@ func (in *Tunnel) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TunnelBinding) DeepCopyInto(out *TunnelBinding) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.Subjects != nil { + in, out := &in.Subjects, &out.Subjects + *out = make([]TunnelBindingSubject, len(*in)) + copy(*out, *in) + } + out.TunnelRef = in.TunnelRef + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TunnelBinding. +func (in *TunnelBinding) DeepCopy() *TunnelBinding { + if in == nil { + return nil + } + out := new(TunnelBinding) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TunnelBinding) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TunnelBindingList) DeepCopyInto(out *TunnelBindingList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]TunnelBinding, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TunnelBindingList. +func (in *TunnelBindingList) DeepCopy() *TunnelBindingList { + if in == nil { + return nil + } + out := new(TunnelBindingList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TunnelBindingList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TunnelBindingStatus) DeepCopyInto(out *TunnelBindingStatus) { + *out = *in + if in.Services != nil { + in, out := &in.Services, &out.Services + *out = make([]ServiceInfo, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TunnelBindingStatus. +func (in *TunnelBindingStatus) DeepCopy() *TunnelBindingStatus { + if in == nil { + return nil + } + out := new(TunnelBindingStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TunnelBindingSubject) DeepCopyInto(out *TunnelBindingSubject) { + *out = *in + out.Spec = in.Spec +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TunnelBindingSubject. +func (in *TunnelBindingSubject) DeepCopy() *TunnelBindingSubject { + if in == nil { + return nil + } + out := new(TunnelBindingSubject) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TunnelBindingSubjectSpec) DeepCopyInto(out *TunnelBindingSubjectSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TunnelBindingSubjectSpec. +func (in *TunnelBindingSubjectSpec) DeepCopy() *TunnelBindingSubjectSpec { + if in == nil { + return nil + } + out := new(TunnelBindingSubjectSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TunnelList) DeepCopyInto(out *TunnelList) { *out = *in @@ -188,6 +318,21 @@ func (in *TunnelList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TunnelRef) DeepCopyInto(out *TunnelRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TunnelRef. +func (in *TunnelRef) DeepCopy() *TunnelRef { + if in == nil { + return nil + } + out := new(TunnelRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TunnelSpec) DeepCopyInto(out *TunnelSpec) { *out = *in diff --git a/bundle/manifests/cloudflare-operator.clusterserviceversion.yaml b/bundle/manifests/cloudflare-operator.clusterserviceversion.yaml index 76615d3..d192d55 100644 --- a/bundle/manifests/cloudflare-operator.clusterserviceversion.yaml +++ b/bundle/manifests/cloudflare-operator.clusterserviceversion.yaml @@ -44,10 +44,25 @@ metadata: }, "size": 2 } + }, + { + "apiVersion": "networking.cfargotunnel.com/v1alpha1", + "kind": "TunnelBinding", + "metadata": { + "labels": { + "app.kubernetes.io/created-by": "cloudflare-operator", + "app.kubernetes.io/instance": "tunnelbinding-sample", + "app.kubernetes.io/managed-by": "kustomize", + "app.kubernetes.io/name": "tunnelbinding", + "app.kubernetes.io/part-of": "cloudflare-operator" + }, + "name": "tunnelbinding-sample" + }, + "spec": null } ] capabilities: Basic Install - operators.operatorframework.io/builder: operator-sdk-v1.20.1 + operators.operatorframework.io/builder: operator-sdk-v1.25.1 operators.operatorframework.io/project_layout: go.kubebuilder.io/v3 name: cloudflare-operator.v0.8.2 namespace: placeholder @@ -60,6 +75,9 @@ spec: kind: ClusterTunnel name: clustertunnels.networking.cfargotunnel.com version: v1alpha1 + - kind: TunnelBinding + name: tunnelbindings.networking.cfargotunnel.com + version: v1alpha1 - description: Tunnel is the Schema for the tunnels API displayName: Tunnel kind: Tunnel @@ -160,6 +178,32 @@ spec: - get - patch - update + - apiGroups: + - networking.cfargotunnel.com + resources: + - tunnelbindings + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - networking.cfargotunnel.com + resources: + - tunnelbindings/finalizers + verbs: + - update + - apiGroups: + - networking.cfargotunnel.com + resources: + - tunnelbindings/status + verbs: + - get + - patch + - update - apiGroups: - networking.cfargotunnel.com resources: diff --git a/bundle/manifests/networking.cfargotunnel.com_tunnelbindings.yaml b/bundle/manifests/networking.cfargotunnel.com_tunnelbindings.yaml new file mode 100644 index 0000000..8c85aea --- /dev/null +++ b/bundle/manifests/networking.cfargotunnel.com_tunnelbindings.yaml @@ -0,0 +1,111 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.7.0 + creationTimestamp: null + name: tunnelbindings.networking.cfargotunnel.com +spec: + group: networking.cfargotunnel.com + names: + kind: TunnelBinding + listKind: TunnelBindingList + plural: tunnelbindings + singular: tunnelbinding + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: TunnelBinding is the Schema for the tunnelbindings API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + status: + description: TunnelBindingStatus defines the observed state of TunnelBinding + properties: + services: + items: + description: ServiceInfo stores the Hostname and Target for each + service + properties: + hostname: + type: string + target: + type: string + required: + - hostname + - target + type: object + type: array + required: + - services + type: object + subjects: + items: + description: TunnelBindingSubject defines the subject TunnelBinding + connects to the Tunnel + properties: + kind: + default: Service + description: Kind can be Service + type: string + name: + type: string + spec: + properties: + caPool: + type: string + fqdn: + type: string + noTlsVerify: + default: "false" + type: boolean + protocol: + type: string + target: + type: string + type: object + required: + - kind + - name + - spec + type: object + type: array + tunnelRef: + description: TunnelRef defines the Tunnel TunnelBinding connects to + properties: + kind: + description: Kind can be Tunnel or ClusterTunnel + type: string + name: + type: string + required: + - kind + - name + type: object + required: + - status + - subjects + - tunnelRef + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/config/crd/bases/networking.cfargotunnel.com_clustertunnels.yaml b/config/crd/bases/networking.cfargotunnel.com_clustertunnels.yaml index 52b2602..d89c3a4 100644 --- a/config/crd/bases/networking.cfargotunnel.com_clustertunnels.yaml +++ b/config/crd/bases/networking.cfargotunnel.com_clustertunnels.yaml @@ -139,9 +139,6 @@ spec: accountId: type: string tunnelId: - description: 'INSERT ADDITIONAL STATUS FIELD - define observed state - of cluster Important: Run "make" to regenerate code after modifying - this file' type: string tunnelName: type: string diff --git a/config/crd/bases/networking.cfargotunnel.com_tunnelbindings.yaml b/config/crd/bases/networking.cfargotunnel.com_tunnelbindings.yaml new file mode 100644 index 0000000..8e34e63 --- /dev/null +++ b/config/crd/bases/networking.cfargotunnel.com_tunnelbindings.yaml @@ -0,0 +1,134 @@ + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.7.0 + creationTimestamp: null + name: tunnelbindings.networking.cfargotunnel.com +spec: + group: networking.cfargotunnel.com + names: + kind: TunnelBinding + listKind: TunnelBindingList + plural: tunnelbindings + singular: tunnelbinding + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: TunnelBinding is the Schema for the tunnelbindings API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + status: + description: TunnelBindingStatus defines the observed state of TunnelBinding + properties: + services: + items: + description: ServiceInfo stores the Hostname and Target for each + service + properties: + hostname: + description: FQDN of the service + type: string + target: + description: Target for cloudflared + type: string + required: + - hostname + - target + type: object + type: array + required: + - services + type: object + subjects: + items: + description: TunnelBindingSubject defines the subject TunnelBinding + connects to the Tunnel + properties: + kind: + default: Service + description: Kind can be Service + type: string + name: + type: string + spec: + properties: + caPool: + description: CaPool trusts the CA certificate referenced by + the key in the secret specified in tunnel.spec.originCaPool. + tls.crt is trusted globally and does not need to be specified. + Only useful if the protocol is HTTPS. + type: string + fqdn: + description: Fqdn specifies the DNS name to access this service + from. Defaults to the service.metadata.name + tunnel.spec.domain. + If specifying this, make sure to use the same domain that + the tunnel belongs to. This is not validated and used as provided + type: string + noTlsVerify: + default: "false" + description: NoTlsVerify sisables TLS verification for this + service. Only useful if the protocol is HTTPS. + type: boolean + protocol: + description: Protocol specifies the protocol for the service. + Should be one of http, https, tcp, udp, ssh or rdp. Defaults + to http, with the exceptions of https for 443, smb for 139 + and 445, rdp for 3389 and ssh for 22 if the service has a + TCP port. The only available option for a UDP port is udp, + which is default. + type: string + target: + description: Target specified where the tunnel should proxy + to. Defaults to the form of ://..svc: + type: string + type: object + required: + - kind + - name + - spec + type: object + type: array + tunnelRef: + description: TunnelRef defines the Tunnel TunnelBinding connects to + properties: + kind: + description: Kind can be Tunnel or ClusterTunnel + type: string + name: + description: Name of the tunnel resource + type: string + required: + - kind + - name + type: object + required: + - status + - subjects + - tunnelRef + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/config/crd/bases/networking.cfargotunnel.com_tunnels.yaml b/config/crd/bases/networking.cfargotunnel.com_tunnels.yaml index e9efefb..609caa1 100644 --- a/config/crd/bases/networking.cfargotunnel.com_tunnels.yaml +++ b/config/crd/bases/networking.cfargotunnel.com_tunnels.yaml @@ -139,9 +139,6 @@ spec: accountId: type: string tunnelId: - description: 'INSERT ADDITIONAL STATUS FIELD - define observed state - of cluster Important: Run "make" to regenerate code after modifying - this file' type: string tunnelName: type: string diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 9fe4bea..c5325ef 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -4,6 +4,7 @@ resources: - bases/networking.cfargotunnel.com_tunnels.yaml - bases/networking.cfargotunnel.com_clustertunnels.yaml +- bases/networking.cfargotunnel.com_tunnelbindings.yaml #+kubebuilder:scaffold:crdkustomizeresource patchesStrategicMerge: @@ -11,12 +12,14 @@ patchesStrategicMerge: # patches here are for enabling the conversion webhook for each CRD #- patches/webhook_in_tunnels.yaml #- patches/webhook_in_clustertunnels.yaml +#- patches/webhook_in_tunnelbindings.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. # patches here are for enabling the CA injection for each CRD #- patches/cainjection_in_tunnels.yaml #- patches/cainjection_in_clustertunnels.yaml +#- patches/cainjection_in_tunnelbindings.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/crd/patches/cainjection_in_tunnelbindings.yaml b/config/crd/patches/cainjection_in_tunnelbindings.yaml new file mode 100644 index 0000000..5d6ae34 --- /dev/null +++ b/config/crd/patches/cainjection_in_tunnelbindings.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: tunnelbindings.networking.cfargotunnel.com diff --git a/config/crd/patches/webhook_in_tunnelbindings.yaml b/config/crd/patches/webhook_in_tunnelbindings.yaml new file mode 100644 index 0000000..b4c92c4 --- /dev/null +++ b/config/crd/patches/webhook_in_tunnelbindings.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: tunnelbindings.networking.cfargotunnel.com +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 28e794c..e16d03d 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -50,24 +50,35 @@ rules: - update - watch - apiGroups: - - "" + - networking.cfargotunnel.com resources: - - services + - clustertunnels verbs: + - create + - delete - get - list + - patch - update - watch - apiGroups: - - "" + - networking.cfargotunnel.com resources: - - services/finalizers + - clustertunnels/finalizers verbs: - update - apiGroups: - networking.cfargotunnel.com resources: - - clustertunnels + - clustertunnels/status + verbs: + - get + - patch + - update +- apiGroups: + - networking.cfargotunnel.com + resources: + - tunnelbindings verbs: - create - delete @@ -79,13 +90,13 @@ rules: - apiGroups: - networking.cfargotunnel.com resources: - - clustertunnels/finalizers + - tunnelbindings/finalizers verbs: - update - apiGroups: - networking.cfargotunnel.com resources: - - clustertunnels/status + - tunnelbindings/status verbs: - get - patch diff --git a/config/rbac/tunnelbinding_editor_role.yaml b/config/rbac/tunnelbinding_editor_role.yaml new file mode 100644 index 0000000..fc12ba2 --- /dev/null +++ b/config/rbac/tunnelbinding_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit tunnelbindings. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: tunnelbinding-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: cloudflare-operator + app.kubernetes.io/part-of: cloudflare-operator + app.kubernetes.io/managed-by: kustomize + name: tunnelbinding-editor-role +rules: +- apiGroups: + - networking.cfargotunnel.com + resources: + - tunnelbindings + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.cfargotunnel.com + resources: + - tunnelbindings/status + verbs: + - get diff --git a/config/rbac/tunnelbinding_viewer_role.yaml b/config/rbac/tunnelbinding_viewer_role.yaml new file mode 100644 index 0000000..0e08b06 --- /dev/null +++ b/config/rbac/tunnelbinding_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view tunnelbindings. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: tunnelbinding-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: cloudflare-operator + app.kubernetes.io/part-of: cloudflare-operator + app.kubernetes.io/managed-by: kustomize + name: tunnelbinding-viewer-role +rules: +- apiGroups: + - networking.cfargotunnel.com + resources: + - tunnelbindings + verbs: + - get + - list + - watch +- apiGroups: + - networking.cfargotunnel.com + resources: + - tunnelbindings/status + verbs: + - get diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 51192d9..e7477b7 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -6,4 +6,5 @@ resources: - apps_v1_deployment.yaml - v1_service.yaml - networking_v1alpha1_clustertunnel.yaml +- networking_v1alpha1_tunnelbinding.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/networking_v1alpha1_tunnelbinding.yaml b/config/samples/networking_v1alpha1_tunnelbinding.yaml new file mode 100644 index 0000000..f95362f --- /dev/null +++ b/config/samples/networking_v1alpha1_tunnelbinding.yaml @@ -0,0 +1,14 @@ +apiVersion: networking.cfargotunnel.com/v1alpha1 +kind: TunnelBinding +metadata: + name: tunnelbinding-sample +subjects: + - name: whoami-test + spec: + fqdn: whoami-test.example.com + target: http://svc-1.namespace.cluster.local:8080 + caPool: custom + noTlsVerify: false +tunnelRef: + kind: ClusterTunnel + name: clustertunnel-sample diff --git a/controllers/service_controller.go b/controllers/service_controller.go deleted file mode 100644 index c2ec53a..0000000 --- a/controllers/service_controller.go +++ /dev/null @@ -1,602 +0,0 @@ -/* -Copyright 2022. - -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 controllers - -import ( - "context" - "crypto/md5" - "encoding/hex" - "fmt" - "sort" - "strings" - - networkingv1alpha1 "github.com/adyanth/cloudflare-operator/api/v1alpha1" - "github.com/go-logr/logr" - yaml "gopkg.in/yaml.v3" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/runtime" - apitypes "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - ctrllog "sigs.k8s.io/controller-runtime/pkg/log" - - appsv1 "k8s.io/api/apps/v1" - "k8s.io/client-go/tools/record" -) - -// ServiceReconciler reconciles a Service object -type ServiceReconciler struct { - client.Client - Scheme *runtime.Scheme - Recorder record.EventRecorder - Namespace string - OverwriteUnmanaged bool - - // Custom data for ease of (re)use - - ctx context.Context - log logr.Logger - config *UnvalidatedIngressRule - tunnel *networkingv1alpha1.Tunnel - clusterTunnel *networkingv1alpha1.ClusterTunnel - service *corev1.Service - configmap *corev1.ConfigMap - fallbackTarget string - namespacedName apitypes.NamespacedName - cfAPI *CloudflareAPI - isClusterTunnel bool -} - -// labelsForService returns the labels for selecting the resources served by a Tunnel. -func (r ServiceReconciler) labelsForService() map[string]string { - labels := map[string]string{ - configHostnameLabel: r.config.Hostname, - configServiceLabel: encodeCfService(r.config.Service), - } - - if r.isClusterTunnel { - labels[clusterTunnelAnnotation] = r.clusterTunnel.Name - labels[tunnelDomainLabel] = r.clusterTunnel.Spec.Cloudflare.Domain - } else { - labels[tunnelAnnotation] = r.tunnel.Name - labels[tunnelDomainLabel] = r.tunnel.Spec.Cloudflare.Domain - } - - return labels -} - -func decodeLabel(label string, service corev1.Service) string { - labelSplit := strings.Split(label, configServiceLabelSplit) - return fmt.Sprintf("%s://%s.%s.svc:%s", labelSplit[0], service.Name, service.Namespace, labelSplit[1]) -} - -func encodeCfService(cfService string) string { - protoSplit := strings.Split(cfService, "://") - domainSplit := strings.Split(protoSplit[1], ":") - return fmt.Sprintf("%s%s%s", protoSplit[0], configServiceLabelSplit, domainSplit[1]) -} - -func (r *ServiceReconciler) initStruct(ctx context.Context, service *corev1.Service) error { - r.ctx = ctx - r.service = service - - // Read Service annotations. If both annotations are not set, return without doing anything - tunnelName, okTunnel := r.service.Annotations[tunnelAnnotation] - clusterTunnelName, okClusterTunnel := r.service.Annotations[clusterTunnelAnnotation] - - if okTunnel == okClusterTunnel { - err := fmt.Errorf("cannot have both tunnel and cluster tunnel annotations") - r.log.Error(err, "error reading annotations") - r.Recorder.Event(service, corev1.EventTypeWarning, "ErrAnno", "Conflicting annotations found") - return err - } - - var err error - - if okClusterTunnel { - r.isClusterTunnel = true - - r.namespacedName = apitypes.NamespacedName{Name: clusterTunnelName, Namespace: r.Namespace} - r.clusterTunnel = &networkingv1alpha1.ClusterTunnel{} - if err := r.Get(r.ctx, r.namespacedName, r.clusterTunnel); err != nil { - r.log.Error(err, "Failed to get ClusterTunnel", "namespacedName", r.namespacedName) - r.Recorder.Event(service, corev1.EventTypeWarning, "ErrTunnel", "Error getting ClusterTunnel") - return err - } - - r.fallbackTarget = r.clusterTunnel.Spec.FallbackTarget - - if r.cfAPI, _, err = getAPIDetails(r.ctx, r.Client, r.log, r.clusterTunnel.Spec, r.clusterTunnel.Status, r.Namespace); err != nil { - r.log.Error(err, "unable to get API details") - r.Recorder.Event(service, corev1.EventTypeWarning, "ErrApiConfig", "Error getting API details") - return err - } - } else { - r.isClusterTunnel = false - - r.namespacedName = apitypes.NamespacedName{Name: tunnelName, Namespace: r.service.Namespace} - r.tunnel = &networkingv1alpha1.Tunnel{} - if err := r.Get(r.ctx, r.namespacedName, r.tunnel); err != nil { - r.log.Error(err, "Failed to get Tunnel", "namespacedName", r.namespacedName) - r.Recorder.Event(service, corev1.EventTypeWarning, "ErrTunnel", "Error getting Tunnel") - return err - } - - r.fallbackTarget = r.tunnel.Spec.FallbackTarget - - if r.cfAPI, _, err = getAPIDetails(r.ctx, r.Client, r.log, r.tunnel.Spec, r.tunnel.Status, r.service.Namespace); err != nil { - r.log.Error(err, "unable to get API details") - r.Recorder.Event(service, corev1.EventTypeWarning, "ErrApiConfig", "Error getting API details") - return err - } - } - - r.configmap = &corev1.ConfigMap{} - if err := r.Get(r.ctx, r.namespacedName, r.configmap); err != nil { - r.log.Error(err, "unable to get configmap for configuration") - r.Recorder.Event(service, corev1.EventTypeWarning, "ErrConfigMap", "Error finding ConfigMap for Tunnel referenced by Service") - return err - } - - var config UnvalidatedIngressRule - if config, err = r.getConfigForService("", nil); err != nil { - r.log.Error(err, "error getting config for service") - r.Recorder.Event(service, corev1.EventTypeWarning, "ErrBuildConfig", "Error building Tunnel configuration") - return err - } - r.config = &config - - return nil -} - -//+kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;update -//+kubebuilder:rbac:groups=core,resources=services/finalizers,verbs=update -//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=tunnels,verbs=get -//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=tunnels/status,verbs=get -//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=clustertunnels,verbs=get -//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=clustertunnels/status,verbs=get -//+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;update;patch -//+kubebuilder:rbac:groups=core,resources=secrets,verbs=get -//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;update;patch -//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch - -// Reconcile is part of the main kubernetes reconciliation loop which aims to -// move the current state of the cluster closer to the desired state. -// -// For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.10.0/pkg/reconcile -func (r *ServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - r.log = ctrllog.FromContext(ctx) - - // Fetch Service from API - service := &corev1.Service{} - if err := r.Get(ctx, req.NamespacedName, service); err != nil { - if apierrors.IsNotFound(err) { - // Service object not found, could have been deleted after reconcile request. - // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. - // Return and don't requeue - r.log.Info("Service deleted, nothing to do") - return ctrl.Result{}, nil - } - r.log.Error(err, "unable to fetch Service") - return ctrl.Result{}, err - } - - _, okTunnel := service.Annotations[tunnelAnnotation] - _, okClusterTunnel := service.Annotations[clusterTunnelAnnotation] - - if !(okTunnel || okClusterTunnel) { - // If a service with annotation is edited to remove just annotations, cleanup wont happen. - // Not an issue as such, since it will be overwritten the next time it is used. - return ctrl.Result{}, r.unManagedService(ctx, service) - } - - if err := r.initStruct(ctx, service); err != nil { - r.log.Error(err, "initialization failed") - return ctrl.Result{}, err - } - - // Check if Service is marked for deletion - if r.service.GetDeletionTimestamp() != nil { - return ctrl.Result{}, r.deletionLogic() - } - - if err := r.creationLogic(); err != nil { - return ctrl.Result{}, err - } - - // Configure ConfigMap - r.Recorder.Event(service, corev1.EventTypeNormal, "Configuring", "Configuring ConfigMap") - if err := r.configureCloudflare(); err != nil { - r.log.Error(err, "unable to configure ConfigMap", "key", configmapKey) - r.Recorder.Event(service, corev1.EventTypeWarning, "FailedConfigure", "Failed to configure ConfigMap") - return ctrl.Result{}, err - } - r.Recorder.Event(service, corev1.EventTypeNormal, "Configured", "Configured Cloudflare Tunnel") - return ctrl.Result{}, nil -} - -func (r *ServiceReconciler) unManagedService(ctx context.Context, service *corev1.Service) error { - r.log.Info("No related annotations not found, skipping Service") - // Check if our finalizer is present on a non managed resource and remove it. This can happen if annotations were removed from the Service. - if controllerutil.ContainsFinalizer(service, tunnelFinalizer) { - r.log.Info("Finalizer found on unmanaged Service, removing it") - controllerutil.RemoveFinalizer(service, tunnelFinalizer) - err := r.Update(ctx, service) - if err != nil { - r.log.Error(err, "unable to remove finalizer from unmanaged Service") - r.Recorder.Event(service, corev1.EventTypeWarning, "FailedFinalizerUnset", "Failed to remove Service Finalizer from unmanaged Service") - return err - } - r.Recorder.Event(service, corev1.EventTypeNormal, "FinalizerUnset", "Service Finalizer removed, unmanaged Service") - } - // Our finalizer not present, nothing to do. - return nil -} - -func (r *ServiceReconciler) deletionLogic() error { - if controllerutil.ContainsFinalizer(r.service, tunnelFinalizer) { - // Run finalization logic. If the finalization logic fails, - // don't remove the finalizer so that we can retry during the next reconciliation. - - // Delete DNS entry - txtId, dnsTxtResponse, canUseDns, err := r.cfAPI.GetManagedDnsTxt(r.config.Hostname) - if err != nil { - // We should not use this entry - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedReadingTxt", "Failed to read existing TXT DNS entry, not cleaning up") - } else if !canUseDns { - // We cannot use this entry. This should be happen if all controllers are using DNS management with the same prefix. - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedReadingTxt", fmt.Sprintf("FQDN already managed by Tunnel Name: %s, Id: %s, not cleaning up", dnsTxtResponse.TunnelName, dnsTxtResponse.TunnelId)) - } else { - if id, err := r.cfAPI.GetDNSCNameId(r.config.Hostname); err != nil { - r.log.Error(err, "Error fetching DNS record", "Hostname", r.config.Hostname) - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedDeletingDns", "Error fetching DNS record") - } else if id != dnsTxtResponse.DnsId { - err := fmt.Errorf("DNS ID from TXT and real DNS record does not match") - r.log.Error(err, "DNS ID from TXT and real DNS record does not match", "Hostname", r.config.Hostname) - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedDeletingDns", "DNS/TXT ID Mismatch") - } else { - if err := r.cfAPI.DeleteDNSId(r.config.Hostname, dnsTxtResponse.DnsId, true); err != nil { - r.log.Info("Failed to delete DNS entry", "Hostname", r.config.Hostname) - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedDeletingDns", fmt.Sprintf("Failed to delete DNS entry: %s", err.Error())) - return err - } - r.log.Info("Deleted DNS entry", "Hostname", r.config.Hostname) - r.Recorder.Event(r.service, corev1.EventTypeNormal, "DeletedDns", "Deleted DNS entry") - if err := r.cfAPI.DeleteDNSId(r.config.Hostname, txtId, true); err != nil { - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedDeletingTxt", fmt.Sprintf("Failed to delete TXT entry: %s", err.Error())) - return err - } - r.log.Info("Deleted DNS TXT entry", "Hostname", r.config.Hostname) - r.Recorder.Event(r.service, corev1.EventTypeNormal, "DeletedTxt", "Deleted DNS TXT entry") - } - } - - // Remove tunnelFinalizer. Once all finalizers have been - // removed, the object will be deleted. - controllerutil.RemoveFinalizer(r.service, tunnelFinalizer) - err = r.Update(r.ctx, r.service) - if err != nil { - r.log.Error(err, "unable to continue with Service deletion") - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedFinalizerUnset", "Failed to remove Service Finalizer") - return err - } - r.Recorder.Event(r.service, corev1.EventTypeNormal, "FinalizerUnset", "Service Finalizer removed") - } - // Already removed our finalizer, all good. - return nil -} - -func (r *ServiceReconciler) creationLogic() error { - // Add finalizer for Service - if !controllerutil.ContainsFinalizer(r.service, tunnelFinalizer) { - controllerutil.AddFinalizer(r.service, tunnelFinalizer) - } - - // Add labels for Service - if r.service.Labels == nil { - r.service.Labels = make(map[string]string) - } - for k, v := range r.labelsForService() { - r.service.Labels[k] = v - } - - // Update Service resource - if err := r.Update(r.ctx, r.service); err != nil { - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedMetaSet", "Failed to set Service Finalizer and Labels") - return err - } - r.Recorder.Event(r.service, corev1.EventTypeNormal, "MetaSet", "Service Finalizer and Labels added") - - // Create DNS entry - return r.createDNSLogic() -} - -func (r *ServiceReconciler) createDNSLogic() error { - txtId, dnsTxtResponse, canUseDns, err := r.cfAPI.GetManagedDnsTxt(r.config.Hostname) - if err != nil { - // We should not use this entry - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedReadingTxt", "Failed to read existing TXT DNS entry") - return err - } - if !canUseDns { - // We cannot use this entry - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedReadingTxt", fmt.Sprintf("FQDN already managed by Tunnel Name: %s, Id: %s", dnsTxtResponse.TunnelName, dnsTxtResponse.TunnelId)) - return err - } - existingId, err := r.cfAPI.GetDNSCNameId(r.config.Hostname) - // Check if a DNS record exists - if err == nil || existingId != "" { - // without a managed TXT record when we are not supposed to overwrite it - if !r.OverwriteUnmanaged && txtId == "" { - err := fmt.Errorf("unmanaged FQDN present") - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedReadingTxt", "FQDN present but unmanaged by Tunnel") - return err - } - // To overwrite - dnsTxtResponse.DnsId = existingId - } - - newDnsId, err := r.cfAPI.InsertOrUpdateCName(r.config.Hostname, dnsTxtResponse.DnsId) - if err != nil { - r.log.Error(err, "Failed to insert/update DNS entry", "Hostname", r.config.Hostname) - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedCreatingDns", fmt.Sprintf("Failed to insert/update DNS entry: %s", err.Error())) - return err - } - if err := r.cfAPI.InsertOrUpdateTXT(r.config.Hostname, txtId, newDnsId); err != nil { - r.log.Error(err, "Failed to insert/update TXT entry", "Hostname", r.config.Hostname) - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedCreatingTxt", fmt.Sprintf("Failed to insert/update TXT entry: %s", err.Error())) - if err := r.cfAPI.DeleteDNSId(r.config.Hostname, newDnsId, dnsTxtResponse.DnsId != ""); err != nil { - r.log.Info("Failed to delete DNS entry, left in broken state", "Hostname", r.config.Hostname) - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedDeletingDns", "Failed to delete DNS entry, left in broken state") - return err - } - if dnsTxtResponse.DnsId != "" { - r.Recorder.Event(r.service, corev1.EventTypeWarning, "DeletedDns", "Deleted DNS entry, retrying") - r.log.Info("Deleted DNS entry", "Hostname", r.config.Hostname) - } else { - r.Recorder.Event(r.service, corev1.EventTypeWarning, "PreventDeleteDns", "Prevented DNS entry deletion, retrying") - r.log.Info("Did not delete DNS entry", "Hostname", r.config.Hostname) - } - return err - } - - r.log.Info("Inserted/Updated DNS/TXT entry") - r.Recorder.Event(r.service, corev1.EventTypeNormal, "CreatedDns", "Inserted/Updated DNS/TXT entry") - return nil -} - -func (r *ServiceReconciler) getRelevantServices() ([]corev1.Service, error) { - // Fetch Services from API - var listOpts []client.ListOption - if r.isClusterTunnel { - listOpts = []client.ListOption{client.MatchingLabels(map[string]string{ - clusterTunnelAnnotation: r.clusterTunnel.Name, - })} - } else { - listOpts = []client.ListOption{client.InNamespace(r.service.Namespace), client.MatchingLabels(map[string]string{ - tunnelAnnotation: r.tunnel.Name, - })} - } - serviceList := &corev1.ServiceList{} - if err := r.List(r.ctx, serviceList, listOpts...); err != nil { - r.log.Error(err, "failed to list Services", "listOpts", listOpts) - return []corev1.Service{}, err - } - - if len(serviceList.Items) == 0 { - r.log.Info("No services found, tunnel not in use", "listOpts", listOpts) - } - - sort.Slice(serviceList.Items, func(i, j int) bool { - return serviceList.Items[i].Name < serviceList.Items[j].Name - }) - - return serviceList.Items, nil -} - -// Get the config entry to be added for this service -func (r ServiceReconciler) getConfigForService(tunnelDomain string, service *corev1.Service) (UnvalidatedIngressRule, error) { - if service == nil { - r.log.Info("Using current service for generating config") - service = r.service - } - - if len(service.Spec.Ports) == 0 { - err := fmt.Errorf("no ports found in service spec, cannot proceed") - r.log.Error(err, "unable to read service") - return UnvalidatedIngressRule{}, err - } else if len(service.Spec.Ports) > 1 { - r.log.Info("Multiple ports definition found, picking the first in the list") - } - - servicePort := service.Spec.Ports[0] - tunnelProto := service.Annotations[tunnelProtoAnnotation] - validProto := tunnelValidProtoMap[tunnelProto] - - serviceProto := r.getServiceProto(tunnelProto, validProto, servicePort) - - r.log.Info("Selected protocol", "protocol", serviceProto) - - cfService := fmt.Sprintf("%s://%s.%s.svc:%d", serviceProto, service.Name, service.Namespace, servicePort.Port) - - cfHostname := service.Annotations[fqdnAnnotation] - - // Generate cfHostname string from Service Spec if not provided - if cfHostname == "" { - if tunnelDomain == "" { - r.log.Info("Using current tunnel's domain for generating config") - tunnelDomain = r.cfAPI.Domain - } - cfHostname = fmt.Sprintf("%s.%s", service.Name, tunnelDomain) - r.log.Info("using default domain value", "domain", tunnelDomain) - } - - r.log.Info("generated cloudflare config", "cfHostname", cfHostname, "cfService", cfService) - - return UnvalidatedIngressRule{Hostname: cfHostname, Service: cfService}, nil -} - -// getServiceProto returns the service protocol to be used -func (r *ServiceReconciler) getServiceProto(tunnelProto string, validProto bool, servicePort corev1.ServicePort) string { - var serviceProto string - if tunnelProto != "" && !validProto { - r.log.Info("Invalid Protocol provided, following default protocol logic") - } - - if tunnelProto != "" && validProto { - serviceProto = tunnelProto - } else if servicePort.Protocol == corev1.ProtocolTCP { - // Default protocol selection logic - switch servicePort.Port { - case 22: - serviceProto = tunnelProtoSSH - case 139, 445: - serviceProto = tunnelProtoSMB - case 443: - serviceProto = tunnelProtoHTTPS - case 3389: - serviceProto = tunnelProtoRDP - default: - serviceProto = tunnelProtoHTTP - } - } else if servicePort.Protocol == corev1.ProtocolUDP { - serviceProto = tunnelProtoUDP - } else { - err := fmt.Errorf("unsupported protocol") - r.log.Error(err, "could not select protocol", "portProtocol", servicePort.Protocol, "annotationProtocol", tunnelProto) - } - return serviceProto -} - -func (r *ServiceReconciler) getConfigMapConfiguration() (*Configuration, error) { - // Read ConfigMap YAML - configStr, ok := r.configmap.Data[configmapKey] - if !ok { - err := fmt.Errorf("unable to find key `%s` in ConfigMap", configmapKey) - r.log.Error(err, "unable to find key in ConfigMap", "key", configmapKey) - return &Configuration{}, err - } - - config := &Configuration{} - if err := yaml.Unmarshal([]byte(configStr), config); err != nil { - r.log.Error(err, "unable to read config as YAML") - return &Configuration{}, err - } - return config, nil -} - -func (r *ServiceReconciler) setConfigMapConfiguration(config *Configuration) error { - // Push updated changes - var configStr string - if configBytes, err := yaml.Marshal(config); err == nil { - configStr = string(configBytes) - } else { - r.log.Error(err, "unable to marshal config to ConfigMap", "key", configmapKey) - return err - } - r.configmap.Data[configmapKey] = configStr - if err := r.Update(r.ctx, r.configmap); err != nil { - r.log.Error(err, "unable to marshal config to ConfigMap", "key", configmapKey) - return err - } - - // Set checksum as annotation on Deployment, causing a restart of the Pods to take config - cfDeployment := &appsv1.Deployment{} - if err := r.Get(r.ctx, apitypes.NamespacedName{Name: r.configmap.Name, Namespace: r.configmap.Namespace}, cfDeployment); err != nil { - r.log.Error(err, "Error in getting deployment, failed to restart") - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedConfigure", "Failed to get Deployment") - return err - } - hash := md5.Sum([]byte(configStr)) - // Restart pods - r.Recorder.Event(r.service, corev1.EventTypeNormal, "ApplyingConfig", "Applying ConfigMap to Deployment") - r.Recorder.Event(cfDeployment, corev1.EventTypeNormal, "ApplyingConfig", "Applying ConfigMap to Deployment") - if cfDeployment.Spec.Template.Annotations == nil { - cfDeployment.Spec.Template.Annotations = map[string]string{} - } - cfDeployment.Spec.Template.Annotations[tunnelConfigChecksum] = hex.EncodeToString(hash[:]) - if err := r.Update(r.ctx, cfDeployment); err != nil { - r.log.Error(err, "Failed to update Deployment for restart") - r.Recorder.Event(r.service, corev1.EventTypeWarning, "FailedApplyingConfig", "Failed to apply ConfigMap to Deployment") - r.Recorder.Event(cfDeployment, corev1.EventTypeWarning, "FailedApplyingConfig", "Failed to apply ConfigMap to Deployment") - return err - } - r.log.Info("Restarted deployment") - r.Recorder.Event(r.service, corev1.EventTypeNormal, "AppliedConfig", "ConfigMap applied to Deployment") - r.Recorder.Event(cfDeployment, corev1.EventTypeNormal, "AppliedConfig", "ConfigMap applied to Deployment") - return nil -} - -func (r *ServiceReconciler) configureCloudflare() error { - var config *Configuration - var err error - - if config, err = r.getConfigMapConfiguration(); err != nil { - r.log.Error(err, "unable to get ConfigMap") - return err - } - - services, err := r.getRelevantServices() - if err != nil { - r.log.Error(err, "unable to get services") - return err - } - - // Total number of ingresses is the number of services + 1 for the catchall ingress - finalIngresses := make([]UnvalidatedIngressRule, 0, len(services)+1) - - for _, service := range services { - targetService := decodeLabel(service.Labels[configServiceLabel], service) - if target, ok := service.Annotations[targetAnnotation]; ok { - targetService = target - } - originRequest := OriginRequestConfig{} - // Check for noTlsVerify - if _, noTlsVerify := service.Annotations[noTlsVerifyAnnotation]; noTlsVerify { - originRequest.NoTLSVerify = &noTlsVerify - } - // Check for caPool - if caPool, ok := service.Annotations[caPoolAnnotation]; ok { - caPath := fmt.Sprintf("/etc/cloudflared/certs/%s", caPool) - originRequest.CAPool = &caPath - } - finalIngresses = append(finalIngresses, UnvalidatedIngressRule{ - Hostname: service.Labels[configHostnameLabel], - Service: targetService, - OriginRequest: originRequest, - }) - } - // Catchall ingress - finalIngresses = append(finalIngresses, UnvalidatedIngressRule{ - Service: r.fallbackTarget, - }) - - config.Ingress = finalIngresses - - return r.setConfigMapConfiguration(config) -} - -// SetupWithManager sets up the controller with the Manager. -func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error { - r.Recorder = mgr.GetEventRecorderFor("cloudflare-operator") - return ctrl.NewControllerManagedBy(mgr). - For(&corev1.Service{}). - Complete(r) -} diff --git a/controllers/tunnelbinding_controller.go b/controllers/tunnelbinding_controller.go new file mode 100644 index 0000000..6fe8466 --- /dev/null +++ b/controllers/tunnelbinding_controller.go @@ -0,0 +1,576 @@ +/* +Copyright 2022. + +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 controllers + +import ( + "context" + "crypto/md5" + "encoding/hex" + "fmt" + "sort" + + networkingv1alpha1 "github.com/adyanth/cloudflare-operator/api/v1alpha1" + "github.com/go-logr/logr" + yaml "gopkg.in/yaml.v3" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + apitypes "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + + appsv1 "k8s.io/api/apps/v1" + "k8s.io/client-go/tools/record" +) + +// TunnelBindingReconciler reconciles a TunnelBinding object +type TunnelBindingReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Namespace string + OverwriteUnmanaged bool + + // Custom data for ease of (re)use + + ctx context.Context + log logr.Logger + binding *networkingv1alpha1.TunnelBinding + configmap *corev1.ConfigMap + fallbackTarget string + cfAPI *CloudflareAPI +} + +// labelsForBinding returns the labels for selecting the Bindings served by a Tunnel. +func (r TunnelBindingReconciler) labelsForBinding() map[string]string { + labels := map[string]string{ + tunnelNameLabel: r.binding.Name, + tunnelKindLabel: r.binding.Kind, + } + + return labels +} + +func (r *TunnelBindingReconciler) initStruct(ctx context.Context, tunnelBinding *networkingv1alpha1.TunnelBinding) error { + r.ctx = ctx + r.binding = tunnelBinding + + var err error + namespacedName := apitypes.NamespacedName{Name: tunnelBinding.TunnelRef.Name, Namespace: r.Namespace} + + // Process based on Tunnel Kind + switch r.binding.TunnelRef.Kind { + case "ClusterTunnel": + clusterTunnel := &networkingv1alpha1.ClusterTunnel{} + if err := r.Get(r.ctx, namespacedName, clusterTunnel); err != nil { + r.log.Error(err, "Failed to get ClusterTunnel", "namespacedName", namespacedName) + r.Recorder.Event(tunnelBinding, corev1.EventTypeWarning, "ErrTunnel", "Error getting ClusterTunnel") + return err + } + + r.fallbackTarget = clusterTunnel.Spec.FallbackTarget + + if r.cfAPI, _, err = getAPIDetails(r.ctx, r.Client, r.log, clusterTunnel.Spec, clusterTunnel.Status, r.Namespace); err != nil { + r.log.Error(err, "unable to get API details") + r.Recorder.Event(tunnelBinding, corev1.EventTypeWarning, "ErrApiConfig", "Error getting API details") + return err + } + case "Tunnel": + tunnel := &networkingv1alpha1.Tunnel{} + if err := r.Get(r.ctx, namespacedName, tunnel); err != nil { + r.log.Error(err, "Failed to get Tunnel", "namespacedName", namespacedName) + r.Recorder.Event(tunnelBinding, corev1.EventTypeWarning, "ErrTunnel", "Error getting Tunnel") + return err + } + + r.fallbackTarget = tunnel.Spec.FallbackTarget + + if r.cfAPI, _, err = getAPIDetails(r.ctx, r.Client, r.log, tunnel.Spec, tunnel.Status, r.Namespace); err != nil { + r.log.Error(err, "unable to get API details") + r.Recorder.Event(tunnelBinding, corev1.EventTypeWarning, "ErrApiConfig", "Error getting API details") + return err + } + default: + err = fmt.Errorf("invalid kind") + r.log.Error(err, "unsupported tunnelRef Kind") + r.Recorder.Event(tunnelBinding, corev1.EventTypeWarning, "ErrTunnelKind", "Unsupported tunnel kind") + return err + } + + r.configmap = &corev1.ConfigMap{} + if err := r.Get(r.ctx, namespacedName, r.configmap); err != nil { + r.log.Error(err, "unable to get configmap for configuration") + r.Recorder.Event(tunnelBinding, corev1.EventTypeWarning, "ErrConfigMap", "Error finding ConfigMap for Tunnel referenced by TunnelBinding") + return err + } + + return nil +} + +//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=tunnelbindings,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=tunnelbindings/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=tunnelbindings/finalizers,verbs=update +//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=tunnels,verbs=get +//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=tunnels/status,verbs=get +//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=clustertunnels,verbs=get +//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=clustertunnels/status,verbs=get +//+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;update;patch +//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;update;patch +//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.10.0/pkg/reconcile +func (r *TunnelBindingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + r.log = ctrllog.FromContext(ctx) + + // Fetch TunnelBinding from API + tunnelBinding := &networkingv1alpha1.TunnelBinding{} + if err := r.Get(ctx, req.NamespacedName, tunnelBinding); err != nil { + if apierrors.IsNotFound(err) { + // Service object not found, could have been deleted after reconcile request. + // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. + // Return and don't requeue + r.log.Info("Service deleted, nothing to do") + return ctrl.Result{}, nil + } + r.log.Error(err, "unable to fetch Service") + return ctrl.Result{}, err + } + + if err := r.initStruct(ctx, tunnelBinding); err != nil { + r.log.Error(err, "initialization failed") + return ctrl.Result{}, err + } + + // Check if TunnelBinding is marked for deletion + if r.binding.GetDeletionTimestamp() != nil { + return ctrl.Result{}, r.deletionLogic() + } + + r.setStatus() + + // Configure ConfigMap + r.Recorder.Event(tunnelBinding, corev1.EventTypeNormal, "Configuring", "Configuring ConfigMap") + if err := r.configureCloudflareDaemon(); err != nil { + r.log.Error(err, "unable to configure ConfigMap", "key", configmapKey) + r.Recorder.Event(tunnelBinding, corev1.EventTypeWarning, "FailedConfigure", "Failed to configure ConfigMap") + return ctrl.Result{}, err + } + r.Recorder.Event(tunnelBinding, corev1.EventTypeNormal, "Configured", "Configured Cloudflare Tunnel") + + if err := r.creationLogic(); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *TunnelBindingReconciler) setStatus() { + status := make([]networkingv1alpha1.ServiceInfo, 0, len(r.binding.Subjects)) + for _, sub := range r.binding.Subjects { + hostname, target, err := r.getConfigForSubject(sub) + if err != nil { + r.log.Error(err, "error getting config for service", "svc", sub.Name) + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "ErrBuildConfig", + fmt.Sprintf("Error building Tunnel configuration, svc: %s", sub.Name)) + } + status = append(status, networkingv1alpha1.ServiceInfo{Hostname: hostname, Target: target}) + } + r.binding.Status.Services = status +} + +func (r *TunnelBindingReconciler) deletionLogic() error { + if controllerutil.ContainsFinalizer(r.binding, tunnelFinalizer) { + // Run finalization logic. If the finalization logic fails, + // don't remove the finalizer so that we can retry during the next reconciliation. + + errors := false + var err error + for _, info := range r.binding.Status.Services { + if err = r.deleteDNSLogic(info.Hostname); err != nil { + errors = true + } + } + if errors { + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FinalizerNotUnset", "Not removing Finalizer due to errors") + return err + } + + // Remove tunnelFinalizer. Once all finalizers have been + // removed, the object will be deleted. + controllerutil.RemoveFinalizer(r.binding, tunnelFinalizer) + err = r.Update(r.ctx, r.binding) + if err != nil { + r.log.Error(err, "unable to delete Finalizer") + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedFinalizerUnset", "Failed to remove Finalizer") + return err + } + r.Recorder.Event(r.binding, corev1.EventTypeNormal, "FinalizerUnset", "Finalizer removed") + } + // Already removed our finalizer, all good. + return nil +} + +func (r *TunnelBindingReconciler) creationLogic() error { + // Add finalizer for Service + if !controllerutil.ContainsFinalizer(r.binding, tunnelFinalizer) { + controllerutil.AddFinalizer(r.binding, tunnelFinalizer) + } + + // Add labels for Service + if r.binding.Labels == nil { + r.binding.Labels = make(map[string]string) + } + for k, v := range r.labelsForBinding() { + r.binding.Labels[k] = v + } + + // Update Service resource + if err := r.Update(r.ctx, r.binding); err != nil { + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedMetaSet", "Failed to set TunnelBinding Finalizer and Labels") + return err + } + r.Recorder.Event(r.binding, corev1.EventTypeNormal, "MetaSet", "TunnelBinding Finalizer and Labels added") + + errors := false + var err error + // Create DNS entries + for _, info := range r.binding.Status.Services { + err = r.createDNSLogic(info.Hostname) + if err != nil { + errors = true + } + } + if errors { + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedDNSCreatePartial", "Some DNS entries failed to create") + return err + } + return nil +} + +func (r *TunnelBindingReconciler) createDNSLogic(hostname string) error { + txtId, dnsTxtResponse, canUseDns, err := r.cfAPI.GetManagedDnsTxt(hostname) + if err != nil { + // We should not use this entry + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedReadingTxt", "Failed to read existing TXT DNS entry") + return err + } + if !canUseDns { + // We cannot use this entry + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedReadingTxt", fmt.Sprintf("FQDN already managed by Tunnel Name: %s, Id: %s", dnsTxtResponse.TunnelName, dnsTxtResponse.TunnelId)) + return err + } + existingId, err := r.cfAPI.GetDNSCNameId(hostname) + // Check if a DNS record exists + if err == nil || existingId != "" { + // without a managed TXT record when we are not supposed to overwrite it + if !r.OverwriteUnmanaged && txtId == "" { + err := fmt.Errorf("unmanaged FQDN present") + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedReadingTxt", "FQDN present but unmanaged by Tunnel") + return err + } + // To overwrite + dnsTxtResponse.DnsId = existingId + } + + newDnsId, err := r.cfAPI.InsertOrUpdateCName(hostname, dnsTxtResponse.DnsId) + if err != nil { + r.log.Error(err, "Failed to insert/update DNS entry", "Hostname", hostname) + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedCreatingDns", fmt.Sprintf("Failed to insert/update DNS entry: %s", err.Error())) + return err + } + if err := r.cfAPI.InsertOrUpdateTXT(hostname, txtId, newDnsId); err != nil { + r.log.Error(err, "Failed to insert/update TXT entry", "Hostname", hostname) + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedCreatingTxt", fmt.Sprintf("Failed to insert/update TXT entry: %s", err.Error())) + if err := r.cfAPI.DeleteDNSId(hostname, newDnsId, dnsTxtResponse.DnsId != ""); err != nil { + r.log.Info("Failed to delete DNS entry, left in broken state", "Hostname", hostname) + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedDeletingDns", "Failed to delete DNS entry, left in broken state") + return err + } + if dnsTxtResponse.DnsId != "" { + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "DeletedDns", "Deleted DNS entry, retrying") + r.log.Info("Deleted DNS entry", "Hostname", hostname) + } else { + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "PreventDeleteDns", "Prevented DNS entry deletion, retrying") + r.log.Info("Did not delete DNS entry", "Hostname", hostname) + } + return err + } + + r.log.Info("Inserted/Updated DNS/TXT entry") + r.Recorder.Event(r.binding, corev1.EventTypeNormal, "CreatedDns", "Inserted/Updated DNS/TXT entry") + return nil +} + +func (r *TunnelBindingReconciler) deleteDNSLogic(hostname string) error { + // Delete DNS entry + txtId, dnsTxtResponse, canUseDns, err := r.cfAPI.GetManagedDnsTxt(hostname) + if err != nil { + // We should not use this entry + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedReadingTxt", "Failed to read existing TXT DNS entry, not cleaning up") + } else if !canUseDns { + // We cannot use this entry. This should be happen if all controllers are using DNS management with the same prefix. + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedReadingTxt", fmt.Sprintf("FQDN already managed by Tunnel Name: %s, Id: %s, not cleaning up", dnsTxtResponse.TunnelName, dnsTxtResponse.TunnelId)) + } else { + if id, err := r.cfAPI.GetDNSCNameId(hostname); err != nil { + r.log.Error(err, "Error fetching DNS record", "Hostname", hostname) + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedDeletingDns", "Error fetching DNS record") + } else if id != dnsTxtResponse.DnsId { + err := fmt.Errorf("DNS ID from TXT and real DNS record does not match") + r.log.Error(err, "DNS ID from TXT and real DNS record does not match", "Hostname", hostname) + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedDeletingDns", "DNS/TXT ID Mismatch") + } else { + if err := r.cfAPI.DeleteDNSId(hostname, dnsTxtResponse.DnsId, true); err != nil { + r.log.Info("Failed to delete DNS entry", "Hostname", hostname) + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedDeletingDns", fmt.Sprintf("Failed to delete DNS entry: %s", err.Error())) + return err + } + r.log.Info("Deleted DNS entry", "Hostname", hostname) + r.Recorder.Event(r.binding, corev1.EventTypeNormal, "DeletedDns", "Deleted DNS entry") + if err := r.cfAPI.DeleteDNSId(hostname, txtId, true); err != nil { + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedDeletingTxt", fmt.Sprintf("Failed to delete TXT entry: %s", err.Error())) + return err + } + r.log.Info("Deleted DNS TXT entry", "Hostname", hostname) + r.Recorder.Event(r.binding, corev1.EventTypeNormal, "DeletedTxt", "Deleted DNS TXT entry") + } + } + return nil +} + +func (r *TunnelBindingReconciler) getRelevantTunnelBindings() ([]networkingv1alpha1.TunnelBinding, error) { + // Fetch TunnelBindings from API + listOpts := []client.ListOption{client.MatchingLabels(map[string]string{ + tunnelNameLabel: r.binding.Name, + tunnelKindLabel: r.binding.Kind, + })} + tunnelBindingList := &networkingv1alpha1.TunnelBindingList{} + if err := r.List(r.ctx, tunnelBindingList, listOpts...); err != nil { + r.log.Error(err, "failed to list Tunnel Bindings", "listOpts", listOpts) + return tunnelBindingList.Items, err + } + + bindings := tunnelBindingList.Items + + if len(bindings) == 0 { + r.log.Info("No services found, tunnel not in use") + } + + // Sort by binding name for idempotent config generation + sort.Slice(bindings, func(i, j int) bool { + return bindings[i].Name < bindings[j].Name + }) + + return bindings, nil +} + +// Get the config entry to be added for this service +func (r TunnelBindingReconciler) getConfigForSubject(subject networkingv1alpha1.TunnelBindingSubject) (string, string, error) { + hostname := subject.Spec.Fqdn + target := "http_status:404" + + // Generate cfHostname string from Service Spec if not provided + if hostname == "" { + r.log.Info("Using current tunnel's domain for generating config") + hostname = fmt.Sprintf("%s.%s", subject.Name, r.cfAPI.Domain) + r.log.Info("using default domain value", "domain", r.cfAPI.Domain) + } + + service := &corev1.Service{} + if err := r.Get(r.ctx, apitypes.NamespacedName{Name: subject.Name, Namespace: r.Namespace}, service); err != nil { + r.log.Error(err, "Error getting referenced service") + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedService", "Failed to get Service") + return hostname, target, err + } + + if len(service.Spec.Ports) == 0 { + err := fmt.Errorf("no ports found in service spec, cannot proceed") + r.log.Error(err, "unable to read service ports", "svc", service.Name) + return hostname, target, err + } else if len(service.Spec.Ports) > 1 { + r.log.Info("Multiple ports definition found, picking the first in the list", "svc", service.Name) + } + + servicePort := service.Spec.Ports[0] + tunnelProto := subject.Spec.Protocol + validProto := tunnelValidProtoMap[tunnelProto] + + serviceProto := r.getServiceProto(tunnelProto, validProto, servicePort) + + r.log.Info("Selected protocol", "protocol", serviceProto) + + target = fmt.Sprintf("%s://%s.%s.svc:%d", serviceProto, service.Name, service.Namespace, servicePort.Port) + + r.log.Info("generated cloudflare config", "hostname", hostname, "target", target) + + return hostname, target, nil +} + +// getServiceProto returns the service protocol to be used +func (r *TunnelBindingReconciler) getServiceProto(tunnelProto string, validProto bool, servicePort corev1.ServicePort) string { + var serviceProto string + if tunnelProto != "" && !validProto { + r.log.Info("Invalid Protocol provided, following default protocol logic") + } + + if tunnelProto != "" && validProto { + serviceProto = tunnelProto + } else if servicePort.Protocol == corev1.ProtocolTCP { + // Default protocol selection logic + switch servicePort.Port { + case 22: + serviceProto = tunnelProtoSSH + case 139, 445: + serviceProto = tunnelProtoSMB + case 443: + serviceProto = tunnelProtoHTTPS + case 3389: + serviceProto = tunnelProtoRDP + default: + serviceProto = tunnelProtoHTTP + } + } else if servicePort.Protocol == corev1.ProtocolUDP { + serviceProto = tunnelProtoUDP + } else { + err := fmt.Errorf("unsupported protocol") + r.log.Error(err, "could not select protocol", "portProtocol", servicePort.Protocol, "annotationProtocol", tunnelProto) + } + return serviceProto +} + +func (r *TunnelBindingReconciler) getConfigMapConfiguration() (*Configuration, error) { + // Read ConfigMap YAML + configStr, ok := r.configmap.Data[configmapKey] + if !ok { + err := fmt.Errorf("unable to find key `%s` in ConfigMap", configmapKey) + r.log.Error(err, "unable to find key in ConfigMap", "key", configmapKey) + return &Configuration{}, err + } + + config := &Configuration{} + if err := yaml.Unmarshal([]byte(configStr), config); err != nil { + r.log.Error(err, "unable to read config as YAML") + return &Configuration{}, err + } + return config, nil +} + +func (r *TunnelBindingReconciler) setConfigMapConfiguration(config *Configuration) error { + // Push updated changes + var configStr string + if configBytes, err := yaml.Marshal(config); err == nil { + configStr = string(configBytes) + } else { + r.log.Error(err, "unable to marshal config to ConfigMap", "key", configmapKey) + return err + } + r.configmap.Data[configmapKey] = configStr + if err := r.Update(r.ctx, r.configmap); err != nil { + r.log.Error(err, "unable to marshal config to ConfigMap", "key", configmapKey) + return err + } + + // Set checksum as annotation on Deployment, causing a restart of the Pods to take config + cfDeployment := &appsv1.Deployment{} + if err := r.Get(r.ctx, apitypes.NamespacedName{Name: r.configmap.Name, Namespace: r.configmap.Namespace}, cfDeployment); err != nil { + r.log.Error(err, "Error in getting deployment, failed to restart") + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedConfigure", "Failed to get Deployment") + return err + } + hash := md5.Sum([]byte(configStr)) + // Restart pods + r.Recorder.Event(r.binding, corev1.EventTypeNormal, "ApplyingConfig", "Applying ConfigMap to Deployment") + r.Recorder.Event(cfDeployment, corev1.EventTypeNormal, "ApplyingConfig", "Applying ConfigMap to Deployment") + if cfDeployment.Spec.Template.Annotations == nil { + cfDeployment.Spec.Template.Annotations = map[string]string{} + } + cfDeployment.Spec.Template.Annotations[tunnelConfigChecksum] = hex.EncodeToString(hash[:]) + if err := r.Update(r.ctx, cfDeployment); err != nil { + r.log.Error(err, "Failed to update Deployment for restart") + r.Recorder.Event(r.binding, corev1.EventTypeWarning, "FailedApplyingConfig", "Failed to apply ConfigMap to Deployment") + r.Recorder.Event(cfDeployment, corev1.EventTypeWarning, "FailedApplyingConfig", "Failed to apply ConfigMap to Deployment") + return err + } + r.log.Info("Restarted deployment") + r.Recorder.Event(r.binding, corev1.EventTypeNormal, "AppliedConfig", "ConfigMap applied to Deployment") + r.Recorder.Event(cfDeployment, corev1.EventTypeNormal, "AppliedConfig", "ConfigMap applied to Deployment") + return nil +} + +func (r *TunnelBindingReconciler) configureCloudflareDaemon() error { + var config *Configuration + var err error + + if config, err = r.getConfigMapConfiguration(); err != nil { + r.log.Error(err, "unable to get ConfigMap") + return err + } + + bindings, err := r.getRelevantTunnelBindings() + if err != nil { + r.log.Error(err, "unable to get tunnel bindings") + return err + } + + // Total number of ingresses is the number of services + 1 for the catchall ingress + // Set to 16 initially + finalIngresses := make([]UnvalidatedIngressRule, 0, 16) + for _, binding := range bindings { + for i, subject := range binding.Subjects { + targetService := "" + if subject.Spec.Target != "" { + targetService = subject.Spec.Target + } else { + targetService = binding.Status.Services[i].Target + } + + originRequest := OriginRequestConfig{} + originRequest.NoTLSVerify = &subject.Spec.NoTlsVerify + if caPool := subject.Spec.CaPool; caPool != "" { + caPath := fmt.Sprintf("/etc/cloudflared/certs/%s", caPool) + originRequest.CAPool = &caPath + } + + finalIngresses = append(finalIngresses, UnvalidatedIngressRule{ + Hostname: binding.Status.Services[i].Hostname, + Service: targetService, + OriginRequest: originRequest, + }) + } + } + + // Catchall ingress + finalIngresses = append(finalIngresses, UnvalidatedIngressRule{ + Service: r.fallbackTarget, + }) + + config.Ingress = finalIngresses + + return r.setConfigMapConfiguration(config) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *TunnelBindingReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.Recorder = mgr.GetEventRecorderFor("cloudflare-operator") + return ctrl.NewControllerManagedBy(mgr). + For(&networkingv1alpha1.TunnelBinding{}). + Complete(r) +} diff --git a/controllers/utils.go b/controllers/utils.go index 0431242..d5141df 100644 --- a/controllers/utils.go +++ b/controllers/utils.go @@ -12,20 +12,6 @@ import ( ) const ( - // Either tunnel or clustertunnel is mandatory - // Tunnel CR Name - tunnelAnnotation = "cfargotunnel.com/tunnel" - // ClusterTunnel CR Name - clusterTunnelAnnotation = "cfargotunnel.com/cluster-tunnel" - // FQDN to create a DNS entry for and route traffic from internet on, defaults to Service name + cloudflare domain - fqdnAnnotation = "cfargotunnel.com/fqdn" - // Target can be used to override the target to send traffic to. Ex: Can be used to point to an ingress rather than the service directly - targetAnnotation = "cfargotunnel.com/target" - // Setting this annotation skips TLS verification for this ingress. Content does not matter. Delete the annotation if not desired. https://github.com/cloudflare/cloudflared/issues/585 - noTlsVerifyAnnotation = "cfargotunnel.com/noTlsVerify" - // Name of the key containing the origin CA certificate in the Secret mentioned under the Tunnel.spec.originCaPool - caPoolAnnotation = "cfargotunnel.com/caPool" - // Protocol to use between cloudflared and the Service. tunnelProtoAnnotation = "cfargotunnel.com/proto" tunnelProtoHTTP = "http" @@ -45,14 +31,11 @@ const ( isClusterTunnelLabel = "cfargotunnel.com/is-cluster-tunnel" tunnelIdLabel = "cfargotunnel.com/id" tunnelNameLabel = "cfargotunnel.com/name" + tunnelKindLabel = "cfargotunnel.com/kind" tunnelAppLabel = "cfargotunnel.com/app" tunnelDomainLabel = "cfargotunnel.com/domain" tunnelFinalizer = "cfargotunnel.com/finalizer" - // Service labels - configHostnameLabel = "cfargotunnel.com/hostname" - configServiceLabel = "cfargotunnel.com/service" - configServiceLabelSplit = "." - configmapKey = "config.yaml" + configmapKey = "config.yaml" ) var tunnelValidProtoMap map[string]bool = map[string]bool{ diff --git a/main.go b/main.go index 4f06071..8c1642a 100644 --- a/main.go +++ b/main.go @@ -83,13 +83,11 @@ func main() { os.Exit(1) } - if err = (&controllers.ServiceReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Namespace: clusterResourceNamespace, - OverwriteUnmanaged: overwriteUnmanaged, + if err = (&controllers.TunnelBindingReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "Service") + setupLog.Error(err, "unable to create controller", "controller", "TunnelBinding") os.Exit(1) } if err = (&controllers.TunnelReconciler{