Initial commit
Validate and publish / Test image and chart (push) Failing after 2m25s
Validate and publish / Publish image and OCI chart (push) Skipped

This commit is contained in:
2026-08-28 23:12:14 +02:00
parent 09fe9553d7
commit d45d84462d
32 changed files with 2401 additions and 2 deletions
+8
View File
@@ -0,0 +1,8 @@
.git
.gitea
chart
config
examples
manager
*.tgz
README.md
+133
View File
@@ -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}"
+2
View File
@@ -0,0 +1,2 @@
/manager
/coverage.out
+11
View File
@@ -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"]
+39
View File
@@ -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)
+190 -2
View File
@@ -1,3 +1,191 @@
# mikrotik-operator
# MikroTik operator
Kubernetes Mikrotik Operator to automatically create router port-forwardings to publish kubernetes services to the internet
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/<service-namespace>/<service-name>`. 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=<strong-password> address=<operator-egress-cidr>
/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.
+15
View File
@@ -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
)
+61
View File
@@ -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{})
}
+113
View File
@@ -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
}
+16
View File
@@ -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
@@ -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
@@ -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" . }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }
}
}
+60
View File
@@ -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
+66
View File
@@ -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)
}
}
+125
View File
@@ -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
+53
View File
@@ -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
+69
View File
@@ -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
+14
View File
@@ -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
+25
View File
@@ -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
+64
View File
@@ -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
)
+171
View File
@@ -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=
+188
View File
@@ -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)
}
+120
View File
@@ -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)
}
+88
View File
@@ -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)
}
}
+247
View File
@@ -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
}
+96
View File
@@ -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")
}
}