DEV Community

Kyriakos Akriotis
Kyriakos Akriotis

Posted on

Strato DynDNS Controller for Kubernetes

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 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 is a framework for building Kubernetes APIs using custom resource definitions (CRDs). 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; 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
Enter fullscreen mode Exit fullscreen mode

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"`
}

Enter fullscreen mode Exit fullscreen mode

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 is a valid IPv4 address. 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"`
}
Enter fullscreen mode Exit fullscreen mode

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"`
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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=
Enter fullscreen mode Exit fullscreen mode

Remember, the value is encoded and NOT encrypted

Deploy both YAMLs to your cluster:

kubectl apply -f config/samples
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
 }
Enter fullscreen mode Exit fullscreen mode

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
 }
Enter fullscreen mode Exit fullscreen mode

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
 }
Enter fullscreen mode Exit fullscreen mode

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
  }
 }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
   }
  }
 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

make run
Enter fullscreen mode Exit fullscreen mode

Updating STRATO DynDNS for our domain was successful!

kubectl describe… of our Domain via K9S, after reconciliation and update

kubectl get domains — all-namespaces command as it looks in K9S

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:

GitHub logo akyriako / strato-dyndns

Strato DynDNS Controller updates your domains' DNS records on STRATO AG. A custom Controller is observing Domain CRs and syncing their desired state with STRATO DNS servers. THIS SOFTWARE IS IN NO WAY ASSOCIATED OR AFFILIATED WITH STRATO AG

Strato DynDNS Controller for Kubernetes

Strato DynDNS Controller updates your domains' DNS records on STRATO AG using Kubernetes Custom Resources and Controller

k9s domains list

Disclaimer

THIS SOFTWARE IS IN NO WAY ASSOCIATED OR AFFILIATED WITH STRATO AG

Description

A custom Controller is observing Domain CRs and syncing their desired state with STRATO DNS servers. You can either define explicitely an IPv4 address (Manual mode) or let the Controller discover you public IPv4 assigned to you by your ISP (Dynamic mode)

Getting Started

You’ll need a Kubernetes cluster to run against. You can use KIND or K3D to get a local cluster for testing, or run against a remote cluster Note: Your controller will automatically use the current context in your kubeconfig file (i.e. whatever cluster kubectl cluster-info shows).

Running on the cluster

  1. Install Instances of Custom Resources:

Encode your STRATO DynDNS password for your domain or your STRATO DynDNS master password:

echo
Enter fullscreen mode Exit fullscreen mode

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…

Top comments (0)