From d45d84462d927fc0e95c07b666a2b7e2b2e18a71 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Fri, 28 Aug 2026 23:12:14 +0200 Subject: [PATCH] Initial commit --- .dockerignore | 8 + .gitea/workflows/ci.yml | 133 ++++++++++ .gitignore | 2 + Dockerfile | 11 + Makefile | 39 +++ README.md | 192 +++++++++++++- api/v1alpha1/groupversion_info.go | 15 ++ api/v1alpha1/mikrotikrouter_types.go | 61 +++++ api/v1alpha1/zz_generated.deepcopy.go | 113 ++++++++ chart/mikrotik-operator/Chart.yaml | 16 ++ .../crds/mikrotikrouters.yaml | 125 +++++++++ chart/mikrotik-operator/templates/NOTES.txt | 8 + .../mikrotik-operator/templates/_helpers.tpl | 48 ++++ .../templates/deployment.yaml | 71 +++++ .../templates/metrics-service.yaml | 21 ++ chart/mikrotik-operator/templates/rbac.yaml | 69 +++++ .../templates/serviceaccount.yaml | 13 + chart/mikrotik-operator/values.schema.json | 72 +++++ chart/mikrotik-operator/values.yaml | 60 +++++ cmd/manager/main.go | 66 +++++ config/crd/mikrotikrouters.yaml | 125 +++++++++ config/manager/deployment.yaml | 53 ++++ config/rbac/operator.yaml | 69 +++++ examples/router.yaml | 14 + examples/service.yaml | 25 ++ go.mod | 64 +++++ go.sum | 171 ++++++++++++ internal/controller/router_controller.go | 188 +++++++++++++ internal/controller/service_rules.go | 120 +++++++++ internal/controller/service_rules_test.go | 88 +++++++ internal/routeros/client.go | 247 ++++++++++++++++++ internal/routeros/client_test.go | 96 +++++++ 32 files changed, 2401 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 api/v1alpha1/groupversion_info.go create mode 100644 api/v1alpha1/mikrotikrouter_types.go create mode 100644 api/v1alpha1/zz_generated.deepcopy.go create mode 100644 chart/mikrotik-operator/Chart.yaml create mode 100644 chart/mikrotik-operator/crds/mikrotikrouters.yaml create mode 100644 chart/mikrotik-operator/templates/NOTES.txt create mode 100644 chart/mikrotik-operator/templates/_helpers.tpl create mode 100644 chart/mikrotik-operator/templates/deployment.yaml create mode 100644 chart/mikrotik-operator/templates/metrics-service.yaml create mode 100644 chart/mikrotik-operator/templates/rbac.yaml create mode 100644 chart/mikrotik-operator/templates/serviceaccount.yaml create mode 100644 chart/mikrotik-operator/values.schema.json create mode 100644 chart/mikrotik-operator/values.yaml create mode 100644 cmd/manager/main.go create mode 100644 config/crd/mikrotikrouters.yaml create mode 100644 config/manager/deployment.yaml create mode 100644 config/rbac/operator.yaml create mode 100644 examples/router.yaml create mode 100644 examples/service.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/controller/router_controller.go create mode 100644 internal/controller/service_rules.go create mode 100644 internal/controller/service_rules_test.go create mode 100644 internal/routeros/client.go create mode 100644 internal/routeros/client_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fd1d3d8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitea +chart +config +examples +manager +*.tgz +README.md diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..70cf09b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,133 @@ +name: Validate and publish + +on: + push: + pull_request: + +env: + IMAGE: registry.brunner.ninja/feedc0de/mikrotik-operator + CHART_REGISTRY: oci://registry.brunner.ninja/feedc0de/charts + HELM_VERSION: v4.2.2 + +jobs: + validate: + name: Test image and chart + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Test and vet + run: | + go test -race ./... + go vet ./... + + - name: Install Helm + run: | + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/helm.tar.gz" \ + "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/helm.tar.gz.sha256sum" \ + "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz.sha256sum" + (cd "${RUNNER_TEMP}" && sha256sum --check helm.tar.gz.sha256sum) + tar --extract --gzip --file "${RUNNER_TEMP}/helm.tar.gz" \ + --directory "${RUNNER_TEMP}" linux-amd64/helm + mv "${RUNNER_TEMP}/linux-amd64/helm" "${RUNNER_TEMP}/helm" + chmod 0700 "${RUNNER_TEMP}/helm" + + - name: Lint and render chart + run: | + cmp config/crd/mikrotikrouters.yaml chart/mikrotik-operator/crds/mikrotikrouters.yaml + "${RUNNER_TEMP}/helm" lint chart/mikrotik-operator + "${RUNNER_TEMP}/helm" template mikrotik-operator chart/mikrotik-operator \ + --namespace mikrotik-operator-system \ + --include-crds > "${RUNNER_TEMP}/rendered.yaml" + test -s "${RUNNER_TEMP}/rendered.yaml" + + - name: Build container image + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + docker build \ + --label "org.opencontainers.image.revision=${COMMIT_SHA}" \ + --label "org.opencontainers.image.source=https://code.brunner.ninja/feedc0de/mikrotik-operator" \ + --tag "${IMAGE}:ci-${COMMIT_SHA}" \ + . + + publish: + name: Publish image and OCI chart + if: gitea.event_name == 'push' && (gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v')) + needs: validate + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install Helm + run: | + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/helm.tar.gz" \ + "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/helm.tar.gz.sha256sum" \ + "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz.sha256sum" + (cd "${RUNNER_TEMP}" && sha256sum --check helm.tar.gz.sha256sum) + tar --extract --gzip --file "${RUNNER_TEMP}/helm.tar.gz" \ + --directory "${RUNNER_TEMP}" linux-amd64/helm + mv "${RUNNER_TEMP}/linux-amd64/helm" "${RUNNER_TEMP}/helm" + chmod 0700 "${RUNNER_TEMP}/helm" + + - name: Build and publish matched artifacts + env: + COMMIT_SHA: ${{ gitea.sha }} + GIT_REF: ${{ gitea.ref }} + QUAY_USERNAME: ${{ secrets.QUAY_USERNAME }} + QUAY_TOKEN: ${{ secrets.QUAY_TOKEN }} + run: | + set -euo pipefail + test -n "${QUAY_USERNAME}" + test -n "${QUAY_TOKEN}" + + if [[ "${GIT_REF}" == refs/tags/v* ]]; then + chart_version="${GIT_REF#refs/tags/v}" + image_tag="${chart_version}" + else + chart_version="0.0.0-dev.sha${COMMIT_SHA:0:12}" + image_tag="${COMMIT_SHA}" + fi + + mkdir -p "${RUNNER_TEMP}/packages" + docker build \ + --label "org.opencontainers.image.version=${image_tag}" \ + --label "org.opencontainers.image.revision=${COMMIT_SHA}" \ + --label "org.opencontainers.image.source=https://code.brunner.ninja/feedc0de/mikrotik-operator" \ + --tag "${IMAGE}:${image_tag}" \ + . + + "${RUNNER_TEMP}/helm" package chart/mikrotik-operator \ + --destination "${RUNNER_TEMP}/packages" \ + --version "${chart_version}" \ + --app-version "${image_tag}" + chart_package="${RUNNER_TEMP}/packages/mikrotik-operator-${chart_version}.tgz" + test -f "${chart_package}" + "${RUNNER_TEMP}/helm" template mikrotik-operator "${chart_package}" \ + --namespace mikrotik-operator-system > "${RUNNER_TEMP}/packaged-rendered.yaml" + grep --fixed-strings "image: \"${IMAGE}:${image_tag}\"" "${RUNNER_TEMP}/packaged-rendered.yaml" + + printf '%s' "${QUAY_TOKEN}" | docker login registry.brunner.ninja \ + --username "${QUAY_USERNAME}" --password-stdin + printf '%s' "${QUAY_TOKEN}" | "${RUNNER_TEMP}/helm" registry login registry.brunner.ninja \ + --username "${QUAY_USERNAME}" --password-stdin + + docker push "${IMAGE}:${image_tag}" + "${RUNNER_TEMP}/helm" push "${chart_package}" "${CHART_REGISTRY}" + + echo "Published image ${IMAGE}:${image_tag}" + echo "Published chart ${CHART_REGISTRY}/mikrotik-operator:${chart_version}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a73fa0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/manager +/coverage.out diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bb55cd7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.26-bookworm@sha256:e8c859f5632dcfde7b32d2012b4351728f6437930887c2f6a91ea242459e5514 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -trimpath -ldflags="-s -w" -o /manager ./cmd/manager + +FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab +COPY --from=build /manager /manager +USER 65532:65532 +ENTRYPOINT ["/manager"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a2f1474 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +IMAGE_REPOSITORY ?= registry.brunner.ninja/feedc0de/mikrotik-operator +IMAGE_TAG ?= dev +IMAGE ?= $(IMAGE_REPOSITORY):$(IMAGE_TAG) +CHART ?= chart/mikrotik-operator +CHART_VERSION ?= 0.1.0 + +.PHONY: build test chart-lint chart-package image push install install-manifests + +build: + go build -buildvcs=false ./cmd/manager + +test: + go test ./... + +chart-lint: + helm lint $(CHART) + helm template mikrotik-operator $(CHART) --namespace mikrotik-operator-system --include-crds >/dev/null + +chart-package: chart-lint + helm package $(CHART) --version $(CHART_VERSION) --app-version $(IMAGE_TAG) + +image: + docker build -t $(IMAGE) . + +push: image + docker push $(IMAGE) + +install: + helm upgrade --install mikrotik-operator $(CHART) \ + --namespace mikrotik-operator-system \ + --create-namespace \ + --set-string image.repository=$(IMAGE_REPOSITORY) \ + --set-string image.tag=$(IMAGE_TAG) + +install-manifests: + kubectl apply -f config/crd/mikrotikrouters.yaml + kubectl apply -f config/rbac/operator.yaml + kubectl apply -f config/manager/deployment.yaml + kubectl -n mikrotik-operator-system set image deployment/mikrotik-operator manager=$(IMAGE) diff --git a/README.md b/README.md index ccbfc67..0bd8527 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,191 @@ -# mikrotik-operator +# MikroTik operator -Kubernetes Mikrotik Operator to automatically create router port-forwardings to publish kubernetes services to the internet \ No newline at end of file +This is an initial Kubernetes operator that reconciles annotated `LoadBalancer` +Services into RouterOS IPv4 `dst-nat` rules. + +The controller reads the internal destination address from +`Service.status.loadBalancer.ingress[].ip`. In this cluster MetalLB publishes +and announces that address over L2. The design does not depend on MetalLB +specifically, so the same contract works with Cilium LB IPAM/L2 announcements. + +## API contract + +Routers are cluster-scoped because Services in several namespaces may use the +same physical router. Credentials are stored in a Kubernetes Secret and only a +reference is placed in the `MikroTikRouter` resource. Every router must +explicitly list the namespaces permitted to create Internet-facing rules; an +empty allowlist permits none. + +One annotated Service creates one rule in this first revision: + +```yaml +metadata: + annotations: + mikrotik.brunner.ninja/router: newgw + mikrotik.brunner.ninja/service-port: game-udp + mikrotik.brunner.ninja/dst-port: "34197" + mikrotik.brunner.ninja/in-interface-list: WAN +``` + +`service-port` selects a Service port by name or number and is optional when the +Service has exactly one port. `dst-port` is the public port and defaults to the +selected Service port. Protocol is inherited from that port. The generated +RouterOS properties are: + +| Source | RouterOS property | +| --- | --- | +| annotation `dst-port` | `dst-port` | +| annotation `dst-address` | `dst-address` | +| annotation `dst-address-list` | `dst-address-list` | +| annotation `in-interface` | `in-interface` | +| annotation `in-interface-list` | `in-interface-list` | +| annotation `src-address` | `src-address` | +| annotation `src-address-list` | `src-address-list` | +| selected Service protocol | `protocol` | +| LoadBalancer status IPv4 | `to-addresses` | +| selected Service `port` | `to-ports` | + +RouterOS calls the incoming/WAN matcher `in-interface`, not `src-interface`. +`src-address` refers to the remote client's address. + +Managed rules have comments of the form +`k8s-mikrotik//`. The controller never modifies +rules without that prefix. It creates and updates desired rules, deletes stale +owned rules, removes duplicate owned rules, and preserves an existing rule if +the corresponding Service temporarily has no LoadBalancer IP. + +Deleting a `MikroTikRouter` removes all of its managed rules before the CR is +deleted. If RouterOS cannot be reached, the cleanup finalizer deliberately keeps +the CR in `Terminating` rather than silently leaving public ports open. + +## RouterOS preparation + +The implementation uses the RouterOS v7 REST API. Enable `www-ssl`, configure a +certificate, and create a dedicated user rather than using `admin`. The minimum +group policies needed are `read`, `write`, and `rest-api`: + +```routeros +/user/group/add name=kubernetes-nat policy=read,write,rest-api +/user/add name=kubernetes-nat group=kubernetes-nat password= address= +/ip/service/enable www-ssl +``` + +Restrict `www-ssl` with both `/ip service ... address=` and firewall rules to +the actual operator egress address or CIDR. Use a trusted certificate or put +its issuing CA in a Secret. `insecureSkipVerify` exists only to make the first +lab test easier. Plain HTTP sends the Basic Auth credentials without transport +encryption and should not be used. + +The operator manages NAT only; it does not create RouterOS filter rules. The +router's forward-chain policy must already allow dst-natted traffic. New rules +are appended by RouterOS, so check that existing earlier NAT rules do not shadow +them. Rule positioning is a good candidate for the next revision after testing +against the actual router configuration. + +## Install from the OCI Helm chart + +Gitea CI publishes the operator image and chart to the local Quay registry. A +release tag such as `v0.1.0` produces these matched artifacts: + +- `registry.brunner.ninja/feedc0de/mikrotik-operator:0.1.0` +- `oci://registry.brunner.ninja/feedc0de/charts/mikrotik-operator:0.1.0` + +Install that release directly from the registry: + +```sh +helm registry login registry.brunner.ninja +helm upgrade --install mikrotik-operator \ + oci://registry.brunner.ninja/feedc0de/charts/mikrotik-operator \ + --version 0.1.0 \ + --namespace mikrotik-operator-system \ + --create-namespace +``` + +The chart installs the CRD, Deployment, ServiceAccount, and RBAC. It +deliberately does not contain credential Secrets or `MikroTikRouter` instances. +Keep those as separate cluster configuration. Helm installs CRDs from `crds/` +but does not upgrade or delete them; apply a changed CRD explicitly before a +future chart upgrade: + +```sh +kubectl apply -f config/crd/mikrotikrouters.yaml +``` + +For a local development build and chart install: + +```sh +make IMAGE_TAG=dev push +make IMAGE_TAG=dev install +``` + +The original plain-manifest path remains available as `make install-manifests`. + +## Gitea CI releases + +The workflow in `.gitea/workflows/ci.yml` runs Go race tests and vet, verifies +that the standalone and chart CRDs are identical, lints and renders the chart, +and builds the container on every push and pull request. + +Publishing runs only for `main` and `v*` tags: + +- `main` publishes the image under the full commit SHA and a development chart + such as `0.0.0-dev.sha0123456789ab` whose `appVersion` is that SHA. +- `v0.1.0` publishes image tag `0.1.0` and chart version `0.1.0`. + +The Deployment template defaults the image tag to `Chart.appVersion`. CI +renders the packaged chart and verifies the exact expected image reference +before pushing either artifact. Configure these Gitea repository Actions +secrets: + +- `QUAY_USERNAME` +- `QUAY_TOKEN` + +The Quay account or robot token needs push access to both +`feedc0de/mikrotik-operator` and `feedc0de/charts/mikrotik-operator`. Make the +repositories public if clusters should pull without an image pull Secret. + +Create credentials without committing them. This keeps the password itself out +of shell history: + +```sh +read -rsp 'Router password: ' ROUTER_PASSWORD +echo +kubectl -n mikrotik-operator-system create secret generic newgw-credentials \ + --from-literal=username=kubernetes-nat \ + --from-literal=password="${ROUTER_PASSWORD}" +unset ROUTER_PASSWORD +``` + +Edit and apply the examples: + +```sh +kubectl apply -f examples/router.yaml +kubectl apply -f examples/service.yaml +kubectl get mikrotikrouter newgw +kubectl describe mikrotikrouter newgw +kubectl -n mikrotik-operator-system logs deployment/mikrotik-operator -f +``` + +For a CA-verified self-signed setup, create a Secret with a `ca.crt` key and +replace `insecureSkipVerify` in the router with: + +```yaml +tls: + caSecretRef: + namespace: mikrotik-operator-system + name: router-ca +``` + +## Current limits and next revisions + +- RouterOS destination NAT is IPv4-only; IPv6 LoadBalancer addresses are ignored. +- One Service currently produces one NAT rule. Split Services by exposed port, + or add a typed multi-forwarding API in the next revision. +- There is no admission webhook yet. Validation errors are reported in the + router's `Ready` condition while the previous rule is preserved. +- Metrics use controller-runtime's standard endpoint; domain-specific counters + and Kubernetes Events can be added once live reconciliation is proven. + +The chart remains intentionally small while the API is `v1alpha1`; new router +and forwarding fields can evolve through `0.x` chart releases without treating +the current contract as stable. diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..5d878a2 --- /dev/null +++ b/api/v1alpha1/groupversion_info.go @@ -0,0 +1,15 @@ +// Package v1alpha1 contains the API types for the MikroTik operator. +// +kubebuilder:object:generate=true +// +groupName=mikrotik.brunner.ninja +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + GroupVersion = schema.GroupVersion{Group: "mikrotik.brunner.ninja", Version: "v1alpha1"} + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1alpha1/mikrotikrouter_types.go b/api/v1alpha1/mikrotikrouter_types.go new file mode 100644 index 0000000..9464664 --- /dev/null +++ b/api/v1alpha1/mikrotikrouter_types.go @@ -0,0 +1,61 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NamespacedSecretReference points to a Secret from a cluster-scoped resource. +type NamespacedSecretReference struct { + Namespace string `json:"namespace"` + Name string `json:"name"` +} + +type TLSConfig struct { + // InsecureSkipVerify is intended only for initial testing. + InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` + // CASecretRef points to a Secret whose ca.crt key contains a PEM CA bundle. + CASecretRef *NamespacedSecretReference `json:"caSecretRef,omitempty"` +} + +type MikroTikRouterSpec struct { + // Endpoint is the RouterOS base URL, for example https://router.example.net. + Endpoint string `json:"endpoint"` + // CredentialsSecretRef points to a Secret containing username and password keys. + CredentialsSecretRef NamespacedSecretReference `json:"credentialsSecretRef"` + TLS TLSConfig `json:"tls,omitempty"` + // AllowedNamespaces is an explicit allowlist. An empty list allows no Services. + // The single value "*" allows every namespace. + AllowedNamespaces []string `json:"allowedNamespaces,omitempty"` +} + +type MikroTikRouterStatus struct { + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + ManagedRules int32 `json:"managedRules,omitempty"` + LastSyncTime *metav1.Time `json:"lastSyncTime,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster,shortName=mtr +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=`.spec.endpoint` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Rules",type=integer,JSONPath=`.status.managedRules` +type MikroTikRouter struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec MikroTikRouterSpec `json:"spec,omitempty"` + Status MikroTikRouterStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type MikroTikRouterList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []MikroTikRouter `json:"items"` +} + +func init() { + SchemeBuilder.Register(&MikroTikRouter{}, &MikroTikRouterList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..6981968 --- /dev/null +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,113 @@ +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +func (in *NamespacedSecretReference) DeepCopyInto(out *NamespacedSecretReference) { *out = *in } +func (in *NamespacedSecretReference) DeepCopy() *NamespacedSecretReference { + if in == nil { + return nil + } + out := new(NamespacedSecretReference) + in.DeepCopyInto(out) + return out +} +func (in *TLSConfig) DeepCopyInto(out *TLSConfig) { + *out = *in + if in.CASecretRef != nil { + out.CASecretRef = in.CASecretRef.DeepCopy() + } +} +func (in *TLSConfig) DeepCopy() *TLSConfig { + if in == nil { + return nil + } + out := new(TLSConfig) + in.DeepCopyInto(out) + return out +} +func (in *MikroTikRouterSpec) DeepCopyInto(out *MikroTikRouterSpec) { + *out = *in + in.TLS.DeepCopyInto(&out.TLS) + if in.AllowedNamespaces != nil { + out.AllowedNamespaces = append([]string(nil), in.AllowedNamespaces...) + } +} +func (in *MikroTikRouterSpec) DeepCopy() *MikroTikRouterSpec { + if in == nil { + return nil + } + out := new(MikroTikRouterSpec) + in.DeepCopyInto(out) + return out +} +func (in *MikroTikRouterStatus) DeepCopyInto(out *MikroTikRouterStatus) { + *out = *in + if in.LastSyncTime != nil { + out.LastSyncTime = in.LastSyncTime.DeepCopy() + } + if in.Conditions != nil { + out.Conditions = make([]metav1.Condition, len(in.Conditions)) + for i := range in.Conditions { + in.Conditions[i].DeepCopyInto(&out.Conditions[i]) + } + } +} +func (in *MikroTikRouterStatus) DeepCopy() *MikroTikRouterStatus { + if in == nil { + return nil + } + out := new(MikroTikRouterStatus) + in.DeepCopyInto(out) + return out +} +func (in *MikroTikRouter) DeepCopyInto(out *MikroTikRouter) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} +func (in *MikroTikRouter) DeepCopy() *MikroTikRouter { + if in == nil { + return nil + } + out := new(MikroTikRouter) + in.DeepCopyInto(out) + return out +} +func (in *MikroTikRouter) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} +func (in *MikroTikRouterList) DeepCopyInto(out *MikroTikRouterList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + out.Items = make([]MikroTikRouter, len(in.Items)) + for i := range in.Items { + in.Items[i].DeepCopyInto(&out.Items[i]) + } + } +} +func (in *MikroTikRouterList) DeepCopy() *MikroTikRouterList { + if in == nil { + return nil + } + out := new(MikroTikRouterList) + in.DeepCopyInto(out) + return out +} +func (in *MikroTikRouterList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/chart/mikrotik-operator/Chart.yaml b/chart/mikrotik-operator/Chart.yaml new file mode 100644 index 0000000..44e7220 --- /dev/null +++ b/chart/mikrotik-operator/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v2 +name: mikrotik-operator +description: Reconcile Kubernetes LoadBalancer Services into MikroTik RouterOS dst-nat rules +type: application +version: 0.1.0 +appVersion: "0.1.0" +home: https://code.brunner.ninja/feedc0de/mikrotik-operator +sources: + - https://code.brunner.ninja/feedc0de/mikrotik-operator +keywords: + - kubernetes + - mikrotik + - routeros + - nat +maintainers: + - name: feedc0de diff --git a/chart/mikrotik-operator/crds/mikrotikrouters.yaml b/chart/mikrotik-operator/crds/mikrotikrouters.yaml new file mode 100644 index 0000000..da92aef --- /dev/null +++ b/chart/mikrotik-operator/crds/mikrotikrouters.yaml @@ -0,0 +1,125 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: mikrotikrouters.mikrotik.brunner.ninja +spec: + group: mikrotik.brunner.ninja + scope: Cluster + names: + plural: mikrotikrouters + singular: mikrotikrouter + kind: MikroTikRouter + shortNames: + - mtr + versions: + - name: v1alpha1 + served: true + storage: true + additionalPrinterColumns: + - name: Endpoint + type: string + jsonPath: .spec.endpoint + - name: Ready + type: string + jsonPath: '.status.conditions[?(@.type=="Ready")].status' + - name: Rules + type: integer + jsonPath: .status.managedRules + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + type: object + required: + - endpoint + - credentialsSecretRef + properties: + endpoint: + type: string + pattern: '^https?://[^/]+/?$' + credentialsSecretRef: + type: object + required: + - namespace + - name + properties: + namespace: + type: string + minLength: 1 + name: + type: string + minLength: 1 + tls: + type: object + properties: + insecureSkipVerify: + type: boolean + caSecretRef: + type: object + required: + - namespace + - name + properties: + namespace: + type: string + minLength: 1 + name: + type: string + minLength: 1 + allowedNamespaces: + type: array + items: + type: string + minLength: 1 + uniqueItems: true + status: + type: object + properties: + observedGeneration: + type: integer + format: int64 + managedRules: + type: integer + format: int32 + lastSyncTime: + type: string + format: date-time + conditions: + type: array + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: + - type + items: + type: object + required: + - lastTransitionTime + - message + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + maxLength: 32768 + observedGeneration: + type: integer + format: int64 + reason: + type: string + status: + type: string + enum: ["True", "False", Unknown] + type: + type: string diff --git a/chart/mikrotik-operator/templates/NOTES.txt b/chart/mikrotik-operator/templates/NOTES.txt new file mode 100644 index 0000000..7d73bd0 --- /dev/null +++ b/chart/mikrotik-operator/templates/NOTES.txt @@ -0,0 +1,8 @@ +The MikroTik operator is installed in namespace {{ .Release.Namespace }}. + +Create router credentials separately, then apply a MikroTikRouter resource. +The chart deliberately does not manage credentials or router instances. + +Check readiness with: + kubectl get mikrotikrouters + kubectl -n {{ .Release.Namespace }} get deployment {{ include "mikrotik-operator.fullname" . }} diff --git a/chart/mikrotik-operator/templates/_helpers.tpl b/chart/mikrotik-operator/templates/_helpers.tpl new file mode 100644 index 0000000..9b21e9d --- /dev/null +++ b/chart/mikrotik-operator/templates/_helpers.tpl @@ -0,0 +1,48 @@ +{{- define "mikrotik-operator.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "mikrotik-operator.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "mikrotik-operator.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "mikrotik-operator.labels" -}} +helm.sh/chart: {{ include "mikrotik-operator.chart" . }} +{{ include "mikrotik-operator.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "mikrotik-operator.selectorLabels" -}} +app.kubernetes.io/name: {{ include "mikrotik-operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "mikrotik-operator.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "mikrotik-operator.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- required "serviceAccount.name is required when serviceAccount.create=false" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{- define "mikrotik-operator.image" -}} +{{- if .Values.image.digest -}} +{{ printf "%s@%s" .Values.image.repository .Values.image.digest }} +{{- else -}} +{{ printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) }} +{{- end -}} +{{- end }} diff --git a/chart/mikrotik-operator/templates/deployment.yaml b/chart/mikrotik-operator/templates/deployment.yaml new file mode 100644 index 0000000..2b39bd1 --- /dev/null +++ b/chart/mikrotik-operator/templates/deployment.yaml @@ -0,0 +1,71 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "mikrotik-operator.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "mikrotik-operator.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "mikrotik-operator.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "mikrotik-operator.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "mikrotik-operator.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: manager + image: {{ include "mikrotik-operator.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - --leader-elect={{ .Values.leaderElection.enabled }} + - --metrics-bind-address=:{{ .Values.metrics.port }} + - --health-probe-bind-address=:{{ .Values.healthProbePort }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + ports: + - name: metrics + containerPort: {{ .Values.metrics.port }} + protocol: TCP + - name: health + containerPort: {{ .Values.healthProbePort }} + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: health + readinessProbe: + httpGet: + path: /readyz + port: health + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} diff --git a/chart/mikrotik-operator/templates/metrics-service.yaml b/chart/mikrotik-operator/templates/metrics-service.yaml new file mode 100644 index 0000000..28838dc --- /dev/null +++ b/chart/mikrotik-operator/templates/metrics-service.yaml @@ -0,0 +1,21 @@ +{{- if .Values.metrics.service.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "mikrotik-operator.fullname" . }}-metrics + namespace: {{ .Release.Namespace }} + labels: + {{- include "mikrotik-operator.labels" . | nindent 4 }} + {{- with .Values.metrics.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + {{- include "mikrotik-operator.selectorLabels" . | nindent 4 }} + ports: + - name: metrics + port: {{ .Values.metrics.service.port }} + targetPort: metrics + protocol: TCP +{{- end }} diff --git a/chart/mikrotik-operator/templates/rbac.yaml b/chart/mikrotik-operator/templates/rbac.yaml new file mode 100644 index 0000000..291c369 --- /dev/null +++ b/chart/mikrotik-operator/templates/rbac.yaml @@ -0,0 +1,69 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "mikrotik-operator.fullname" . }} + labels: + {{- include "mikrotik-operator.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["services"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] + - apiGroups: ["mikrotik.brunner.ninja"] + resources: ["mikrotikrouters"] + verbs: ["get", "list", "watch", "patch", "update"] + - apiGroups: ["mikrotik.brunner.ninja"] + resources: ["mikrotikrouters/finalizers"] + verbs: ["update"] + - apiGroups: ["mikrotik.brunner.ninja"] + resources: ["mikrotikrouters/status"] + verbs: ["get", "patch", "update"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "mikrotik-operator.fullname" . }} + labels: + {{- include "mikrotik-operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "mikrotik-operator.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "mikrotik-operator.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- if .Values.leaderElection.enabled }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "mikrotik-operator.fullname" . }}-leader-election + namespace: {{ .Release.Namespace }} + labels: + {{- include "mikrotik-operator.labels" . | nindent 4 }} +rules: + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "mikrotik-operator.fullname" . }}-leader-election + namespace: {{ .Release.Namespace }} + labels: + {{- include "mikrotik-operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "mikrotik-operator.fullname" . }}-leader-election +subjects: + - kind: ServiceAccount + name: {{ include "mikrotik-operator.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} +{{- end }} diff --git a/chart/mikrotik-operator/templates/serviceaccount.yaml b/chart/mikrotik-operator/templates/serviceaccount.yaml new file mode 100644 index 0000000..ec87624 --- /dev/null +++ b/chart/mikrotik-operator/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "mikrotik-operator.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "mikrotik-operator.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/chart/mikrotik-operator/values.schema.json b/chart/mikrotik-operator/values.schema.json new file mode 100644 index 0000000..48f0670 --- /dev/null +++ b/chart/mikrotik-operator/values.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "replicaCount": { "type": "integer", "minimum": 1 }, + "image": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "tag", "digest", "pullPolicy"], + "properties": { + "repository": { "type": "string", "minLength": 1 }, + "tag": { "type": "string" }, + "digest": { "type": "string", "pattern": "^$|^sha256:[a-f0-9]{64}$" }, + "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] } + } + }, + "imagePullSecrets": { "type": "array", "items": { "type": "object" } }, + "nameOverride": { "type": "string" }, + "fullnameOverride": { "type": "string" }, + "serviceAccount": { + "type": "object", + "additionalProperties": false, + "required": ["create", "annotations", "name"], + "properties": { + "create": { "type": "boolean" }, + "annotations": { "type": "object" }, + "name": { "type": "string" } + } + }, + "rbac": { + "type": "object", + "additionalProperties": false, + "required": ["create"], + "properties": { "create": { "type": "boolean" } } + }, + "leaderElection": { + "type": "object", + "additionalProperties": false, + "required": ["enabled"], + "properties": { "enabled": { "type": "boolean" } } + }, + "metrics": { + "type": "object", + "additionalProperties": false, + "required": ["port", "service"], + "properties": { + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "service": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "port", "annotations"], + "properties": { + "enabled": { "type": "boolean" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "annotations": { "type": "object" } + } + } + } + }, + "healthProbePort": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "podAnnotations": { "type": "object" }, + "podLabels": { "type": "object" }, + "podSecurityContext": { "type": "object" }, + "securityContext": { "type": "object" }, + "resources": { "type": "object" }, + "nodeSelector": { "type": "object" }, + "tolerations": { "type": "array" }, + "affinity": { "type": "object" }, + "terminationGracePeriodSeconds": { "type": "integer", "minimum": 0 } + } +} diff --git a/chart/mikrotik-operator/values.yaml b/chart/mikrotik-operator/values.yaml new file mode 100644 index 0000000..a205216 --- /dev/null +++ b/chart/mikrotik-operator/values.yaml @@ -0,0 +1,60 @@ +replicaCount: 1 + +image: + repository: registry.brunner.ninja/feedc0de/mikrotik-operator + # Defaults to Chart.appVersion, which CI sets to the matching image tag. + tag: "" + digest: "" + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +rbac: + create: true + +leaderElection: + enabled: true + +metrics: + port: 8080 + service: + enabled: false + port: 8080 + annotations: {} + +healthProbePort: 8081 + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + +resources: + requests: + cpu: 20m + memory: 48Mi + limits: + memory: 128Mi + +nodeSelector: {} +tolerations: [] +affinity: {} + +terminationGracePeriodSeconds: 10 diff --git a/cmd/manager/main.go b/cmd/manager/main.go new file mode 100644 index 0000000..70f9eca --- /dev/null +++ b/cmd/manager/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "flag" + "os" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + mikrotikv1alpha1 "github.com/feedc0de/mikrotik-operator/api/v1alpha1" + "github.com/feedc0de/mikrotik-operator/internal/controller" +) + +func main() { + var metricsAddress string + var healthAddress string + var leaderElection bool + flag.StringVar(&metricsAddress, "metrics-bind-address", ":8080", "Address for the metrics endpoint") + flag.StringVar(&healthAddress, "health-probe-bind-address", ":8081", "Address for health probes") + flag.BoolVar(&leaderElection, "leader-elect", true, "Use leader election") + zapOptions := zap.Options{Development: false} + zapOptions.BindFlags(flag.CommandLine) + flag.Parse() + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&zapOptions))) + + scheme := runtime.NewScheme() + must(clientgoscheme.AddToScheme(scheme)) + must(corev1.AddToScheme(scheme)) + must(mikrotikv1alpha1.AddToScheme(scheme)) + + manager, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: metricsAddress}, + HealthProbeBindAddress: healthAddress, + LeaderElection: leaderElection, + LeaderElectionID: "mikrotik-operator.mikrotik.brunner.ninja", + }) + if err != nil { + ctrl.Log.Error(err, "unable to create manager") + os.Exit(1) + } + + reconciler := &controller.MikroTikRouterReconciler{Client: manager.GetClient(), Scheme: manager.GetScheme()} + if err := reconciler.SetupWithManager(manager); err != nil { + ctrl.Log.Error(err, "unable to create controller") + os.Exit(1) + } + must(manager.AddHealthzCheck("healthz", healthz.Ping)) + must(manager.AddReadyzCheck("readyz", healthz.Ping)) + if err := manager.Start(ctrl.SetupSignalHandler()); err != nil { + ctrl.Log.Error(err, "manager stopped") + os.Exit(1) + } +} + +func must(err error) { + if err != nil { + ctrl.Log.Error(err, "fatal setup error") + os.Exit(1) + } +} diff --git a/config/crd/mikrotikrouters.yaml b/config/crd/mikrotikrouters.yaml new file mode 100644 index 0000000..da92aef --- /dev/null +++ b/config/crd/mikrotikrouters.yaml @@ -0,0 +1,125 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: mikrotikrouters.mikrotik.brunner.ninja +spec: + group: mikrotik.brunner.ninja + scope: Cluster + names: + plural: mikrotikrouters + singular: mikrotikrouter + kind: MikroTikRouter + shortNames: + - mtr + versions: + - name: v1alpha1 + served: true + storage: true + additionalPrinterColumns: + - name: Endpoint + type: string + jsonPath: .spec.endpoint + - name: Ready + type: string + jsonPath: '.status.conditions[?(@.type=="Ready")].status' + - name: Rules + type: integer + jsonPath: .status.managedRules + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + type: object + required: + - endpoint + - credentialsSecretRef + properties: + endpoint: + type: string + pattern: '^https?://[^/]+/?$' + credentialsSecretRef: + type: object + required: + - namespace + - name + properties: + namespace: + type: string + minLength: 1 + name: + type: string + minLength: 1 + tls: + type: object + properties: + insecureSkipVerify: + type: boolean + caSecretRef: + type: object + required: + - namespace + - name + properties: + namespace: + type: string + minLength: 1 + name: + type: string + minLength: 1 + allowedNamespaces: + type: array + items: + type: string + minLength: 1 + uniqueItems: true + status: + type: object + properties: + observedGeneration: + type: integer + format: int64 + managedRules: + type: integer + format: int32 + lastSyncTime: + type: string + format: date-time + conditions: + type: array + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: + - type + items: + type: object + required: + - lastTransitionTime + - message + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + maxLength: 32768 + observedGeneration: + type: integer + format: int64 + reason: + type: string + status: + type: string + enum: ["True", "False", Unknown] + type: + type: string diff --git a/config/manager/deployment.yaml b/config/manager/deployment.yaml new file mode 100644 index 0000000..90e848c --- /dev/null +++ b/config/manager/deployment.yaml @@ -0,0 +1,53 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mikrotik-operator + namespace: mikrotik-operator-system + labels: + app.kubernetes.io/name: mikrotik-operator +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: mikrotik-operator + template: + metadata: + labels: + app.kubernetes.io/name: mikrotik-operator + spec: + serviceAccountName: mikrotik-operator + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: manager + image: registry.brunner.ninja/feedc0de/mikrotik-operator:dev + imagePullPolicy: Always + args: + - --leader-elect=true + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + readOnlyRootFilesystem: true + ports: + - name: metrics + containerPort: 8080 + - name: health + containerPort: 8081 + livenessProbe: + httpGet: + path: /healthz + port: health + readinessProbe: + httpGet: + path: /readyz + port: health + resources: + requests: + cpu: 20m + memory: 48Mi + limits: + memory: 128Mi + terminationGracePeriodSeconds: 10 diff --git a/config/rbac/operator.yaml b/config/rbac/operator.yaml new file mode 100644 index 0000000..aa87536 --- /dev/null +++ b/config/rbac/operator.yaml @@ -0,0 +1,69 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: mikrotik-operator-system +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mikrotik-operator + namespace: mikrotik-operator-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: mikrotik-operator +rules: + - apiGroups: [""] + resources: ["services"] + verbs: ["get", "list", "watch"] + # The controller only reads Secret objects explicitly referenced by a router. + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] + - apiGroups: ["mikrotik.brunner.ninja"] + resources: ["mikrotikrouters"] + verbs: ["get", "list", "watch", "patch", "update"] + - apiGroups: ["mikrotik.brunner.ninja"] + resources: ["mikrotikrouters/finalizers"] + verbs: ["update"] + - apiGroups: ["mikrotik.brunner.ninja"] + resources: ["mikrotikrouters/status"] + verbs: ["get", "patch", "update"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: mikrotik-operator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: mikrotik-operator +subjects: + - kind: ServiceAccount + name: mikrotik-operator + namespace: mikrotik-operator-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: mikrotik-operator-leader-election + namespace: mikrotik-operator-system +rules: + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: mikrotik-operator-leader-election + namespace: mikrotik-operator-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: mikrotik-operator-leader-election +subjects: + - kind: ServiceAccount + name: mikrotik-operator + namespace: mikrotik-operator-system diff --git a/examples/router.yaml b/examples/router.yaml new file mode 100644 index 0000000..e451302 --- /dev/null +++ b/examples/router.yaml @@ -0,0 +1,14 @@ +apiVersion: mikrotik.brunner.ninja/v1alpha1 +kind: MikroTikRouter +metadata: + name: newgw +spec: + endpoint: https://newgw.localdomain + credentialsSecretRef: + namespace: mikrotik-operator-system + name: newgw-credentials + tls: + # Use a caSecretRef in production. This is convenient only for first contact. + insecureSkipVerify: true + allowedNamespaces: + - factorio diff --git a/examples/service.yaml b/examples/service.yaml new file mode 100644 index 0000000..ec4959b --- /dev/null +++ b/examples/service.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: Service +metadata: + name: factorio + namespace: factorio + annotations: + metallb.io/loadBalancerIPs: "192.168.0.17" + mikrotik.brunner.ninja/router: newgw + mikrotik.brunner.ninja/service-port: game-udp + mikrotik.brunner.ninja/dst-port: "34197" + mikrotik.brunner.ninja/in-interface-list: WAN + # Optional matchers: + # mikrotik.brunner.ninja/dst-address: 203.0.113.10 + # mikrotik.brunner.ninja/dst-address-list: public-addresses + # mikrotik.brunner.ninja/src-address: 198.51.100.0/24 + # mikrotik.brunner.ninja/src-address-list: trusted-clients +spec: + type: LoadBalancer + selector: + app: factorio + ports: + - name: game-udp + port: 34197 + targetPort: 34197 + protocol: UDP diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c0358f0 --- /dev/null +++ b/go.mod @@ -0,0 +1,64 @@ +module github.com/feedc0de/mikrotik-operator + +go 1.26.0 + +require ( + k8s.io/api v0.36.0 + k8s.io/apimachinery v0.36.0 + k8s.io/client-go v0.36.0 + sigs.k8s.io/controller-runtime v0.24.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.14.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.36.0 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dbc04b4 --- /dev/null +++ b/go.sum @@ -0,0 +1,171 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= +github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= +github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= +k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34= +k8s.io/apiextensions-apiserver v0.36.0 h1:Wt7E8J+VBCbj4FjiBfDTK/neXDDjyJVJc7xfuOHImZ0= +k8s.io/apiextensions-apiserver v0.36.0/go.mod h1:kGDjH0msuiIB3tgsYRV0kS9GqpMYMUsQ3GHv7TApyug= +k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= +k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= +k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c= +k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/controller/router_controller.go b/internal/controller/router_controller.go new file mode 100644 index 0000000..3604194 --- /dev/null +++ b/internal/controller/router_controller.go @@ -0,0 +1,188 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + mikrotikv1alpha1 "github.com/feedc0de/mikrotik-operator/api/v1alpha1" + "github.com/feedc0de/mikrotik-operator/internal/routeros" +) + +const ( + syncInterval = 2 * time.Minute + routerFinalizer = "mikrotik.brunner.ninja/nat-cleanup" +) + +type RouterClientFactory func(endpoint, username, password string, tlsConfig routeros.TLSConfig) (routeros.API, error) + +type MikroTikRouterReconciler struct { + client.Client + Scheme *runtime.Scheme + ClientFactory RouterClientFactory +} + +func (r *MikroTikRouterReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) { + var router mikrotikv1alpha1.MikroTikRouter + if err := r.Get(ctx, request.NamespacedName, &router); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if !router.DeletionTimestamp.IsZero() { + return r.finalize(ctx, &router) + } + if !controllerutil.ContainsFinalizer(&router, routerFinalizer) { + controllerutil.AddFinalizer(&router, routerFinalizer) + if err := r.Update(ctx, &router); err != nil { + return ctrl.Result{}, fmt.Errorf("add cleanup finalizer: %w", err) + } + return ctrl.Result{Requeue: true}, nil + } + + api, err := r.routerClient(ctx, &router) + if err != nil { + if statusErr := r.setStatus(ctx, &router, 0, metav1.ConditionFalse, "ConfigurationError", err.Error()); statusErr != nil { + return ctrl.Result{}, errors.Join(err, statusErr) + } + return ctrl.Result{RequeueAfter: syncInterval}, nil + } + + var serviceList corev1.ServiceList + if err := r.List(ctx, &serviceList); err != nil { + return ctrl.Result{}, fmt.Errorf("list Services: %w", err) + } + desired, preserve, issues := DesiredRules(&router, serviceList.Items) + managedRules, err := api.Sync(ctx, desired, preserve) + if err != nil { + if statusErr := r.setStatus(ctx, &router, router.Status.ManagedRules, metav1.ConditionFalse, "RouterAPIError", err.Error()); statusErr != nil { + return ctrl.Result{}, errors.Join(err, statusErr) + } + return ctrl.Result{}, err + } + if len(issues) > 0 { + message := issueMessage(issues) + if err := r.setStatus(ctx, &router, int32(managedRules), metav1.ConditionFalse, "ServiceConfigurationError", message); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: syncInterval}, nil + } + if err := r.setStatus(ctx, &router, int32(managedRules), metav1.ConditionTrue, "Synchronized", "RouterOS NAT rules are synchronized"); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: syncInterval}, nil +} + +func (r *MikroTikRouterReconciler) finalize(ctx context.Context, router *mikrotikv1alpha1.MikroTikRouter) (ctrl.Result, error) { + if !controllerutil.ContainsFinalizer(router, routerFinalizer) { + return ctrl.Result{}, nil + } + api, err := r.routerClient(ctx, router) + if err != nil { + return ctrl.Result{}, fmt.Errorf("initialize RouterOS client for cleanup: %w", err) + } + if _, err := api.Sync(ctx, nil, map[string]struct{}{}); err != nil { + return ctrl.Result{}, fmt.Errorf("remove managed NAT rules during router deletion: %w", err) + } + controllerutil.RemoveFinalizer(router, routerFinalizer) + if err := r.Update(ctx, router); err != nil { + return ctrl.Result{}, fmt.Errorf("remove cleanup finalizer: %w", err) + } + return ctrl.Result{}, nil +} + +func (r *MikroTikRouterReconciler) routerClient(ctx context.Context, router *mikrotikv1alpha1.MikroTikRouter) (routeros.API, error) { + ref := router.Spec.CredentialsSecretRef + var credentials corev1.Secret + if err := r.Get(ctx, types.NamespacedName{Namespace: ref.Namespace, Name: ref.Name}, &credentials); err != nil { + if apierrors.IsNotFound(err) { + return nil, fmt.Errorf("credentials Secret %s/%s does not exist", ref.Namespace, ref.Name) + } + return nil, fmt.Errorf("read credentials Secret: %w", err) + } + username, usernameOK := credentials.Data["username"] + password, passwordOK := credentials.Data["password"] + if !usernameOK || !passwordOK { + return nil, errors.New("credentials Secret must contain username and password keys") + } + if len(username) == 0 || len(password) == 0 { + return nil, errors.New("credentials Secret username and password must not be empty") + } + + tlsConfig := routeros.TLSConfig{InsecureSkipVerify: router.Spec.TLS.InsecureSkipVerify} + if caRef := router.Spec.TLS.CASecretRef; caRef != nil { + var caSecret corev1.Secret + if err := r.Get(ctx, types.NamespacedName{Namespace: caRef.Namespace, Name: caRef.Name}, &caSecret); err != nil { + return nil, fmt.Errorf("read CA Secret %s/%s: %w", caRef.Namespace, caRef.Name, err) + } + var ok bool + tlsConfig.CABundle, ok = caSecret.Data["ca.crt"] + if !ok { + return nil, fmt.Errorf("CA Secret %s/%s must contain ca.crt", caRef.Namespace, caRef.Name) + } + } + return r.ClientFactory(router.Spec.Endpoint, string(username), string(password), tlsConfig) +} + +func (r *MikroTikRouterReconciler) setStatus(ctx context.Context, router *mikrotikv1alpha1.MikroTikRouter, count int32, conditionStatus metav1.ConditionStatus, reason, message string) error { + now := metav1.Now() + router.Status.ObservedGeneration = router.Generation + router.Status.ManagedRules = count + router.Status.LastSyncTime = &now + meta.SetStatusCondition(&router.Status.Conditions, metav1.Condition{ + Type: "Ready", Status: conditionStatus, Reason: reason, Message: message, + ObservedGeneration: router.Generation, + }) + return r.Status().Update(ctx, router) +} + +func issueMessage(issues []RuleIssue) string { + const maxIssues = 3 + messages := make([]string, 0, min(len(issues), maxIssues)) + for i, issue := range issues { + if i == maxIssues { + break + } + messages = append(messages, issue.Service+": "+issue.Message) + } + if len(issues) > maxIssues { + messages = append(messages, fmt.Sprintf("and %d more", len(issues)-maxIssues)) + } + return strings.Join(messages, "; ") +} + +func (r *MikroTikRouterReconciler) enqueueAllRouters(ctx context.Context, _ client.Object) []reconcile.Request { + var routers mikrotikv1alpha1.MikroTikRouterList + if err := r.List(ctx, &routers); err != nil { + return nil + } + requests := make([]reconcile.Request, 0, len(routers.Items)) + for i := range routers.Items { + requests = append(requests, reconcile.Request{NamespacedName: types.NamespacedName{Name: routers.Items[i].Name}}) + } + return requests +} + +func (r *MikroTikRouterReconciler) SetupWithManager(manager ctrl.Manager) error { + if r.ClientFactory == nil { + r.ClientFactory = func(endpoint, username, password string, tlsConfig routeros.TLSConfig) (routeros.API, error) { + return routeros.NewClient(endpoint, username, password, tlsConfig) + } + } + return ctrl.NewControllerManagedBy(manager). + For(&mikrotikv1alpha1.MikroTikRouter{}). + Watches(&corev1.Service{}, handler.EnqueueRequestsFromMapFunc(r.enqueueAllRouters)). + Complete(r) +} diff --git a/internal/controller/service_rules.go b/internal/controller/service_rules.go new file mode 100644 index 0000000..48c92a8 --- /dev/null +++ b/internal/controller/service_rules.go @@ -0,0 +1,120 @@ +package controller + +import ( + "fmt" + "net/netip" + "strconv" + "strings" + + corev1 "k8s.io/api/core/v1" + + mikrotikv1alpha1 "github.com/feedc0de/mikrotik-operator/api/v1alpha1" + "github.com/feedc0de/mikrotik-operator/internal/routeros" +) + +const AnnotationPrefix = "mikrotik.brunner.ninja/" + +const ( + AnnotationRouter = AnnotationPrefix + "router" + AnnotationServicePort = AnnotationPrefix + "service-port" + AnnotationDestinationPort = AnnotationPrefix + "dst-port" + AnnotationDestinationIP = AnnotationPrefix + "dst-address" + AnnotationDestinationList = AnnotationPrefix + "dst-address-list" + AnnotationInInterface = AnnotationPrefix + "in-interface" + AnnotationInInterfaceList = AnnotationPrefix + "in-interface-list" + AnnotationSourceAddress = AnnotationPrefix + "src-address" + AnnotationSourceList = AnnotationPrefix + "src-address-list" +) + +type RuleIssue struct { + Service string + Message string +} + +func DesiredRules(router *mikrotikv1alpha1.MikroTikRouter, services []corev1.Service) ([]routeros.Rule, map[string]struct{}, []RuleIssue) { + desired := make([]routeros.Rule, 0) + preserve := make(map[string]struct{}) + issues := make([]RuleIssue, 0) + for i := range services { + service := &services[i] + if service.Annotations[AnnotationRouter] != router.Name || !namespaceAllowed(router.Spec.AllowedNamespaces, service.Namespace) { + continue + } + comment := routeros.ManagedComment(service.Namespace, service.Name) + rule, err := ruleForService(service, comment) + if err != nil { + preserve[comment] = struct{}{} + issues = append(issues, RuleIssue{Service: service.Namespace + "/" + service.Name, Message: err.Error()}) + continue + } + desired = append(desired, rule) + } + return desired, preserve, issues +} + +func namespaceAllowed(allowed []string, namespace string) bool { + for _, item := range allowed { + if item == "*" || item == namespace { + return true + } + } + return false +} + +func ruleForService(service *corev1.Service, comment string) (routeros.Rule, error) { + if service.Spec.Type != corev1.ServiceTypeLoadBalancer { + return routeros.Rule{}, fmt.Errorf("Service must have type LoadBalancer") + } + port, err := selectServicePort(service) + if err != nil { + return routeros.Rule{}, err + } + toAddress := "" + for _, ingress := range service.Status.LoadBalancer.Ingress { + address, parseErr := netip.ParseAddr(ingress.IP) + if parseErr == nil && address.Is4() { + toAddress = address.String() + break + } + } + if toAddress == "" { + return routeros.Rule{}, fmt.Errorf("waiting for an IPv4 address in status.loadBalancer.ingress") + } + + destinationPort := service.Annotations[AnnotationDestinationPort] + if destinationPort == "" { + destinationPort = strconv.Itoa(int(port.Port)) + } + parsedDestinationPort, err := strconv.ParseUint(destinationPort, 10, 16) + if err != nil || parsedDestinationPort == 0 { + return routeros.Rule{}, fmt.Errorf("%s must be a port from 1 through 65535", AnnotationDestinationPort) + } + + return routeros.Rule{ + Comment: comment, Protocol: strings.ToLower(string(port.Protocol)), + DestinationPort: destinationPort, + DestinationIP: service.Annotations[AnnotationDestinationIP], + DestinationList: service.Annotations[AnnotationDestinationList], + InInterface: service.Annotations[AnnotationInInterface], + InInterfaceList: service.Annotations[AnnotationInInterfaceList], + SourceAddress: service.Annotations[AnnotationSourceAddress], + SourceList: service.Annotations[AnnotationSourceList], + ToAddress: toAddress, ToPort: strconv.Itoa(int(port.Port)), + }, nil +} + +func selectServicePort(service *corev1.Service) (corev1.ServicePort, error) { + selector := service.Annotations[AnnotationServicePort] + if selector == "" { + if len(service.Spec.Ports) != 1 { + return corev1.ServicePort{}, fmt.Errorf("%s is required when the Service has more than one port", AnnotationServicePort) + } + return service.Spec.Ports[0], nil + } + for _, port := range service.Spec.Ports { + if port.Name == selector || strconv.Itoa(int(port.Port)) == selector { + return port, nil + } + } + return corev1.ServicePort{}, fmt.Errorf("%s %q does not match a Service port name or number", AnnotationServicePort, selector) +} diff --git a/internal/controller/service_rules_test.go b/internal/controller/service_rules_test.go new file mode 100644 index 0000000..dbf6fbe --- /dev/null +++ b/internal/controller/service_rules_test.go @@ -0,0 +1,88 @@ +package controller + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + mikrotikv1alpha1 "github.com/feedc0de/mikrotik-operator/api/v1alpha1" + "github.com/feedc0de/mikrotik-operator/internal/routeros" +) + +func TestDesiredRules(t *testing.T) { + router := &mikrotikv1alpha1.MikroTikRouter{ + ObjectMeta: metav1.ObjectMeta{Name: "edge"}, + Spec: mikrotikv1alpha1.MikroTikRouterSpec{AllowedNamespaces: []string{"games"}}, + } + service := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "games", Name: "factorio", + Annotations: map[string]string{ + AnnotationRouter: "edge", AnnotationServicePort: "game", + AnnotationDestinationPort: "40000", AnnotationInInterfaceList: "WAN", + }, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{ + {Name: "game", Port: 34197, Protocol: corev1.ProtocolUDP}, + {Name: "rcon", Port: 27015, Protocol: corev1.ProtocolTCP}, + }, + }, + Status: corev1.ServiceStatus{LoadBalancer: corev1.LoadBalancerStatus{Ingress: []corev1.LoadBalancerIngress{ + {IP: "2001:db8::1"}, {IP: "192.168.0.17"}, + }}}, + } + + rules, preserve, issues := DesiredRules(router, []corev1.Service{service}) + if len(issues) != 0 { + t.Fatalf("unexpected issues: %#v", issues) + } + if len(preserve) != 0 { + t.Fatalf("unexpected preserve set: %#v", preserve) + } + if len(rules) != 1 { + t.Fatalf("got %d rules, want 1", len(rules)) + } + want := routeros.Rule{ + Comment: routeros.ManagedComment("games", "factorio"), Protocol: "udp", + DestinationPort: "40000", InInterfaceList: "WAN", + ToAddress: "192.168.0.17", ToPort: "34197", + } + if rules[0] != want { + t.Fatalf("rule = %#v, want %#v", rules[0], want) + } +} + +func TestDesiredRulesPreservesRuleWhileLoadBalancerIsPending(t *testing.T) { + router := &mikrotikv1alpha1.MikroTikRouter{ + ObjectMeta: metav1.ObjectMeta{Name: "edge"}, + Spec: mikrotikv1alpha1.MikroTikRouterSpec{AllowedNamespaces: []string{"games"}}, + } + service := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Namespace: "games", Name: "factorio", Annotations: map[string]string{AnnotationRouter: "edge"}}, + Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeLoadBalancer, Ports: []corev1.ServicePort{{Port: 34197, Protocol: corev1.ProtocolUDP}}}, + } + rules, preserve, issues := DesiredRules(router, []corev1.Service{service}) + if len(rules) != 0 || len(issues) != 1 { + t.Fatalf("rules=%#v issues=%#v", rules, issues) + } + if _, ok := preserve[routeros.ManagedComment("games", "factorio")]; !ok { + t.Fatal("pending rule was not preserved") + } +} + +func TestDesiredRulesDoesNotHonorUnlistedNamespace(t *testing.T) { + router := &mikrotikv1alpha1.MikroTikRouter{ + ObjectMeta: metav1.ObjectMeta{Name: "edge"}, + Spec: mikrotikv1alpha1.MikroTikRouterSpec{AllowedNamespaces: []string{"trusted"}}, + } + service := corev1.Service{ObjectMeta: metav1.ObjectMeta{ + Namespace: "untrusted", Name: "bad", Annotations: map[string]string{AnnotationRouter: "edge"}, + }} + rules, preserve, issues := DesiredRules(router, []corev1.Service{service}) + if len(rules) != 0 || len(preserve) != 0 || len(issues) != 0 { + t.Fatalf("unlisted namespace affected reconciliation: rules=%#v preserve=%#v issues=%#v", rules, preserve, issues) + } +} diff --git a/internal/routeros/client.go b/internal/routeros/client.go new file mode 100644 index 0000000..7114dbe --- /dev/null +++ b/internal/routeros/client.go @@ -0,0 +1,247 @@ +package routeros + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strings" + "time" +) + +const managedCommentPrefix = "k8s-mikrotik/" + +var managedFields = []string{ + "action", "chain", "comment", "protocol", "dst-port", "dst-address", + "dst-address-list", "in-interface", "in-interface-list", "src-address", + "src-address-list", "to-addresses", "to-ports", +} + +type TLSConfig struct { + InsecureSkipVerify bool + CABundle []byte +} + +// Rule is the complete portion of a RouterOS dst-nat rule owned by the operator. +type Rule struct { + Comment string + Protocol string + DestinationPort string + DestinationIP string + DestinationList string + InInterface string + InInterfaceList string + SourceAddress string + SourceList string + ToAddress string + ToPort string +} + +func (r Rule) fields(includeEmpty bool) map[string]string { + all := map[string]string{ + "action": "dst-nat", "chain": "dstnat", "comment": r.Comment, + "protocol": r.Protocol, "dst-port": r.DestinationPort, + "dst-address": r.DestinationIP, "dst-address-list": r.DestinationList, + "in-interface": r.InInterface, "in-interface-list": r.InInterfaceList, + "src-address": r.SourceAddress, "src-address-list": r.SourceList, + "to-addresses": r.ToAddress, "to-ports": r.ToPort, + } + if includeEmpty { + return all + } + for key, value := range all { + if value == "" { + delete(all, key) + } + } + return all +} + +type API interface { + Sync(context.Context, []Rule, map[string]struct{}) (int, error) +} + +type Client struct { + baseURL *url.URL + username string + password string + http *http.Client +} + +func NewClient(endpoint, username, password string, tlsConfig TLSConfig) (*Client, error) { + base, err := url.Parse(strings.TrimRight(endpoint, "/")) + if err != nil { + return nil, fmt.Errorf("parse endpoint: %w", err) + } + if base.Scheme != "https" && base.Scheme != "http" { + return nil, errors.New("endpoint scheme must be https or http") + } + if base.Host == "" { + return nil, errors.New("endpoint must include a hostname") + } + if base.User != nil { + return nil, errors.New("endpoint must not contain credentials") + } + if base.Path != "" || base.RawQuery != "" || base.Fragment != "" { + return nil, errors.New("endpoint must be an origin without a path, query, or fragment") + } + if tlsConfig.InsecureSkipVerify && len(tlsConfig.CABundle) > 0 { + return nil, errors.New("insecureSkipVerify and a CA bundle are mutually exclusive") + } + + tlsClientConfig := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: tlsConfig.InsecureSkipVerify} // #nosec G402 -- explicit CRD opt-in + if len(tlsConfig.CABundle) > 0 { + roots, err := x509.SystemCertPool() + if err != nil { + return nil, fmt.Errorf("load system CA pool: %w", err) + } + if !roots.AppendCertsFromPEM(tlsConfig.CABundle) { + return nil, errors.New("CA Secret contains no valid PEM certificates") + } + tlsClientConfig.RootCAs = roots + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = tlsClientConfig + return &Client{ + baseURL: base, username: username, password: password, + http: &http.Client{Transport: transport, Timeout: 20 * time.Second}, + }, nil +} + +func (c *Client) Sync(ctx context.Context, desired []Rule, preserve map[string]struct{}) (int, error) { + current, err := c.listNAT(ctx) + if err != nil { + return 0, err + } + + byComment := make(map[string][]map[string]string) + for _, rule := range current { + if strings.HasPrefix(rule["comment"], managedCommentPrefix) { + byComment[rule["comment"]] = append(byComment[rule["comment"]], rule) + } + } + slices.SortFunc(desired, func(a, b Rule) int { return strings.Compare(a.Comment, b.Comment) }) + desiredComments := make(map[string]struct{}, len(desired)) + + for _, rule := range desired { + if !strings.HasPrefix(rule.Comment, managedCommentPrefix) { + return 0, fmt.Errorf("refusing unmanaged rule comment %q", rule.Comment) + } + desiredComments[rule.Comment] = struct{}{} + matches := byComment[rule.Comment] + if len(matches) == 0 { + if err := c.create(ctx, rule.fields(false)); err != nil { + return 0, fmt.Errorf("create %s: %w", rule.Comment, err) + } + continue + } + if !sameManagedFields(matches[0], rule.fields(true)) { + if err := c.patch(ctx, matches[0][".id"], rule.fields(true)); err != nil { + return 0, fmt.Errorf("update %s: %w", rule.Comment, err) + } + } + for _, duplicate := range matches[1:] { + if err := c.delete(ctx, duplicate[".id"]); err != nil { + return 0, fmt.Errorf("delete duplicate %s: %w", rule.Comment, err) + } + } + } + + managedCount := len(desired) + for comment, rules := range byComment { + if _, wanted := desiredComments[comment]; wanted { + continue + } + if _, keep := preserve[comment]; keep { + managedCount++ + continue + } + for _, rule := range rules { + if err := c.delete(ctx, rule[".id"]); err != nil { + return 0, fmt.Errorf("delete stale %s: %w", comment, err) + } + } + } + return managedCount, nil +} + +func sameManagedFields(current, desired map[string]string) bool { + for _, key := range managedFields { + if current[key] != desired[key] { + return false + } + } + return true +} + +func (c *Client) listNAT(ctx context.Context) ([]map[string]string, error) { + var response []map[string]string + if err := c.do(ctx, http.MethodGet, []string{"rest", "ip", "firewall", "nat"}, nil, &response); err != nil { + return nil, fmt.Errorf("list NAT rules: %w", err) + } + return response, nil +} + +func (c *Client) create(ctx context.Context, fields map[string]string) error { + return c.do(ctx, http.MethodPut, []string{"rest", "ip", "firewall", "nat"}, fields, nil) +} + +func (c *Client) patch(ctx context.Context, id string, fields map[string]string) error { + if id == "" { + return errors.New("RouterOS returned a NAT rule without .id") + } + return c.do(ctx, http.MethodPatch, []string{"rest", "ip", "firewall", "nat", id}, fields, nil) +} + +func (c *Client) delete(ctx context.Context, id string) error { + if id == "" { + return errors.New("RouterOS returned a NAT rule without .id") + } + return c.do(ctx, http.MethodDelete, []string{"rest", "ip", "firewall", "nat", id}, nil, nil) +} + +func (c *Client) do(ctx context.Context, method string, path []string, input, output any) error { + requestURL := c.baseURL.JoinPath(path...).String() + var body io.Reader + if input != nil { + encoded, err := json.Marshal(input) + if err != nil { + return err + } + body = bytes.NewReader(encoded) + } + req, err := http.NewRequestWithContext(ctx, method, requestURL, body) + if err != nil { + return err + } + req.SetBasicAuth(c.username, c.password) + if input != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= http.StatusBadRequest { + message, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("RouterOS returned %s: %s", resp.Status, strings.TrimSpace(string(message))) + } + if output != nil { + if err := json.NewDecoder(resp.Body).Decode(output); err != nil { + return fmt.Errorf("decode RouterOS response: %w", err) + } + } + return nil +} + +func ManagedComment(namespace, name string) string { + return managedCommentPrefix + namespace + "/" + name +} diff --git a/internal/routeros/client_test.go b/internal/routeros/client_test.go new file mode 100644 index 0000000..6f0904f --- /dev/null +++ b/internal/routeros/client_test.go @@ -0,0 +1,96 @@ +package routeros + +import ( + "context" + "encoding/json" + "io" + "net/http" + "slices" + "strings" + "testing" +) + +func TestSyncOwnsOnlyPrefixedRules(t *testing.T) { + requests := make([]string, 0) + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + username, password, ok := request.BasicAuth() + if !ok || username != "operator" || password != "secret" { + t.Errorf("wrong Basic Auth credentials") + } + requests = append(requests, request.Method+" "+request.URL.Path) + body := `{}` + if request.Method == http.MethodGet { + encoded, err := json.Marshal([]map[string]string{ + {".id": "*1", "comment": "manual rule", "chain": "dstnat"}, + {".id": "*2", "comment": "k8s-mikrotik/games/factorio", "chain": "dstnat", "action": "dst-nat", "protocol": "udp", "dst-port": "34197", "to-addresses": "192.168.0.99", "to-ports": "34197"}, + {".id": "*3", "comment": "k8s-mikrotik/old/gone", "chain": "dstnat", "action": "dst-nat"}, + {".id": "*4", "comment": "k8s-mikrotik/pending/service", "chain": "dstnat", "action": "dst-nat"}, + }) + if err != nil { + return nil, err + } + body = string(encoded) + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: request, + }, nil + }) + + client, err := NewClient("https://router.test", "operator", "secret", TLSConfig{}) + if err != nil { + t.Fatal(err) + } + client.http.Transport = transport + desired := []Rule{ + {Comment: "k8s-mikrotik/games/factorio", Protocol: "udp", DestinationPort: "34197", ToAddress: "192.168.0.17", ToPort: "34197"}, + {Comment: "k8s-mikrotik/new/service", Protocol: "tcp", DestinationPort: "443", ToAddress: "192.168.0.18", ToPort: "8443"}, + } + count, err := client.Sync(context.Background(), desired, map[string]struct{}{"k8s-mikrotik/pending/service": {}}) + if err != nil { + t.Fatal(err) + } + if count != 3 { + t.Fatalf("managed count = %d, want 3", count) + } + + got := slices.Clone(requests) + want := []string{ + "GET /rest/ip/firewall/nat", + "PATCH /rest/ip/firewall/nat/*2", + "PUT /rest/ip/firewall/nat", + "DELETE /rest/ip/firewall/nat/*3", + } + if !slices.Equal(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } + for _, request := range got { + if strings.Contains(request, "*1") || strings.Contains(request, "*4") { + t.Fatalf("operator touched a manual or preserved rule: %s", request) + } + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (function roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return function(request) +} + +func TestNewClientRejectsInvalidEndpointAndCA(t *testing.T) { + if _, err := NewClient("router.local", "u", "p", TLSConfig{}); err == nil { + t.Fatal("expected missing scheme to fail") + } + if _, err := NewClient("https://user:password@router.local", "u", "p", TLSConfig{}); err == nil { + t.Fatal("expected credentials in endpoint to fail") + } + if _, err := NewClient("https://router.local/rest", "u", "p", TLSConfig{}); err == nil { + t.Fatal("expected endpoint path to fail") + } + if _, err := NewClient("https://router.local", "u", "p", TLSConfig{CABundle: []byte("not PEM")}); err == nil { + t.Fatal("expected invalid CA to fail") + } +}