Strato DynDNS Controller for Kubernetes
Tags: strato, kubebuilder, dyndns, kubernetes
Published: 2023-02-15 08:01:39

## Introduction
For some years, I have gradually moved my home lab’s workloads from virtual machines to Docker containers, and eventually, in Kubernetes, I am looking for an efficient solution to the problem of keeping my domains’ DNS records in sync with the dynamic IP address assigned by my ISP.

I used [DirectUpdate](https://www.directupdate.net/) for a long time, which, although it costs ~25EUR and is really worth its money, comes with a downside: it runs only on Windows, and after a point wasting so many resources only for a simple DynDNS-updater client was an overkill. So I started looking into other solutions like Cloudflare, DigitalOcean, No-IP DynDNS, and others. But I was still not satisfied. I was pretty bored jumping across different dashboards, providers, and panels to periodically have an overview of the DNS records of my domains so I make sure my reverse proxy and my Kubernetes Ingress were not in trouble. I decided I needed my very own solution (why not?) that had to fulfil three criteria:

1. It shouldn’t cost me a penny
2. It should integrate with Kubernetes, so I wouldn’t need to jump from dashboard to dashboard
3. It should be fully autonomous, self-healing, and periodic

The obvious solution meeting all those criteria were to go for a custom Kubernetes controller with custom CRDs, and what better tool than Kubebuilder to start with? 

[Kubebuilder](https://github.com/kubernetes-sigs/kubebuilder) is a framework for building Kubernetes APIs using [custom resource definitions (CRDs)](https://kubernetes.io/docs/tasks/access-kubernetes-api/extend-api-custom-resource-definitions). It does all the heavy lifting for us, building the project structure and scaffolding the basic components needed to code, build and deploy our artifacts.

In a nutshell, the story is pretty simple and consists mainly of two parts: You extend Kubernetes control plane by expressing your artifacts as custom resource definitions (CRDs), and you create a custom controller which, either periodically or by responding to changes on this CRs, tries to adjust the actual observed state of those CRs, so it matches with the desired one.

In our case, this translates to a CRD, which will be called Domain and practically is a representation of the domain (or subdomain) you want to periodically update its DNS records on [STRATO](https://www.strato.de/); and a custom Controller that takes over the Sisyphean task of reconciling the CRs states and the propagation of the IP changes to STRATO DynDNS endpoints.

Additionally, we will need a Secret, but its role is pure supplementary as it contributes only as a safekeeper for the credentials required to issue requests against STRATO DynDNS endpoints.

> Why STRATO in the first place? Simply because this is where I have registered all my domains.

> Strato AG is a German internet hosting service provider with headquarters in Berlin. It is a subsidiary of United Internet AG that bought it from Deutsche Telekom AG back in 2016. Strato operates mainly in Germany, the Netherlands, Spain, France, UK and Sweden and serves more than 2 million customers.

## Custom Resource Definition (CRD)

The `Domain` consists of two properties (mainly — what is `TypeMeta` and `ObjectMeta` you can look it up in Kubebuilder book) which we have briefly discussed earlier. `Spec`, type of `DomainSpec`, is the desired state and `Status`, type of `DomainStatus` is the actual (observed) state of our Domain Customer Resource (CR) at any given moment.

If you notice, the struct is decorated with a bunch of attributes that are prefixed with `+kubebuilder:printcolumn` and they dictating which columns will be displayed when we will inquire about an object or a list of objects of that `Kind` for example, with `kubectl`:

```
kubectl get domains --all-namespaces
```

The value of each column can either derive from the desired state (`.spec.XXX`) or from the observed state (`.status.XXX`).

```
// Domain is the Schema for the domains API
// +kubebuilder:printcolumn:name="Fqdn",type=string,JSONPath=`.spec.fqdn`
// +kubebuilder:printcolumn:name="IP Address",type=string,JSONPath=`.status.ipAddress`
// +kubebuilder:printcolumn:name="Mode",type=string,JSONPath=`.status.mode`
// +kubebuilder:printcolumn:name="Successful",type=boolean,JSONPath=`.status.lastResult`
// +kubebuilder:printcolumn:name="Last Run",type=string,JSONPath=`.status.lastLoop`
// +kubebuilder:printcolumn:name="Enabled",type=boolean,JSONPath=`.spec.enabled`
type Domain struct {
 metav1.TypeMeta   `json:",inline"`
 metav1.ObjectMeta `json:"metadata,omitempty"`

 Spec   DomainSpec   `json:"spec,omitempty"`
 Status DomainStatus `json:"status,omitempty"`
}

```

The desired state, `DomainSpec`, has five properties. `Fqdn` that is the fully qualified name of your domain or subdomain you want to track. The `IpAddress` is optional, and if it is set, then we implicitly enforce the manual mode, and when it is empty, our Controller will discover the current IP address assigned to us by our ISP (dynamic mode). `Enabled` is something that needs no further explanation. `IntervalInMinutes` is defining the intervals between two consecutive reconciliation loops and Password is a reference to the Secret resource that will hold the password for our Strato DynDNS service.

Those properties can also be decorated with attributes that enforce or dictate various behavioral aspects of the object. For instance, we enforce validation via a regular expression for `Fqdn` so we make sure it is a valid domain name and for `IpAddress` that <u>is a valid IPv4 address</u>. For `IntervalInMinutes` we want to ensure that it cannot be more frequent than five minutes, and in case of absence, that would be the default value assigned automatically when deployed.

```
// DomainSpec defines the desired state of Domain
type DomainSpec struct {
 // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
 // Important: Run "make" to regenerate code after modifying this file

 // +kubebuilder:validation:Required
 // +kubebuilder:validation:Pattern:=`^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$`
 Fqdn string `json:"fqdn"`

 // +optional
 // +kubebuilder:validation:Required
 // +kubebuilder:validation:Pattern:=`^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$`
 IpAddress *string `json:"ipAddress,omitempty"`

 // +optional
 // +kubebuilder:default:=true
 // +kubebuilder:validation:Type=boolean
 Enabled bool `json:"enabled,omitempty"`

 // +optional
 // +kubebuilder:default=5
 // +kubebuilder:validation:Minimum=5
 IntervalInMinutes *int32 `json:"interval,omitempty"`

 Password *v1.SecretReference `json:"password"`
}
```

The observed state, `DomainStatus`, is way simpler. Their values are calculated in every reconciliation loop either based on the output of the reconciliation (`IpAddress` the IP that was updated in STRATO records, `LastReconciliationLoop` when the last update attempt took place and `LastReconciliationResult` whether the last attempt was successful or not) or on the current desired state that is processed in that loop (`Enabled` or `Mode`).

```
// DomainStatus defines the observed state of Domain
type DomainStatus struct {
 // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
 // Important: Run "make" to regenerate code after modifying this file
 Enabled                  bool         `json:"enabled,omitempty"`
 IpAddress                string       `json:"ipAddress,omitempty"`
 Mode                     string       `json:"mode,omitempty"`
 LastReconciliationLoop   *metav1.Time `json:"lastLoop,omitempty"`
 LastReconciliationResult *bool        `json:"lastResult,omitempty"`
}
```

When we are done coding those structs, you can find them under `/api/v1alpha1/domain_types.go`, we can then update the rest of our project with Kubebuilder and install them as CRDs to our development cluster.

```
make manifests
make install
```

Making manifests will create, among others, some sample YAML files based on the structs we coded earlier under `/config/samples`.

```
apiVersion: dyndns.contrib.strato.com/v1alpha1
kind: Domain
metadata:
  name: www-example-de
spec:
  fqdn: "www.example.de"
  enabled: true
  interval: 5
  password:
    name: strato-dyndns-password
```

Change the values, so they point to a domain, or subdomain, of yours.

## Secret

Manifests will not create a scaffold for the `Secret` — it is not a CRD but a core resource of Kubernetes. We have to create it ourselves. STRATO DynDNS endpoints require a username and a password, where username is always the domain or subdomain itself, and password is the password you created when you activated DynDNS for this (sub)domain or the master-password for DynDNS of your STRATO customer account. You choose which one to use, but before proceeding with the YAML of Secret we need to **encode** this password in **base64**:

```
echo -n "password" | base64
```

Create an empty YAML file under `/config/samples` and as name declare the password.name you used in Domain YAML, and as `data.password` the encoded value of the password you just generated.

```
apiVersion: v1
kind: Secret
metadata:
  name: strato-dyndns-password
type: Opaque
data:
  password: cGFzc3dvcmQ=
```

> Remember, the value is <u>encoded</u> and NOT **encrypted**

Deploy both YAMLs to your cluster:

```
kubectl apply -f config/samples
```
 
If everything worked out, you could see a `www-example-de`, if you requested to get the domains, and strato-dyndns-password if you requested the secrets in your cluster:

```
kubectl get domains --all-namespaces
kubectl get secrets --all-namespaces
```

## Custom Controller

As mentioned before, it’s out of the scope of this article to explain to you how **a** Custom Controller works — so I will stick to how **this** Controller works. Do your prep if that is a new topic for you.

First, we want to ensure that our Controller has adequate permissions to watch or update various resources. We want, of course, to have full control on `Domains`, but we want additionally to be able to get and observe `Secrets` and to create or update Kubernetes Events. We manage this by the `+kubebuilder:rbac` attribute.

```
//+kubebuilder:rbac:groups=dyndns.contrib.strato.com,resources=domains,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=dyndns.contrib.strato.com,resources=domains/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=dyndns.contrib.strato.com,resources=domains/finalizers,verbs=update
//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch
//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;
```

When you issue make manifests a bunch of YAML files, among others, will be created under `/config/rbac` based on those attributes.

The flow of our reconciliation loop is simple. Get the `Domain`, if that fails, terminate the loop permanently and **don’t requeue**.

```
var domain dyndnsv1alpha1.Domain
 if err := r.Get(ctx, req.NamespacedName, &domain); err != nil {
  if apierrors.IsNotFound(err) {
   logger.Error(err, "finding Domain failed")
   return ctrl.Result{}, nil
  }

  logger.Error(err, "fetching Domain failed")
  return ctrl.Result{}, err
 }
```

Check the desired status (`.Spec.Enabled`), if it is not enabled, update the status of the CR in Kubernetes (`.Status.Enabled`) accordingly and exit the reconciliation loop permanently, if enabled is false.

```
// update status and break reconciliation loop if is not enabled
 if !domain.Spec.Enabled {
  domainCopy.Status.Enabled = domain.Spec.Enabled
  // update the status of the CR
  if err := r.Status().Update(ctx, &domainCopy); err != nil {
   logger.Error(err, "updating status failed") //

   requeueAfterUpdateStatusFailure := time.Now().Add(time.Second * time.Duration(15))
   return ctrl.Result{RequeueAfter: time.Until(requeueAfterUpdateStatusFailure)}, err
  }

  return ctrl.Result{}, nil
 }
```

Ensure an acceptable interval is in place and decide if the desired state dictates us to proceed in `Manual` or `Dynamic` mode.

```
// define interval between reconciliation loops
 interval := defaultIntervalInMinutes
 if domain.Spec.IntervalInMinutes != nil {
  interval = *domain.Spec.IntervalInMinutes
 }

 // change mode to manual in presence of an explicit ip address in specs
 if domain.Spec.IpAddress != nil {
  mode = Manual
 }
```

If the reconciliation loop kicked in earlier than the interval defines (maybe an external change in the YAML files or an internal Kubernetes event), make sure you skip this turn and wait until its next scheduled execution.

> Otherwise, we might create an overflow of frequent requests to STRATO and we don’t want to do that because we will either hit the rate limiter of Kubernetes or of STRATO itself, and last thing you want is to be benched for a period of time due to abusing their API.

```
// is reconciliation loop started too soon because of an external event?
 if domain.Status.LastReconciliationLoop != nil && mode == Dynamic {
  if time.Since(domain.Status.LastReconciliationLoop.Time) < (time.Minute*time.Duration(interval)) && wasSuccess {
   sinceLastRunDuration := time.Since(domain.Status.LastReconciliationLoop.Time)
   intervalDuration := time.Minute * time.Duration(interval)
   requeueAfter := intervalDuration - sinceLastRunDuration

   logger.Info("skipped turn", "sinceLastRun", sinceLastRunDuration, "requeueAfter", requeueAfter)
   return ctrl.Result{RequeueAfter: time.Until(time.Now().Add(requeueAfter))}, nil
  }
 }
```

If mode is Manual our IP address is the one defined in desired state (`.Spec.IpAddress`). Otherwise, we discover our external IP address, the one our ISP assigned to our router.

```
currentIpAddress := domain.Status.IpAddress
 var newIpAddress *string

 switch mode {
 case Dynamic:
  externalIpAddress, err := r.getExternalIpAddress()
  if err != nil {
   logger.Error(err, "retrieving external ip failed")
   r.Recorder.Eventf(instance, v1core.EventTypeWarning, "RetrieveExternalIpFailed", err.Error())

   success = false
  } else {
   newIpAddress = externalIpAddress
  }
 case Manual:
  newIpAddress = domain.Spec.IpAddress
}
```
If the new desired state of our IP address matches the observed state, do nothing — remember, play nice, and don’t abuse their endpoints for no reason. If not, get the Secret and retrieve your password and propagate the desired changes to STRATO DNS servers.

```
// proceed to update Strato DynDNS only if a valid IP address was found
 if newIpAddress != nil {
  // if last reconciliation loop was successful and there is no ip change skip the loop
  if *newIpAddress == currentIpAddress && wasSuccess {
   logger.Info("updating dyndns skipped, ip is up-to-date", "ipAddress", currentIpAddress, "mode", mode.String())
   r.Recorder.Event(instance, v1core.EventTypeNormal, "DynDnsUpdateSkipped", "updating skipped, ip is up-to-date")
  } else {
   logger.Info("updating dyndns", "ipAddress", newIpAddress, "mode", mode.String())

   passwordRef := domain.Spec.Password
   objectKey := client.ObjectKey{
    Namespace: req.Namespace,
    Name:      passwordRef.Name,
   }

   var secret v1core.Secret
   if err := r.Get(ctx, objectKey, &secret); err != nil {
    if apierrors.IsNotFound(err) {
     logger.Error(err, "finding Secret failed")
     return ctrl.Result{}, nil
    }

    logger.Error(err, "fetching Secret failed")
    return ctrl.Result{}, err
   }

   password := string(secret.Data["password"])
   if err := r.updateDns(domain.Spec.Fqdn, domain.Spec.Fqdn, password, *newIpAddress); err != nil {
    logger.Error(err, "updating dyndns failed")
    r.Recorder.Eventf(instance, v1core.EventTypeWarning, "DynDnsUpdateFailed", err.Error())

    success = false
   } else {
    logger.Info("updating dyndns completed")
    r.Recorder.Eventf(instance, v1core.EventTypeNormal, "DynDnsUpdateCompleted", "updating dyndns completed")

    success = true
   }
  }
 }
```

Updating STRATO DynDNS is easy as pie. You need to issue a **GET** request to do, and that looks like this:

```
https://%s:%s@dyndns.strato.com/nic/update?hostname=%s&myip=%s
```

The first two parameters are `username` and `password`, respectively, `hostname` is your (sub)domain name, and `myip` is the new IP address you want to update the DNS records.

Lastly, we update the status of our CR, and we reschedule the following:

```
// update the status of the CR no matter what, but assign a new IP address in the status
 // only when Strato DynDNS update was successful
 if success {
  domainCopy.Status.IpAddress = *newIpAddress
 }

 domainCopy.Status.LastReconciliationLoop = &v1meta.Time{Time: time.Now()}
 domainCopy.Status.LastReconciliationResult = &success
 domainCopy.Status.Enabled = domain.Spec.Enabled
 domainCopy.Status.Mode = mode.String()

 // update the status of the CR
 if err := r.Status().Update(ctx, &domainCopy); err != nil {
  logger.Error(err, "updating status failed") //

  requeueAfterUpdateStatusFailure := time.Now().Add(time.Second * time.Duration(15))
  return ctrl.Result{RequeueAfter: time.Until(requeueAfterUpdateStatusFailure)}, err
 }

 // if Mode is Manual, and we updated DynDNS with success, then we don't requeue, and we will rely only on
 // events that will be triggered externally from YAML updates of the CR
 if mode == Manual && success {
  return ctrl.Result{}, nil
 }

 requeueAfter := time.Now().Add(time.Minute * time.Duration(interval))

 logger.Info("requeue", "nextRun", fmt.Sprintf("%s", requeueAfter.Local().Format(time.RFC822)))
 logger.V(10).Info("finished dyndns update")

 return ctrl.Result{RequeueAfter: time.Until(requeueAfter)}, nil
```

Now, we are ready to try our controller (externally without deploying it to a cluster):

```
make run
```

![Updating STRATO DynDNS for our domain was successful!](https://miro.medium.com/v2/resize:fit:4800/format:webp/1*XsRleCSS4vy-cMSybDvnzg.png)

![kubectl describe… of our Domain via K9S, after reconciliation and update](https://miro.medium.com/v2/resize:fit:4800/format:webp/1*oRwzb6n0qejX4CinrQs9Lw.png)

![kubectl get domains — all-namespaces command as it looks in K9S](https://miro.medium.com/v2/resize:fit:4800/format:webp/1*4-IokDi-C4oUtudAD-t-eQ.png)


## Summary

You can find the whole source code in GitHub along with instructions on how to build this as a container and deploy it to your cluster:

{% embed https://github.com/akyriako/strato-dyndns %}

Try out this controller, and feel free to fork the repo and extend it as you see fit, or drop your feedback in the comments below or on Github. Till the next time…