From 49324a66a9e474028558a094de08c5fa626da6ba Mon Sep 17 00:00:00 2001 From: Robin Scherrer <43877944+rxbn@users.noreply.github.com> Date: Tue, 15 Mar 2022 08:11:54 +0100 Subject: [PATCH] CO-58 BREAKING CHANGE: Implement IP sources (#67) --- api/v1beta1/ip_types.go | 31 +++- api/v1beta1/zz_generated.deepcopy.go | 33 +++- config/crd/bases/cf.containeroo.ch_ips.yaml | 55 ++++++- controllers/ip_controller.go | 157 +++++++++++++++----- docs/content/installation.md | 8 +- 5 files changed, 229 insertions(+), 55 deletions(-) diff --git a/api/v1beta1/ip_types.go b/api/v1beta1/ip_types.go index 5047ce5c..79bc4a0e 100644 --- a/api/v1beta1/ip_types.go +++ b/api/v1beta1/ip_types.go @@ -17,9 +17,36 @@ limitations under the License. package v1beta1 import ( + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +type IPSpecIPSources struct { + // URL of the IP source (e.g. https://api.hetzner.cloud/v1/servers/12345) + // +optional + URL string `json:"url,omitempty"` + // RequestBody to be sent to the URL + // +optional + RequestBody string `json:"requestBody,omitempty"` + // RequestHeaders to be sent to the URL + // +optional + RequestHeaders map[string]string `json:"requestHeaders,omitempty"` + // RequestHeadersSecretRef is a secret reference to the headers to be sent to the URL (e.g. for authentication) + // where the key is the header name and the value is the header value + // +optional + RequestHeadersSecretRef v1.SecretReference `json:"requestHeadersSecretRef,omitempty"` + // RequestMethod defines the HTTP method to be used + // +kubebuilder:validation:Enum=GET;POST;PUT;DELETE + // +kubebuilder:default=GET + RequestMethod string `json:"requestMethod,omitempty"` + // ResponseJSONPath defines the JSON path to the value to be used as IP + // +optional + ResponseJSONPath string `json:"responseJSONPath,omitempty"` + // ResponseTextRegex defines the regular expression to be used to extract the IP from the response + // +optional + ResponseTextRegex string `json:"responseTextRegex,omitempty"` +} + // IPSpec defines the desired state of IP type IPSpec struct { // IP address (omit if type is dynamic) @@ -33,9 +60,9 @@ type IPSpec struct { // Interval at which a dynamic IP should be checked // +optional Interval *metav1.Duration `json:"interval,omitempty"` - // List of services that return the public IP address + // IPSources can be configured to get an IP from an external source (e.g. an API or public IP echo service) // +optional - DynamicIPSources []string `json:"dynamicIPSources,omitempty"` + IPSources []IPSpecIPSources `json:"ipSources,omitempty"` } // IPStatus defines the observed state of IP diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 535e09f0..3f8a727e 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -336,10 +336,12 @@ func (in *IPSpec) DeepCopyInto(out *IPSpec) { *out = new(v1.Duration) **out = **in } - if in.DynamicIPSources != nil { - in, out := &in.DynamicIPSources, &out.DynamicIPSources - *out = make([]string, len(*in)) - copy(*out, *in) + if in.IPSources != nil { + in, out := &in.IPSources, &out.IPSources + *out = make([]IPSpecIPSources, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } } @@ -353,6 +355,29 @@ func (in *IPSpec) DeepCopy() *IPSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IPSpecIPSources) DeepCopyInto(out *IPSpecIPSources) { + *out = *in + if in.RequestHeaders != nil { + in, out := &in.RequestHeaders, &out.RequestHeaders + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + out.RequestHeadersSecretRef = in.RequestHeadersSecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IPSpecIPSources. +func (in *IPSpecIPSources) DeepCopy() *IPSpecIPSources { + if in == nil { + return nil + } + out := new(IPSpecIPSources) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IPStatus) DeepCopyInto(out *IPStatus) { *out = *in diff --git a/config/crd/bases/cf.containeroo.ch_ips.yaml b/config/crd/bases/cf.containeroo.ch_ips.yaml index 29897514..7df0f3a7 100644 --- a/config/crd/bases/cf.containeroo.ch_ips.yaml +++ b/config/crd/bases/cf.containeroo.ch_ips.yaml @@ -49,14 +49,59 @@ spec: address: description: IP address (omit if type is dynamic) type: string - dynamicIPSources: - description: List of services that return the public IP address - items: - type: string - type: array interval: description: Interval at which a dynamic IP should be checked type: string + ipSources: + description: IPSources can be configured to get an IP from an external + source (e.g. an API or public IP echo service) + items: + properties: + requestBody: + description: RequestBody to be sent to the URL + type: string + requestHeaders: + additionalProperties: + type: string + description: RequestHeaders to be sent to the URL + type: object + requestHeadersSecretRef: + description: RequestHeadersSecretRef is a secret reference to + the headers to be sent to the URL (e.g. for authentication) + where the key is the header name and the value is the header + value + properties: + name: + description: Name is unique within a namespace to reference + a secret resource. + type: string + namespace: + description: Namespace defines the space within which the + secret name must be unique. + type: string + type: object + requestMethod: + default: GET + description: RequestMethod defines the HTTP method to be used + enum: + - GET + - POST + - PUT + - DELETE + type: string + responseJSONPath: + description: ResponseJSONPath defines the JSON path to the value + to be used as IP + type: string + responseTextRegex: + description: ResponseTextRegex defines the regular expression + to be used to extract the IP from the response + type: string + url: + description: URL of the IP source (e.g. https://api.hetzner.cloud/v1/servers/12345) + type: string + type: object + type: array type: default: static description: IP address type (static or dynamic) diff --git a/controllers/ip_controller.go b/controllers/ip_controller.go index 4f024c30..1d18ba37 100644 --- a/controllers/ip_controller.go +++ b/controllers/ip_controller.go @@ -17,24 +17,30 @@ limitations under the License. package controllers import ( + "bytes" "context" + "encoding/json" "fmt" + cfv1beta1 "github.com/containeroo/cloudflare-operator/api/v1beta1" + "github.com/go-logr/logr" + "io" "io/ioutil" + v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/util/jsonpath" "math/rand" "net" "net/http" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "strings" - "time" - - "k8s.io/apimachinery/pkg/runtime" + "net/url" + "regexp" 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" - - cfv1beta1 "github.com/containeroo/cloudflare-operator/api/v1beta1" + "strings" + "time" ) // IPReconciler reconciles a IP object @@ -46,6 +52,7 @@ type IPReconciler struct { // +kubebuilder:rbac:groups=cf.containeroo.ch,resources=ips,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=cf.containeroo.ch,resources=ips/status,verbs=get;update;patch // +kubebuilder:rbac:groups=cf.containeroo.ch,resources=ips/finalizers,verbs=update +// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -115,25 +122,43 @@ func (r *IPReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Re return ctrl.Result{}, err } } - if instance.Spec.DynamicIPSources == nil { - instance.Spec.DynamicIPSources = append(instance.Spec.DynamicIPSources, "https://ifconfig.me/ip", "https://ipecho.net/plain", "https://myip.is/ip/", "https://checkip.amazonaws.com", "https://api.ipify.org") - err := r.Update(ctx, instance) + + if len(instance.Spec.IPSources) == 0 { + err := r.markFailed(instance, ctx, "IPSources is required for dynamic IPs") if err != nil { log.Error(err, "Failed to update IP resource") return ctrl.Result{}, err } + return ctrl.Result{}, err } - currentIP, err := getCurrentIP(instance.Spec.DynamicIPSources) - if err != nil { - err := r.markFailed(instance, ctx, err.Error()) + + if len(instance.Spec.IPSources) > 1 { + rand.Seed(time.Now().UnixNano()) + rand.Shuffle(len(instance.Spec.IPSources), func(i, j int) { + instance.Spec.IPSources[i], instance.Spec.IPSources[j] = instance.Spec.IPSources[j], instance.Spec.IPSources[i] + }) + } + + ipSourceError := true + for _, source := range instance.Spec.IPSources { + response, err := r.getIPSource(ctx, source, log) + if err != nil { + log.Error(err, "Failed to process source %s", source.URL) + continue + } + instance.Spec.Address = response + ipSourceError = false + break + } + + if ipSourceError { + err := r.markFailed(instance, ctx, "Failed to get IP from any source") if err != nil { log.Error(err, "Failed to update IP resource") return ctrl.Result{}, err } return ctrl.Result{RequeueAfter: time.Second * 30}, err } - - instance.Spec.Address = currentIP } if instance.Spec.Address != instance.Status.LastObservedIP { @@ -194,41 +219,93 @@ func (r *IPReconciler) SetupWithManager(mgr ctrl.Manager) error { Complete(r) } -// getCurrentIP returns the current public IP -func getCurrentIP(sources []string) (string, error) { - rand.Seed(time.Now().UnixNano()) - rand.Shuffle(len(sources), func(i, j int) { sources[i], sources[j] = sources[j], sources[i] }) +// getIPSource returns the IP gathered from the IPSource +func (r *IPReconciler) getIPSource(ctx context.Context, source cfv1beta1.IPSpecIPSources, log logr.Logger) (string, error) { + _, err := url.Parse(source.URL) + if err != nil { + return "", fmt.Errorf("failed to parse URL %s: %s", source.URL, err) + } + + httpClient := &http.Client{} + req, err := http.NewRequest(source.RequestMethod, source.URL, + io.Reader(bytes.NewBuffer([]byte(source.RequestBody)))) - var currentIP string - var ipError error - for _, provider := range sources { - resp, err := http.Get(provider) + for key, value := range source.RequestHeaders { + req.Header.Add(key, value) + } + + if source.RequestHeadersSecretRef.Name != "" { + secret := &v1.Secret{} + err := r.Get(ctx, client.ObjectKey{Name: source.RequestHeadersSecretRef.Name, + Namespace: source.RequestHeadersSecretRef.Namespace}, secret) if err != nil { - ipError = fmt.Errorf("failed to get IP from %s: %s", provider, err) - continue + return "", fmt.Errorf("failed to get secret %s: %s", source.RequestHeadersSecretRef.Name, err) } - if resp.StatusCode != 200 { - ipError = fmt.Errorf("failed to get IP from %s: %s", provider, resp.Status) - continue + for key, value := range secret.Data { + req.Header.Add(key, string(value)) } - ip, err := ioutil.ReadAll(resp.Body) + } + + httpClient.Timeout = time.Second * 30 + req.Header.Add("User-Agent", "cloudflare-operator") + + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to get IP from %s: %s", source.URL, err) + } + defer func(Body io.ReadCloser) { + err := Body.Close() if err != nil { - ipError = fmt.Errorf("failed to get IP from %s: %s", provider, err) - continue + log.Error(err, "Failed to close response body") } - currentIP = strings.TrimSpace(string(ip)) - if net.ParseIP(currentIP) == nil { - ipError = fmt.Errorf("ip %s is not a valid IP", currentIP) - continue + }(resp.Body) + + if resp.StatusCode != 200 { + return "", fmt.Errorf("failed to get IP from %s: %s", source.URL, resp.Status) + } + response, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to get IP from %s: %s", source.URL, err) + } + + var extractedIP string + extractedIP = strings.TrimSpace(string(response)) + if source.ResponseJSONPath != "" { + var jsonResponse map[string]interface{} + err := json.Unmarshal(response, &jsonResponse) + if err != nil { + return "", fmt.Errorf("failed to get IP from %s: %s", source.URL, err) + } + j := jsonpath.New("jsonpath") + buf := new(bytes.Buffer) + if err := j.Parse(source.ResponseJSONPath); err != nil { + return "", fmt.Errorf("failed to parse jsonpath %s: %s", source.ResponseJSONPath, err) } - ipError = nil - break + if err := j.Execute(buf, jsonResponse); err != nil { + return "", fmt.Errorf("failed to extract IP from %s: %s", source.URL, err) + } + + extractedIP = strings.TrimSpace(buf.String()) } - if ipError != nil { - return "", ipError + + if source.ResponseTextRegex != "" { + re, err := regexp.Compile(source.ResponseTextRegex) + if err != nil { + return "", fmt.Errorf("failed to compile regex %s: %s", source.ResponseTextRegex, err) + } + match := re.FindStringSubmatch(strings.TrimSpace(string(response))) + if len(match) == 0 { + return "", fmt.Errorf("failed to extract IP from %s: %s", source.URL, string(response)) + } + + extractedIP = strings.TrimSpace(match[len(match)-1]) + } + + if net.ParseIP(extractedIP) == nil { + return "", fmt.Errorf("failed to extract IP from %s: %s", source.URL, string(response)) } - return currentIP, nil + return extractedIP, nil } // markFailed marks the reconciled object as failed diff --git a/docs/content/installation.md b/docs/content/installation.md index 3645d175..f7a5cd60 100644 --- a/docs/content/installation.md +++ b/docs/content/installation.md @@ -32,7 +32,7 @@ cloudflare-operator requires a number of CRD resources, which must be installed Installing CRDs with kubectl: ```bash -kubectl apply -f https://github.com/containeroo/cloudflare-operator/releases/download/v0.1.3/crds.yaml +kubectl apply -f https://github.com/containeroo/cloudflare-operator/releases/download/v0.2.0/crds.yaml ``` 4. Install cloudflare-operator @@ -44,7 +44,7 @@ helm install \ cloudflare-operator containeroo/cloudflare-operator \ --namespace cloudflare-operator \ --create-namespace \ - --version v0.1.3 + --version v0.2.0 ``` A full list of available Helm values is on [cloudflare-operator’s ArtifactHub page](https://artifacthub.io/packages/helm/containeroo/cloudflare-operator). @@ -58,7 +58,7 @@ helm template \ cloudflare-operator containeroo/cloudflare-operator \ --namespace cloudflare-operator \ --create-namespace \ - --version v0.1.3 \ + --version v0.2.0 \ --set your.value=here ``` @@ -93,7 +93,7 @@ Finally, delete the cloudflare-operator CustomResourceDefinitions using the link This command will also remove installed cloudflare-operator objects. All cloudflare-operator resources will be removed by Kubernetes' garbage collector. ```bash -kubectl delete -f https://github.com/containeroo/cloudflare-operator/releases/download/v0.1.3/crds.yaml +kubectl delete -f https://github.com/containeroo/cloudflare-operator/releases/download/v0.2.0/crds.yaml ``` ## Namespace Stuck in Terminating State