feedc0de b4185368f6
Validate and publish / Test image and chart (push) Successful in 2m9s
Validate and publish / Publish image and OCI chart (push) Successful in 34s
Trying to fix CI really this time
2026-08-28 23:30:02 +02:00
2026-08-28 23:12:14 +02:00
2026-08-28 23:12:14 +02:00
2026-08-28 23:22:26 +02:00
2026-08-28 23:12:14 +02:00
2026-08-28 23:12:14 +02:00
2026-08-28 23:22:26 +02:00
2026-08-28 23:22:26 +02:00
2026-08-28 23:22:26 +02:00
2026-08-28 23:12:14 +02:00
2026-08-28 23:12:14 +02:00
2026-08-28 23:22:26 +02:00
2026-08-28 23:12:14 +02:00
2026-08-28 22:51:05 +02:00
2026-08-28 23:12:14 +02:00
2026-08-28 23:12:14 +02:00

MikroTik operator

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:

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:

/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:

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:

kubectl apply -f config/crd/mikrotikrouters.yaml

For a local development build and chart install:

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:

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:

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:

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.

S
Description
Kubernetes Mikrotik Operator to automatically create router port-forwardings to publish kubernetes services to the internet
Readme GPL-2.0
94 KiB
Languages
Go 87.8%
Go Template 5%
Makefile 3.3%
Shell 2.5%
Dockerfile 1.4%