Initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.gitea
|
||||
chart
|
||||
config
|
||||
examples
|
||||
manager
|
||||
*.tgz
|
||||
README.md
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,2 @@
|
||||
/manager
|
||||
/coverage.out
|
||||
+11
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user