Kubernetes for geeks: Creating your own Kubernetes Operator


,As I promised in Kubernetes tip of the day – external-dns, here is the writeup of my automations of firewall openings. As the methods of configuration, and features of, firewalls are more varied than DNS, I quickly realized that this needed to be something built explicitly for Unifi. I had a brief look at The terraform provider for Unifi. While it worked, it wouldn’t be as integrated with my Kubernetes system. There’s a Terraform Operator for Kubernetes out there, but after briefly playing around with it, I realized it wasn’t feature complete, i.e. it lacked support for the newer zone based firewalls in Unifi, that I have converted to.

This means I needed to go the Kubernetes Operator way. There existed one small project in the beginning phase, but it looked to be not so active lately – so I needed to create my own. Experimenting with Kubernetes Operators was sort of on my bucket list anyhow, so creating one from scratch was a welcoming challenge!

There exists a few frameworks for creating operators for Kubernetes. After a brief look at Operator SDK and Kopf, I landed on Kubebuilder. This is a Go Framework for creating operators, and it looked reasonably easy to work with.

Note: There exists a few tutorials for Kubebuilder, most prominently on https://book.kubebuilder.io/, and it explains concepts more in depth than I will in this blog post, so please refer to that for more indepth explanations than I give here. In this blog post, I will just explain some main concept and ideas, but you’ll have to look at my code and some other tutorials to get the complete picture.

Getting started with Kubebuilder

First step to get started with Kubebuilder is to install it.

curl -L -o kubebuilder https://github.com/kubernetes-sigs/kubebuilder/releases/latest/download/kubebuilder_linux_amd64
chmod +x kubebuilder
sudo mv kubebuilder /usr/local/bin/

Then, to get started with a new operator, you’ll do:

mkdir unifi-network-operator && cd unifi-network-operator
kubebuilder init --domain=engen.priv.no --repo=github.com/vegardengen/unifi-network-operator

This doesn’t actually create a github repo, but it’s a pretty strong suggestion it’s a good idea to do so, so I did set this up as a personal github repo too. When working with things of some complexity, it’s always a good idea to work in steps, and create checkpoints along the road as you complete functionality, allowing you to test and to run earlier versions of the software. This holds true even if you are the only developer, as I am (so far).

Part of the reason I decided to go with Kubebuilder was that there existed a pretty decent API already. This is the API that the terraform provider is using to communicating with Unifi devices. It did, however, lack some newer functionality like network zones and zone based firewalls, so I knew I’d have to improve that.

To work on that, I created my own fork of it, but eventually I want to contribute upstream of course.

Unifi Logon and a session handler

To not having to log on each time I do an API request to the Unifi device, I have created a unifi object that wraps the API. It will hold on to a logon. For now, I have created a Reauthenticate() method that I call before I start doing something towards Unifi, but this is likely an area of improvement.

I did borrow this part from the not-so-active project I was look it at first, but hav extended it to fulfill my needs. The source lives here.

In my main method, I just call:

// Unifi client
setupLog.Info("Setting up UniFi client")
unifiClient, err := unifi.CreateUnifiClient()
if err != nil {
setupLog.Error(err, "failed to create UniFi client")
os.Exit(1)
}
setupLog.Info("Finished Setting up UniFi client")

I am also adding support for a configmap with https://github.com/vegardengen/unifi-network-operator/blob/master/internal/config/config.go, which I initialize in my main.go:

configLoader := config.NewConfigLoader(mgr.GetClient())

My main method lives here. Much of this is actually scaffolding done by kubebuilder, and not all of it is actually used yet. There’s things I haven’t gotten to, like validating or mutating webhooks, that I might find useful at some time.

Since I have my two objects initalized at setup, I need to pass them into the reconciler functions when I call them from the main loop. Kubebuilder scaffolds most of this, but you can extend it:

        if err = (&controller.FirewallGroupReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
UnifiClient: unifiClient,
ConfigLoader: configLoader,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "FirewallGroup")
os.Exit(1)
}

The Operator in itself runs as a Controller. A controller will basically watch Kubernetes for events that you configure and have it run your functionality when those events happen. Probably the most used pattern is using Custom Resources. Your controller will watch for changes for those custom resources, and run your reconcile functions when something happens to them. But there are also other possibilties – you can watch for other resources, perhaps resources with a certain annotation, or you can run it periodically based on time. In my operator, I am using custom resources, but I am also watching services for annotations.

Creating an API

An operator can be a bit hard to grasp, so let’s instead create an API. I started off with creating custom resources for firewall groups, which is a pretty central concept in what I want to achive

$ kubectl create api --group unifi --version v1beta1 --kind FirewallGoup --controller --resurce

