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:
- It shouldn’t cost me a penny
- It should integrate with Kubernetes, so I wouldn’t need to jump from dashboard to dashboard
- 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
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 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"`
}
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 encoded 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
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:
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
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
- Install Instances of Custom Resources:
Encode your STRATO DynDNS password for your domain or your STRATO DynDNS master password:
echo
…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)