Working manually configured firewall group entries

This commit is contained in:
2025-04-10 00:14:11 +02:00
parent e4c5b5fdd7
commit 2a4d03cbfe
8 changed files with 627 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
/*
Copyright 2025 Vegard Engen.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package v1beta1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
// FirewallGroupSpec defines the desired state of FirewallGroup.
type FirewallGroupSpec struct {
// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
// Important: Run "make" to regenerate code after modifying this file
// Foo is an example field of FirewallGroup. Edit firewallgroup_types.go to remove/update
// Description is a human-readable explanation for the object
Name string `json:"name,omitempty"`
MatchServicesInAllNamespaces bool `json:"matchServicesInAllNamespaces,omitempty"`
// ManualAddresses is a list of manual IPs or CIDRs (IPv4 or IPv6)
// +optional
ManualAddresses []string `json:"manualAddresses,omitempty"`
// AutoIncludeSelector defines which services to extract addresses from
// +optional
AutoIncludeSelector *metav1.LabelSelector `json:"autoIncludeSelector,omitempty"`
// AddressType can be "ip", "cidr", or "both"
// +kubebuilder:validation:Enum=ip;cidr;both
// +optional
AddressType string `json:"addressType,omitempty"`
}
// FirewallGroupStatus defines the observed state of FirewallGroup.
type FirewallGroupStatus struct {
// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
// Important: Run "make" to regenerate code after modifying this file
ResolvedAddresses []string `json:"resolvedAddresses,omitempty"`
// SyncedWithUnifi indicates whether the addresses are successfully pushed
// +optional
SyncedWithUnifi bool `json:"syncedWithUnifi,omitempty"`
// LastSyncTime is the last time the object was synced
// +optional
LastSyncTime *metav1.Time `json:"lastSyncTime,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// FirewallGroup is the Schema for the firewallgroups API.
type FirewallGroup struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec FirewallGroupSpec `json:"spec,omitempty"`
Status FirewallGroupStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// FirewallGroupList contains a list of FirewallGroup.
type FirewallGroupList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []FirewallGroup `json:"items"`
}
func init() {
SchemeBuilder.Register(&FirewallGroup{}, &FirewallGroupList{})
}

View File

@@ -21,9 +21,118 @@ limitations under the License.
package v1beta1
import (
"k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FirewallGroup) DeepCopyInto(out *FirewallGroup) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirewallGroup.
func (in *FirewallGroup) DeepCopy() *FirewallGroup {
if in == nil {
return nil
}
out := new(FirewallGroup)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *FirewallGroup) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FirewallGroupList) DeepCopyInto(out *FirewallGroupList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]FirewallGroup, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirewallGroupList.
func (in *FirewallGroupList) DeepCopy() *FirewallGroupList {
if in == nil {
return nil
}
out := new(FirewallGroupList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *FirewallGroupList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FirewallGroupSpec) DeepCopyInto(out *FirewallGroupSpec) {
*out = *in
if in.ManualAddresses != nil {
in, out := &in.ManualAddresses, &out.ManualAddresses
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.AutoIncludeSelector != nil {
in, out := &in.AutoIncludeSelector, &out.AutoIncludeSelector
*out = new(v1.LabelSelector)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirewallGroupSpec.
func (in *FirewallGroupSpec) DeepCopy() *FirewallGroupSpec {
if in == nil {
return nil
}
out := new(FirewallGroupSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FirewallGroupStatus) DeepCopyInto(out *FirewallGroupStatus) {
*out = *in
if in.ResolvedAddresses != nil {
in, out := &in.ResolvedAddresses, &out.ResolvedAddresses
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.LastSyncTime != nil {
in, out := &in.LastSyncTime, &out.LastSyncTime
*out = (*in).DeepCopy()
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirewallGroupStatus.
func (in *FirewallGroupStatus) DeepCopy() *FirewallGroupStatus {
if in == nil {
return nil
}
out := new(FirewallGroupStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Networkconfiguration) DeepCopyInto(out *Networkconfiguration) {
*out = *in

View File

@@ -223,6 +223,15 @@ func main() {
}
// +kubebuilder:scaffold:builder
if err = (&controller.FirewallGroupReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
UnifiClient: unifiClient,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "FirewallGroup")
os.Exit(1)
}
if metricsCertWatcher != nil {
setupLog.Info("Adding metrics certificate watcher to manager")
if err := mgr.Add(metricsCertWatcher); err != nil {

View File

@@ -0,0 +1,130 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.2
name: firewallgroups.unifi.engen.priv.no
spec:
group: unifi.engen.priv.no
names:
kind: FirewallGroup
listKind: FirewallGroupList
plural: firewallgroups
singular: firewallgroup
scope: Namespaced
versions:
- name: v1beta1
schema:
openAPIV3Schema:
description: FirewallGroup is the Schema for the firewallgroups API.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: FirewallGroupSpec defines the desired state of FirewallGroup.
properties:
addressType:
description: AddressType can be "ip", "cidr", or "both"
enum:
- ip
- cidr
- both
type: string
autoIncludeSelector:
description: AutoIncludeSelector defines which services to extract
addresses from
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements.
The requirements are ANDed.
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
properties:
key:
description: key is the label key that the selector applies
to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
matchLabels:
additionalProperties:
type: string
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
type: object
x-kubernetes-map-type: atomic
manualAddresses:
description: ManualAddresses is a list of manual IPs or CIDRs (IPv4
or IPv6)
items:
type: string
type: array
matchServicesInAllNamespaces:
type: boolean
name:
description: |-
Foo is an example field of FirewallGroup. Edit firewallgroup_types.go to remove/update
Description is a human-readable explanation for the object
type: string
type: object
status:
description: FirewallGroupStatus defines the observed state of FirewallGroup.
properties:
lastSyncTime:
description: LastSyncTime is the last time the object was synced
format: date-time
type: string
resolvedAddresses:
items:
type: string
type: array
syncedWithUnifi:
description: SyncedWithUnifi indicates whether the addresses are successfully
pushed
type: boolean
type: object
type: object
served: true
storage: true
subresources:
status: {}

View File

@@ -7,6 +7,7 @@ rules:
- apiGroups:
- unifi.engen.priv.no
resources:
- firewallgroups
- networkconfigurations
verbs:
- create
@@ -19,12 +20,14 @@ rules:
- apiGroups:
- unifi.engen.priv.no
resources:
- firewallgroups/finalizers
- networkconfigurations/finalizers
verbs:
- update
- apiGroups:
- unifi.engen.priv.no
resources:
- firewallgroups/status
- networkconfigurations/status
verbs:
- get

View File

@@ -0,0 +1,17 @@
apiVersion: unifi.engen.priv.no/v1beta1
kind: FirewallGroup
metadata:
labels:
app.kubernetes.io/name: unifi-network-operator
app.kubernetes.io/managed-by: kustomize
name: firewallgroup-sample
spec:
name: Test
manualAddresses:
- 192.168.1.153
- 192.168.1.154
- 192.168.1.155
- 2a01::3
- 2a01:0::5
- 2a01:2a01::/32
# TODO(user): Add fields here

View File

@@ -0,0 +1,186 @@
/*
Copyright 2025 Vegard Engen.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package controller
import (
"context"
"net"
"fmt"
"reflect"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
unifiv1beta1 "github.com/vegardengen/unifi-network-operator/api/v1beta1"
goUnifi "github.com/vegardengen/go-unifi/unifi"
"github.com/vegardengen/unifi-network-operator/internal/unifi"
)
// FirewallGroupReconciler reconciles a FirewallGroup object
type FirewallGroupReconciler struct {
client.Client
Scheme *runtime.Scheme
UnifiClient *unifi.UnifiClient
}
// +kubebuilder:rbac:groups=unifi.engen.priv.no,resources=firewallgroups,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=unifi.engen.priv.no,resources=firewallgroups/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=unifi.engen.priv.no,resources=firewallgroups/finalizers,verbs=update
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
// TODO(user): Modify the Reconcile function to compare the state specified by
// the FirewallGroup object against the actual cluster state, and then
// perform operations to make the cluster state reflect the state specified by
// the user.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.2/pkg/reconcile
func (r *FirewallGroupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := log.FromContext(ctx)
var nwObj unifiv1beta1.FirewallGroup
if err := r.Get(ctx, req.NamespacedName, &nwObj); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
log.Info(nwObj.Spec.Name)
var ipv4, ipv6 []string
for _,addressEntry := range nwObj.Spec.ManualAddresses {
ip := net.ParseIP(addressEntry)
if ip != nil {
if ip.To4() != nil {
log.Info(fmt.Sprintf("IPv4 address: %s", addressEntry))
ipv4 = append(ipv4, addressEntry)
} else {
log.Info(fmt.Sprintf("IPv6 address: %s", addressEntry))
ipv6 = append(ipv6, ip.String())
}
} else {
addr, net, err := net.ParseCIDR(addressEntry)
if err == nil && addr.Equal(net.IP) {
if addr.To4() != nil {
log.Info(fmt.Sprintf("Ipv4 Net: %s", net))
ipv4 = append(ipv4, addressEntry)
} else {
mask,_ := net.Mask.Size()
log.Info(fmt.Sprintf("Ipv6 Net: %s", net))
ipv6 = append(ipv6, addr.Mask(net.Mask).String() + "/" + fmt.Sprint(mask))
}
} else {
log.Error(err,fmt.Sprintf("Could not parse: %s", addressEntry))
return ctrl.Result{}, err
}
}
}
firewall_groups, err := r.UnifiClient.Client.ListFirewallGroup(context.Background(), r.UnifiClient.SiteID)
if err != nil {
log.Error(err,"Could not list network objects")
return ctrl.Result{}, err
}
ipv4_name := "k8s-"+nwObj.Spec.Name+"-ipv4"
ipv6_name := "k8s-"+nwObj.Spec.Name+"-ipv6"
ipv4_done := false
ipv6_done := false
for _,firewall_group := range firewall_groups {
if firewall_group.Name == ipv4_name {
if(len(ipv4) == 0) {
log.Info(fmt.Sprintf("Delete %s", ipv4_name))
err := r.UnifiClient.Client.DeleteFirewallGroup(context.Background(), r.UnifiClient.SiteID, firewall_group.ID)
if err != nil {
log.Error(err,"Could not delete firewall group")
return ctrl.Result{}, err
}
ipv4_done = true
} else {
if !reflect.DeepEqual(firewall_group.GroupMembers, ipv4) {
firewall_group.GroupMembers = ipv4
log.Info(fmt.Sprintf("Updating %s", ipv4_name))
_, err := r.UnifiClient.Client.UpdateFirewallGroup(context.Background(), r.UnifiClient.SiteID, &firewall_group)
if err != nil {
log.Error(err,"Could not update firewall group")
return ctrl.Result{}, err
}
}
ipv4_done = true
}
}
if firewall_group.Name == ipv6_name {
if(len(ipv6) == 0) {
log.Info(fmt.Sprintf("Delete %s", ipv6_name))
err := r.UnifiClient.Client.DeleteFirewallGroup(context.Background(), r.UnifiClient.SiteID, firewall_group.ID)
if err != nil {
log.Error(err,"Could not delete firewall group")
return ctrl.Result{}, err
}
ipv6_done = true
} else {
if !reflect.DeepEqual(firewall_group.GroupMembers, ipv6) {
firewall_group.GroupMembers = ipv6
log.Info(fmt.Sprintf("Updating %s", ipv6_name))
_, err := r.UnifiClient.Client.UpdateFirewallGroup(context.Background(), r.UnifiClient.SiteID, &firewall_group)
if err != nil {
log.Error(err,"Could not update firewall group")
return ctrl.Result{}, err
}
}
ipv6_done = true
}
}
}
if len(ipv4) > 0 && !ipv4_done {
log.Info(fmt.Sprintf("Creating %s", ipv4_name))
var firewall_group goUnifi.FirewallGroup
firewall_group.Name=ipv4_name
firewall_group.SiteID=r.UnifiClient.SiteID
firewall_group.GroupMembers = ipv4
firewall_group.GroupType = "address-group"
_, err := r.UnifiClient.Client.CreateFirewallGroup(context.Background(), r.UnifiClient.SiteID, &firewall_group)
if err != nil {
log.Error(err,"Could not create firewall group")
return ctrl.Result{}, err
}
}
if len(ipv6) > 0 && !ipv6_done {
log.Info(fmt.Sprintf("Creating %s", ipv6_name))
var firewall_group goUnifi.FirewallGroup
firewall_group.Name=ipv6_name
firewall_group.SiteID=r.UnifiClient.SiteID
firewall_group.GroupMembers = ipv6
firewall_group.GroupType = "ipv6-address-group"
_, err := r.UnifiClient.Client.CreateFirewallGroup(context.Background(), r.UnifiClient.SiteID, &firewall_group)
if err != nil {
log.Error(err,"Could not create firewall group")
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *FirewallGroupReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&unifiv1beta1.FirewallGroup{}).
Named("firewallgroup").
Complete(r)
}

View File

@@ -0,0 +1,84 @@
/*
Copyright 2025 Vegard Engen.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package controller
import (
"context"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
unifiv1beta1 "github.com/vegardengen/unifi-network-operator/api/v1beta1"
)
var _ = Describe("FirewallGroup Controller", func() {
Context("When reconciling a resource", func() {
const resourceName = "test-resource"
ctx := context.Background()
typeNamespacedName := types.NamespacedName{
Name: resourceName,
Namespace: "default", // TODO(user):Modify as needed
}
firewallgroup := &unifiv1beta1.FirewallGroup{}
BeforeEach(func() {
By("creating the custom resource for the Kind FirewallGroup")
err := k8sClient.Get(ctx, typeNamespacedName, firewallgroup)
if err != nil && errors.IsNotFound(err) {
resource := &unifiv1beta1.FirewallGroup{
ObjectMeta: metav1.ObjectMeta{
Name: resourceName,
Namespace: "default",
},
// TODO(user): Specify other spec details if needed.
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
}
})
AfterEach(func() {
// TODO(user): Cleanup logic after each test, like removing the resource instance.
resource := &unifiv1beta1.FirewallGroup{}
err := k8sClient.Get(ctx, typeNamespacedName, resource)
Expect(err).NotTo(HaveOccurred())
By("Cleanup the specific resource instance FirewallGroup")
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
})
It("should successfully reconcile the resource", func() {
By("Reconciling the created resource")
controllerReconciler := &FirewallGroupReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
}
_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())
// TODO(user): Add more specific assertions depending on your controller's reconciliation logic.
// Example: If you expect a certain status condition after reconciliation, verify it here.
})
})
})