This will scaffold type definitions for the custom resource in api/v1beta1/firewallgroup_type.go and a controller in internal/controller/firewallgroup_controller.go.

In the API specification, you need to define your field specifications yourself, but kubebuilder scaffolds it so that it all fits together, compiles and runs.

In the controller, the main functionality is in the FirewallGroupReconciler, which starts like this:

func (r *FirewallGroupReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
log := log.FromContext(ctx)

cfg, err := r.ConfigLoader.GetConfig(ctx, "unifi-operator-config")
if err != nil {
return ctrl.Result{}, err
}

defaultNs := cfg.Data["defaultNamespace"]
log.Info(defaultNs)

var firewallGroup unifiv1beta1.FirewallGroup
if err := r.Get(ctx, req.NamespacedName, &firewallGroup); err != nil {
return reconcile.Result{}, client.IgnoreNotFound(err)
}

The ctx context is passed in from main.go, and will contain the config loader and the unifi object. As I write this, I realize it’s obviously not good to hardode the name of the configmap in all controllers, so I’ll likely change this a bit with specifying it in main.go instead.

In the r.Get, I get the custom resource from the name that the main.go has fed it, and am ready to act on it. When it’s created in Kubernetes, nothing has of course changed in your Unifi device until your controller have done its job.

There doesn’t need to be a 1-to-1 relationship between Unifi resources and Kubernetes resources, I am actually creating up four Unifi firewall groups per Kubernetes FirewallGroup: IPV4 addresses, IPv6 addresses, TCP port and UDP ports, which roughly matches what can be in a loadbalancer service in Kubernetes. These Unifi objects can then be used in Firewall policies in Unifi, which can either be created manually in the user interface, or better yet, with my FirewallPolicy API (custom resource), which will handle creating firewall policies.

The firewall policies in Kubernetes also need to know about networks and firewall zones, so I have created a FirewallZone API and a Networkconfiguration API. Since I don’t at first need to create or change any of those, I can live with doing that in the Unifi Console for now, I am just fetching the information from Unifi for these and storing it in custom resources in Kubernetes, doing a synchronization every 10 minutes.

Last, I have created a PortFordward API, which allows me to do simple portforwarding with inbound NAT.

So, on to the details. A firewallGroup might be defined like this:

apiVersion: unifi.engen.priv.no/v1beta1
kind: FirewallGroup
metadata:
labels:
app.kubernetes.io/name: unifi-network-operator
name: firewallgroup-sample
namespace: default
spec:
name: Test
matchServicesInAllNamespaces: true
manualAddresses:
- 192.168.1.155
- 192.168.1.154
- 2001:db8:393:f101::1:0
manualServices:
- loadbalancer_service

This will create IPv4 and IPV6 address groups from the loadbalancer_service, and TCP and UDP port groups for the service, should they exist. It will add the manually specified addresses. My use case is mostly to get it automatically from services, but I have started off with adding some support for specifying it manually. I could want to manage firewall groups for things that haven’t been created with Kubernetes, for example.

In my use case, I need to be able to specify openings from zones and networks towards the objects, while specifying to/from specific IP addresses are less useful to me, so I have at first limited functionality to my usecase. So, I have a network:

$ kubectl get networkconfigurations.unifi.engen.priv.no klient -n unifinetwork -o yaml
apiVersion: unifi.engen.priv.no/v1beta1
kind: Networkconfiguration
metadata:
creationTimestamp: "2025-04-15T05:08:16Z"
generation: 1
name: klient
namespace: unifinetwork
resourceVersion: "12264211"
uid: 1edcf11a-82ba-4987-9a0d-984b7adabc38
spec:
_id: 676467b0be9bff431d2e446a
ip_subnet: 192.168.6.1/24
ipv6_interface_type: static
ipv6_pd_auto_prefixid_enabled: true
ipv6_ra_enabled: true
ipv6_setting_preference: auto
ipv6_subnet: 2a01:db8:393:f106::1/64
name: Klient
networkgroup: LAN
purpose: corporate
setting_preference: manual
vlan: 6
vlan_enabled: true

And I have some zones, for example:

kubectl get firewallzones.unifi.engen.priv.no -n unifinetwork server -o yaml  
apiVersion: unifi.engen.priv.no/v1beta1
kind: FirewallZone
metadata:
  creationTimestamp: "2025-04-14T13:02:20Z"
  generation: 1
  name: server
  namespace: unifinetwork
  resourceVersion: "11234085"
  uid: e3716497-4f84-4a94-85ec-cd0c3a44d121
spec:
  _id: 67686b1786dbcc4ec1bfd50e
  name: Server
  network_ids:
  - 676466f2be9bff431d2e4447
  - 6764679dbe9bff431d2e4462
  - 6791e244f4dfc42a59be89f1

These two are autocreated from Unifi. I could possibly work on getting something more readable in a Statusfield for the zones, but the IDs are really the interesting bits for the API calls. I have gathered some more information in the custom resources, like IPV4 and IPV6 subjects, as that might come in handy. One extention idea I have is to make it possible to configure IP addresses in annotations of Kubernetes resources with the network name instead of the specific subnet, and have the actual IP addresses updated whenever the network around it changes.

With a firewall group in place and a zone and a network, I can create a firewall-policy

kind: FirewallRule
metadata:
labels:
app.kubernetes.io/name: unifi-network-operator
app.kubernetes.io/managed-by: kustomize
name: test-opening
spec:
name: "Test-opening"
source:
from_zones:
- name: external
from_networks:
- name: server

destination:
firewall_groups:
- name: "firewallgroup-sample"
namespace: default

As for the firewall groups, this doesn’t necessarily correspond to one firewall policy in Unifi – you can’t mix and match everything in a Unifi rule. So this will amount to up to 8 rules: A TCP- and an UDP- rule for both IPV4 and IPV6, for both the external zone and the server network.

Of course the controller logic can be quite complex. It will have to take into account every possible change you might want to do, and then update/delete Unifi rules based on these changes.

There’s one more thing worth mentioning: Finalizers.

A finalizer is basically a flag you set on a resource when a resource is created, in your reconcile function:

const firewallGroupFinalizer = "finalizer.unifi.engen.priv.no/firewallgroup"
....
if !controllerutil.ContainsFinalizer(&firewallGroup, firewallGroupFinalizer) {
controllerutil.AddFinalizer(&firewallGroup, firewallGroupFinalizer)
if err := r.Update(ctx, &firewallGroup); err != nil {
return ctrl.Result{}, err
}
}

When Kubernetes deletes a resource, it will signal your reconciler function before actually doing it, and setting a timestamp for when it was deleted. Your reconciler function will check for this timestamp, and if it is set, it should delete all the Unifi resources. But what happens if there is some error?

Well, your reconciler function should only delete the finalizer flag when it has managed to delete all the resources, and then and only then will Kubernetes delete the resource. Kubernetes will never delete a resource where the finalizer flag is still set. This means you get another shot at deleting it, either in a periodic sync or if you return this upon an error:

return ctrl.Result{RequeueAfter: 10 * time.Minute}, err

This basically tells the main loop to requeue the request after 10 minutes, and you’ll get another shot at cleanng up. Once all the resources are cleaned up, you’ll delete the finalizer:

   controllerutil.RemoveFinalizer(&firewallGroup, firewallGroupFinalizer)
if err := r.Update(ctx, &firewallGroup); err != nil {
return ctrl.Result{}, err
}

But hold on, how do you track the state on all of this? There might be better options, but I use the Status field in the resource:

firewall_group_result, err := r.UnifiClient.Client.CreateFirewallGroup(context.Background(), r.UnifiClient.SiteID, &firewall_group)
log.Info(fmt.Sprintf("%+v", firewall_group_result))
if err != nil {
log.Error(err, "Could not create firewall group")
return reconcile.Result{}, err
} else {
firewall_group = *firewall_group_result
}

log.Info(fmt.Sprintf("ID and name: %s %s", firewall_group.ID, firewall_group.Name))
log.Info(fmt.Sprintf("%+v", firewall_group))
firewallGroup.Status.ResourcesManaged.IPV4Object.ID = firewall_group.ID
firewallGroup.Status.ResourcesManaged.IPV4Object.Name = firewall_group.Name

if err := r.Status().Update(ctx, &firewallGroup); err != nil {
log.Error(err, "unable to update FirewallGroup status")
return reconcile.Result{}, err
}

Upon deleting and modifying, I just update these status fields, and once there is no managed resources, I can delete the finalizer and let Kubernetes do the final deletion of the custom resource.

I’m not going to explain my code for all of this, this is just an idea of what you can do with operators. My code is in my repo, and while it’s hacked together by a single hobbyist, you might still learn something from it.

This is just the beginning of my operator, but it is complete enough that I’m now using it for my personal environment. With Operators, there’s a ton of possibilities that open, so who knows…maybe it will grow into a proper open source project? Give me a word – or even better,a pull request – if you have any improvement ideas!

, , , ,

Legg igjen en kommentar

Din e-postadresse vil ikke bli publisert. Obligatoriske felt er merket med *

This site uses Akismet to reduce spam. Learn how your comment data is processed.