Compare commits

..
Author SHA1 Message Date
renovate-bot 50307fe748 chore(deps): update ci non-major
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
2026-11-02 03:01:57 +00:00
security-bot 03cf6a0e45 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-1059
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
Updates managed by image-automation-controller.
2026-11-01 11:07:04 +00:00
k.renner 88576f7022 Merge pull request 'revert(authentik-gate-sync): roll back OIDC clients (#1535, #1536), gates are being deleted' (#1537) from revert/gate-sync-oidc into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1537
2026-11-01 10:59:29 +00:00
hermes-bot 8c85a49ae5 Revert "Merge pull request 'feat(authentik-gate-sync): OIDC clients from OIDCClient resources' (#1535) from feat/gate-sync-oidc into main"
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
This reverts commit 09a03dfd76, reversing
changes made to e518b46045.
2026-11-01 10:44:22 +00:00
hermes-bot 8fa68bc9cd Revert "Merge pull request 'fix(authentik-gate-sync): stop sweeping every gate as an OIDC client' (#1536) from fix/gate-sync-proxy-sweep into main"
This reverts commit 60dfcf5382, reversing
changes made to 1a99c02996.
2026-11-01 10:44:22 +00:00
security-bot 20d928aeba chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-1014
Updates managed by image-automation-controller.
2026-11-01 10:37:04 +00:00
k.renner 60dfcf5382 Merge pull request 'fix(authentik-gate-sync): stop sweeping every gate as an OIDC client' (#1536) from fix/gate-sync-proxy-sweep into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1536
2026-11-01 10:13:55 +00:00
hermes-bot 5e814c525a fix(authentik-gate-sync): stop sweeping every gate as an OIDC client
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Outage since 10:11Z: the first pass of image 2026-11-01-0959 (#1535)
removed all 14 gates ("removed OIDC client from Authentik", "14 objects
removed"), and every pass since recreates and removes them again.

ProxyProvider subclasses OAuth2Provider (authentik 2026.8.4
providers/proxy/models.py: class ProxyProvider(OutpostModel,
OAuth2Provider)), so GET providers/oauth2/ also lists every proxy
provider. owned_applications() yielded each gate a second time as an
OAuth2 application; it was not in the OIDC keep set, so the sweep
deleted its application and provider.

Both the sweep and ensure_oidc's provider lookup now take from
providers/oauth2/ only providers whose meta_model_name is
authentik_providers_oauth2.oauth2provider. The test fake now lists proxy
providers under providers/oauth2/ like the real API; the new tests
(gate-only and gate + OIDC client stable over two passes) fail against
the previous controller.
2026-11-01 10:12:58 +00:00
security-bot 1a99c02996 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-0959
Updates managed by image-automation-controller.
2026-11-01 10:07:04 +00:00
k.renner 09a03dfd76 Merge pull request 'feat(authentik-gate-sync): OIDC clients from OIDCClient resources' (#1535) from feat/gate-sync-oidc into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1535
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-01 09:59:01 +00:00
19 changed files with 60 additions and 614 deletions
+16 -16
View File
@@ -47,7 +47,7 @@ when:
steps:
- name: build-ansible
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/ansible
# Only rebuild when THIS image's source (or the pipeline file) changed, or
# on a manual run. Without this every step ran on any matching push, so a
@@ -74,7 +74,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-ansible:latest
- name: build-k8s
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/k8s
when:
- event: deployment
@@ -97,7 +97,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
- name: build-vmtest
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/vmtest
when:
- event: deployment
@@ -120,7 +120,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-vmtest:latest
- name: build-tofu
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/tofu
when:
- event: deployment
@@ -150,7 +150,7 @@ steps:
# Runtime image, not CI tooling: a static musl binary in a FROM-scratch image,
# used as the base for secret-rotation CronJobs. Replaces per-job inline Python.
- name: build-secret-rotator
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: secret-rotator
when:
- event: deployment
@@ -175,7 +175,7 @@ steps:
# review-bot: the Go service that reviews pull requests with a Mistral agent (webhook plus sweep).
# Runs after Gitea is up, so no ghcr mirror.
- name: build-review-bot
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: review-bot
when:
- event: deployment
@@ -225,7 +225,7 @@ steps:
#
# The gitea_admin/ copy stays until nothing references it any more.
- name: build-alpine-jq
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/alpine-jq
when:
- event: deployment
@@ -276,7 +276,7 @@ steps:
# note on build-alpine-jq. Anything an app needs while the cluster is still
# being built cannot live only in that cluster's own registry.
- name: build-cni-plugins
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/cni-plugins
when:
- event: deployment
@@ -316,7 +316,7 @@ steps:
# gitea.renner.uno is not there to serve it. A cold-rebuild rehearsal left
# paperless in Init:ErrImagePull on exactly this image.
- name: build-alpine-openssl
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/alpine-openssl
when:
- event: deployment
@@ -351,7 +351,7 @@ steps:
# Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it
# builds the same way and lands in the same registry so the audit sees it.
- name: build-heating-can
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/heating-can
when:
- event: deployment
@@ -377,7 +377,7 @@ steps:
# services (public Ingress hosts -> TLS passthrough -> Traefik ClusterIP).
# Runtime artifact like heating-can; same registry so the audit tracks it.
- name: build-netbird-service-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/netbird-service-sync
when:
- event: deployment
@@ -402,7 +402,7 @@ steps:
# authentik-gate-sync: the authentik-gate ingress class (Ingress -> Authentik
# proxy gate + Traefik route). Its unit tests run as a stage of the image build.
- name: build-authentik-gate-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/authentik-gate-sync
when:
- event: deployment
@@ -428,7 +428,7 @@ steps:
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. Its unit
# tests run as a stage of the image build.
- name: build-kuma-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/kuma-sync
when:
- event: deployment
@@ -454,7 +454,7 @@ steps:
# cloudflare-lb-sync: reconciles CloudflareLoadBalancer resources into
# Cloudflare. Its unit tests run as a stage of the image build.
- name: build-cloudflare-lb-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/cloudflare-lb-sync
when:
- event: deployment
@@ -480,7 +480,7 @@ steps:
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
# artifact like heating-can; same registry so the audit tracks it.
- name: build-bird
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/bird
when:
- event: deployment
@@ -504,7 +504,7 @@ steps:
# Orchestrator for the monthly backup-verify CronJob (velero ns).
- name: build-backup-verify
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/backup-verify
when:
- event: deployment
+1 -1
View File
@@ -131,7 +131,7 @@ steps:
# --build-arg to the resolved version (not "latest") guarantees the tag and
# the image content agree even if a new release lands mid-run.
- name: build
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/claude
depends_on: [decide]
environment:
+1 -1
View File
@@ -172,7 +172,7 @@ steps:
# whenever it trails the deployed server. If you bump the server, bump here.
- name: woodpecker-lint
depends_on: [] # see note above `steps:`
image: woodpeckerci/woodpecker-cli:v3.19.0-alpine@sha256:27a77117e38d7bbdc380f0e90ff3ee4871ab1eefa78b5d9136a2c969fbecd9f4
image: woodpeckerci/woodpecker-cli:v3.20.1-alpine@sha256:74c576faf49cce699825d11c7d7bd38ab5f007dd4f57cdbdd55099d2c0f2621a
commands:
- |
set -eu
+1 -1
View File
@@ -63,7 +63,7 @@ steps:
# bridge, and the secret guard.
- name: hermes-skill-tests
depends_on: []
image: python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
image: python:3.15-alpine@sha256:20a82dbc2783112ddb91d1534603f802d222a3e63e593cd002eb2ac534e70211
environment:
PYTHONDONTWRITEBYTECODE: "1"
commands:
+1 -1
View File
@@ -10,7 +10,7 @@ when:
steps:
- name: build
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/hermes
environment:
# registry-bot write:package PAT (konairius org secret).
+1 -1
View File
@@ -6,7 +6,7 @@ when:
steps:
- name: go-test
image: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5
image: golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9
commands:
- test -z "$(gofmt -l review-bot)"
- go -C review-bot vet ./...
+2 -2
View File
@@ -5,12 +5,12 @@
# The unit tests gate the image: the final stage copies the controller out of
# this stage, so a failing test fails the build.
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS test
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913 AS test
WORKDIR /src
COPY controller.py test_controller.py ./
RUN python3 -B -m unittest -v
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY --from=test /src/controller.py /opt/controller.py
+24 -228
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""authentik-gate-sync: the Authentik side of the gate Middleware and of OIDC clients.
"""authentik-gate-sync: the Authentik side of the gate Middleware.
An Ingress is gated when its traefik.ingress.kubernetes.io/router.middlewares
annotation names the gate Middleware (authentik/gate, a ForwardAuth against the
@@ -18,16 +18,6 @@ Annotations (all optional):
auth.renner.uno/authorization-flow authorization flow slug (default below)
auth.renner.uno/skip-path provider skip_path_regex
OIDC clients: each OIDCClient (auth.renner.uno/v1alpha1, CRD in
infrastructure/authentik-gate-sync-crds) gets a confidential OAuth2/OpenID
provider oidc-<slug> (client ID = slug, secret generated by Authentik) and an
application with that slug, so the issuer is <authentik>/application/o/<slug>/;
with spec.group the same group policy as gates. Its status carries Ready and
the issuer. OIDC slugs and gate names are kept apart: a slug that is a gate's
name, or an existing application without this controller's OAuth2 provider,
is refused. An unassigned provider named oidc-<slug> is adopted (the remains
of an interrupted teardown, which deletes the application first).
Fail closed: until the provider exists, or after it is removed (e.g. the named
group is gone), the embedded outpost has no provider for the host and rejects
the request; Traefik answers 500 when the outpost is unreachable.
@@ -40,7 +30,6 @@ import hashlib
import json
import logging
import os
import re
import sys
import time
import urllib.error
@@ -57,10 +46,6 @@ DEFAULT_AUTHZ_FLOW = os.environ.get("DEFAULT_AUTHORIZATION_FLOW", "default-provi
INVALIDATION_FLOW = os.environ.get("INVALIDATION_FLOW", "default-provider-invalidation-flow")
COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", "renner.uno")
AK_BROWSER_URL = os.environ.get("AUTHENTIK_HOST_BROWSER", "https://auth.renner.uno/")
SIGNING_KEY = os.environ.get("OIDC_SIGNING_KEY", "authentik Self-signed Certificate")
OIDC_SCOPES = tuple(f"goauthentik.io/providers/oauth2/scope-{s}" for s in ("openid", "profile", "email"))
OIDC_GRANTS = ["authorization_code", "refresh_token"]
SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$")
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
HTTP_TIMEOUT = 20
@@ -249,74 +234,6 @@ def provider_body(gate, flows):
}
OIDC_API = "/apis/auth.renner.uno/v1alpha1"
def oidc_slug(cr):
return ((cr.get("spec") or {}).get("slug") or cr["metadata"]["name"]).strip()
def oidc_spec(cr):
"""Translate an OIDCClient into the client it asks for. Raises GateError."""
meta, spec = cr["metadata"], cr.get("spec") or {}
slug = oidc_slug(cr)
if not SLUG_RE.match(slug):
raise GateError(f"slug {slug!r} is not a slug (a-z, 0-9, -; max 50)")
launch = spec.get("launchURL", "")
if not launch.startswith(("https://", "http://")):
raise GateError(f"launchURL {launch!r} is not an http(s) URL")
redirects = [{"matching_mode": r.get("matchingMode", "strict"), "url": r.get("url", ""),
"redirect_uri_type": "authorization"}
for r in spec.get("redirectURIs") or []]
if not redirects or any(not r["url"] or r["matching_mode"] not in ("strict", "regex") for r in redirects):
raise GateError("redirectURIs needs at least one entry with url and matchingMode strict|regex")
for r in redirects:
if r["matching_mode"] == "regex":
try:
re.compile(r["url"])
except re.error as e:
raise GateError(f"redirect regex {r['url']!r}: {e}") from None
return {
# name: provider and policy name; slug: application slug and client ID.
"name": f"oidc-{slug}",
"slug": slug,
"namespace": meta["namespace"],
"resource": meta["name"],
"launch_url": launch,
"group": spec.get("group") or None,
"authorization_flow": spec.get("authorizationFlow") or DEFAULT_AUTHZ_FLOW,
"redirect_uris": redirects,
}
def oidc_status(cr, ok, message):
cond = {"type": "Ready", "status": "True" if ok else "False",
"reason": "Reconciled" if ok else "Failed", "message": message[:1000],
"observedGeneration": cr["metadata"].get("generation", 0)}
old = next((c for c in (cr.get("status") or {}).get("conditions") or [] if c.get("type") == "Ready"), {})
same = all(old.get(k) == cond[k] for k in ("status", "reason", "message", "observedGeneration"))
cond["lastTransitionTime"] = old.get("lastTransitionTime") if same and old.get("lastTransitionTime") \
else time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
status = {"conditions": [cond], "observedGeneration": cond["observedGeneration"]}
if ok:
status["issuer"] = f"{AK_BROWSER_URL}application/o/{oidc_slug(cr)}/"
return status
def oauth2_body(spec, flows, refs):
return {
"name": spec["name"],
"authorization_flow": flows[spec["authorization_flow"]],
"invalidation_flow": flows[INVALIDATION_FLOW],
"client_type": "confidential",
"client_id": spec["slug"],
"grant_types": OIDC_GRANTS,
"signing_key": refs["signing_key"],
"property_mappings": sorted(refs["scopes"]),
"redirect_uris": spec["redirect_uris"],
}
def drift(current, desired):
return {k: v for k, v in desired.items() if current.get(k) != v}
@@ -347,18 +264,6 @@ class K8s:
return http("PATCH", K8S_API + path, obj, headers=self._h(), cafile=K8S_CA_FILE,
content_type="application/apply-patch+yaml")
def patch_status(self, path, status):
return http("PATCH", K8S_API + path + "/status", {"status": status}, headers=self._h(),
cafile=K8S_CA_FILE, content_type="application/merge-patch+json")
def oidc_clients(self):
try:
return self.get(f"{OIDC_API}/oidcclients").get("items", [])
except ApiError as e:
if e.code == 404: # CRD not installed: no clients, gates unaffected
return []
raise
def delete(self, obj):
try:
http("DELETE", K8S_API + object_path(obj), headers=self._h(), cafile=K8S_CA_FILE)
@@ -542,111 +447,42 @@ def ensure_access(ak, gate, target):
ak.req("DELETE", f"policies/expression/{stale['pk']}/")
def oidc_refs(ak):
"""Signing key and scope mappings every OIDC client uses; matched here, not
by server-side filters, so an ignored filter cannot pick another object."""
keys = [k["pk"] for k in ak.list("crypto/certificatekeypairs/") if k.get("name") == SIGNING_KEY]
if len(keys) != 1:
raise ApiError(f"expected one certificate {SIGNING_KEY!r}, found {len(keys)}")
scopes = {m.get("managed"): m["pk"] for m in ak.list("propertymappings/provider/scope/")
if m.get("managed") in OIDC_SCOPES}
missing = [s for s in OIDC_SCOPES if s not in scopes]
if missing:
raise ApiError(f"scope mappings not found: {missing}")
return {"signing_key": keys[0], "scopes": list(scopes.values())}
def ensure_oidc(ak, spec, flows, refs):
name, slug = spec["name"], spec["slug"]
if spec["group"] and ak.one("core/groups/", name=spec["group"]) is None:
raise GateError(f"group {spec['group']!r} not found in Authentik")
app = get_application(ak, slug)
if app is not None and app.get("meta_publisher") != MANAGER:
raise GateError(f"application {slug} exists and is not managed by {MANAGER}")
want = oauth2_body(spec, flows, refs)
provider = next((p for p in ak.list("providers/oauth2/") if p.get("name", "").lower() == name), None)
if app is not None and (provider is None or app.get("provider") != provider["pk"]):
# E.g. a gate's application: never re-point it at an OAuth2 provider.
raise GateError(f"application {slug} exists without OAuth2 provider {name}")
if provider is None:
# No client_secret: Authentik generates it (generate_client_secret).
provider = ak.req("POST", "providers/oauth2/", want)
LOG.info("%s: created OAuth2 provider %s", name, provider["pk"])
elif provider.get("assigned_application_slug") not in (None, slug) or \
(app is not None and app.get("provider") != provider["pk"]):
raise GateError(f"OAuth2 provider {name} belongs to another application")
else:
current = dict(provider, property_mappings=sorted(provider.get("property_mappings") or []))
if drift(current, want):
ak.req("PATCH", f"providers/oauth2/{provider['pk']}/", drift(current, want))
LOG.info("%s: updated OAuth2 provider %s", name, sorted(drift(current, want)))
want_app = {"name": f"{slug} (oidc)", "slug": slug, "provider": provider["pk"],
"meta_launch_url": spec["launch_url"], "meta_publisher": MANAGER,
"meta_description": f"OIDCClient {spec['namespace']}/{spec['resource']}"}
if app is None:
app = ak.req("POST", "core/applications/", want_app)
LOG.info("%s: created application %s", name, slug)
elif drift(app, want_app):
ak.req("PATCH", f"core/applications/{slug}/", drift(app, want_app))
LOG.info("%s: updated application %s", name, sorted(drift(app, want_app)))
ensure_access(ak, spec, app["pbm_uuid"])
PROVIDER_PATHS = ("providers/proxy/", "providers/oauth2/")
def policy_name(app):
return f"oidc-{app['slug']}" if app.get("_provider_path") == "providers/oauth2/" else app["slug"]
def remove_gate(ak, app):
"""Delete an owned gate or OIDC client: the application (bindings cascade;
its provider is PROTECTed while referenced), then the provider (a proxy
provider leaves the embedded outpost with it), the policy. Ownership is
found through the application, so until it is gone a failed step is
retried on the next pass."""
"""Delete an owned gate: the application (bindings cascade; its provider is
PROTECTed while referenced), then the provider (which leaves the embedded
outpost with it), the policy. Ownership is found through the application,
so until it is gone a failed step is retried on the next pass."""
name, provider = app["slug"], app.get("provider")
path = app.get("_provider_path", "providers/proxy/")
if path == "providers/proxy/":
remove_dedicated_outposts(ak, name, provider)
remove_dedicated_outposts(ak, name, provider)
ak.req("DELETE", f"core/applications/{name}/")
if provider:
ak.req("DELETE", f"{path}{provider}/")
policy = ak.one("policies/expression/", name=policy_name(app))
ak.req("DELETE", f"providers/proxy/{provider}/")
policy = ak.one("policies/expression/", name=name)
if policy is not None:
ak.req("DELETE", f"policies/expression/{policy['pk']}/")
LOG.info("%s: removed %s from Authentik", name,
"OIDC client" if path == "providers/oauth2/" else "gate")
LOG.info("%s: removed gate from Authentik", name)
def owned_applications(ak):
"""Applications this controller owns, found through the (unfiltered) proxy
and OAuth2 provider lists and confirmed one by one."""
for path in PROVIDER_PATHS:
for p in ak.list(path):
slug = p.get("assigned_application_slug")
if not slug:
continue
app = get_application(ak, slug)
if app and app.get("meta_publisher") == MANAGER and app.get("provider") == p["pk"]:
yield dict(app, _provider_path=path)
provider list and confirmed one by one."""
for p in ak.list("providers/proxy/"):
slug = p.get("assigned_application_slug")
if not slug:
continue
app = get_application(ak, slug)
if app and app.get("meta_publisher") == MANAGER and app.get("provider") == p["pk"]:
yield app
# --- reconcile --------------------------------------------------------------
def reconcile(k8s=None, ak=None):
k8s = k8s or K8s()
if ak is None:
with open(AK_TOKEN_FILE) as f:
ak = Authentik(f.read().strip())
def reconcile():
k8s = K8s()
with open(AK_TOKEN_FILE) as f:
ak = Authentik(f.read().strip())
ingresses = k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", [])
sources = [i for i in ingresses if is_gated(i)]
clients = k8s.oidc_clients()
sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) if is_gated(i)]
shared = shared_hosts(sources)
flows = {f["slug"]: f["pk"] for f in ak.list("flows/instances/")}
ensure_embedded_config(ak)
@@ -690,44 +526,6 @@ def reconcile(k8s=None, ak=None):
held.add(gate_name(ing))
failed += 1
gate_names = {gate_name(i) for i in sources}
keep_clients = set() # OIDC slugs whose Authentik side should exist
slugs = [oidc_slug(i) for i in clients]
refs = None
for cr in sorted(clients, key=oidc_slug):
ref, slug = f'{cr["metadata"]["namespace"]}/{cr["metadata"]["name"]}', oidc_slug(cr)
ok, message = False, ""
try:
spec = oidc_spec(cr)
if slugs.count(slug) > 1:
raise GateError(f"slug {slug} is claimed by more than one OIDCClient")
if slug in gate_names:
raise GateError(f"slug {slug} is the name of a gate")
for flow in (spec["authorization_flow"], INVALIDATION_FLOW):
if flow not in flows:
raise GateError(f"flow {flow!r} not found in Authentik")
keep_clients.add(slug)
refs = refs or oidc_refs(ak)
ensure_oidc(ak, spec, flows, refs)
ok, message = True, f"provider {spec['name']}, application {slug}"
except GateError as e:
LOG.error("OIDCClient %s: %s", ref, e)
failed += 1
keep_clients.discard(slug)
message = str(e)
except Exception as e:
LOG.error("OIDCClient %s: %s", ref, e)
keep_clients.add(slug)
failed += 1
message = f"transient: {e}"
try:
path = f'{OIDC_API}/namespaces/{cr["metadata"]["namespace"]}/oidcclients/{cr["metadata"]["name"]}'
status = oidc_status(cr, ok, message)
if status != cr.get("status"):
k8s.patch_status(path, status)
except ApiError as e:
LOG.error("OIDCClient %s: status: %s", ref, e)
removed = 0
for obj in managed_objects(k8s):
key = (obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"])
@@ -738,13 +536,11 @@ def reconcile(k8s=None, ak=None):
removed += 1
for app in list(owned_applications(ak)):
keep = keep_clients if app["_provider_path"] == "providers/oauth2/" else gates
if app["slug"] not in keep:
if app["slug"] not in gates:
remove_gate(ak, app)
removed += 1
LOG.info("reconciled: %d gates, %d OIDC clients, %d failed, %d objects removed",
len(sources), len(clients), failed, removed)
LOG.info("reconciled: %d gates, %d failed, %d objects removed", len(sources), failed, removed)
def main():
@@ -176,10 +176,8 @@ class FakeAuthentik:
"""In-memory stand-in for the endpoints the controller uses; records calls."""
def __init__(self):
self.db = {"providers/proxy/": {}, "providers/oauth2/": {}, "core/applications/": {},
"outposts/instances/": {}, "policies/bindings/": {}, "policies/expression/": {},
"core/groups/": {}, "crypto/certificatekeypairs/": {},
"propertymappings/provider/scope/": {}}
self.db = {"providers/proxy/": {}, "core/applications/": {}, "outposts/instances/": {},
"policies/bindings/": {}, "policies/expression/": {}, "core/groups/": {}}
self.calls = []
self.next = 1
@@ -196,9 +194,6 @@ class FakeAuthentik:
return coll, rest or None
raise AssertionError(path)
def _provider(self, pk):
return self.db["providers/proxy/"].get(pk) or self.db["providers/oauth2/"][pk]
def _find(self, coll, key):
if coll == "core/applications/":
return next((a for a in self.db[coll].values() if a["slug"] == key), None)
@@ -231,7 +226,7 @@ class FakeAuthentik:
obj = self.add(coll, dict(body))
if coll == "core/applications/":
obj["pbm_uuid"] = f"pbm-{obj['pk']}"
self._provider(obj["provider"])["assigned_application_slug"] = obj["slug"]
self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = obj["slug"]
return copy.deepcopy(obj)
obj = self._find(coll, key)
if method == "PATCH":
@@ -244,7 +239,7 @@ class FakeAuthentik:
if method == "DELETE":
del self.db[coll][obj["pk"]]
if coll == "core/applications/":
self._provider(obj["provider"])["assigned_application_slug"] = None
self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = None
for b in [b for b in self.db["policies/bindings/"].values() if b["target"] == obj["pbm_uuid"]]:
del self.db["policies/bindings/"][b["pk"]]
return None
@@ -387,234 +382,5 @@ class Reconcile(unittest.TestCase):
self.assertEqual(self.ak.get(f"core/applications/{self.g['name']}/")["provider"], app["provider"])
YOUTRACK = {
"metadata": {"name": "youtrack", "namespace": "youtrack", "generation": 3},
"spec": {
"launchURL": "https://youtrack.renner.uno",
"group": "YouTrack Users",
"redirectURIs": [{"matchingMode": "regex", "url": r"^https://youtrack\.renner\.uno/.*$"}],
},
}
class OidcSpec(unittest.TestCase):
def test_youtrack(self):
s = c.oidc_spec(YOUTRACK)
self.assertEqual((s["name"], s["slug"], s["launch_url"], s["group"]),
("oidc-youtrack", "youtrack", "https://youtrack.renner.uno", "YouTrack Users"))
self.assertEqual(s["redirect_uris"],
[{"matching_mode": "regex", "url": r"^https://youtrack\.renner\.uno/.*$",
"redirect_uri_type": "authorization"}])
def test_slug_overrides_name(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["slug"] = "yt"
self.assertEqual(c.oidc_spec(cr)["slug"], "yt")
def test_strict_is_default(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["redirectURIs"] = [{"url": "https://youtrack.renner.uno/cb"}]
self.assertEqual(c.oidc_spec(cr)["redirect_uris"][0]["matching_mode"], "strict")
def test_rejects_bad_slug(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["slug"] = "You Track"
with self.assertRaises(c.GateError):
c.oidc_spec(cr)
def test_rejects_missing_redirects(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["redirectURIs"] = []
with self.assertRaises(c.GateError):
c.oidc_spec(cr)
def test_rejects_bad_regex(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["redirectURIs"] = [{"matchingMode": "regex", "url": "("}]
with self.assertRaises(c.GateError):
c.oidc_spec(cr)
def test_status(self):
st = c.oidc_status(YOUTRACK, True, "ok")
self.assertEqual(st["issuer"], c.AK_BROWSER_URL + "application/o/youtrack/")
self.assertEqual((st["conditions"][0]["status"], st["observedGeneration"]), ("True", 3))
cr = dict(YOUTRACK, status=st)
self.assertEqual(c.oidc_status(cr, True, "ok")["conditions"][0]["lastTransitionTime"],
st["conditions"][0]["lastTransitionTime"])
self.assertNotIn("issuer", c.oidc_status(cr, False, "group missing"))
class ReconcileOidc(unittest.TestCase):
def setUp(self):
self.ak = FakeAuthentik()
self.ak.add("crypto/certificatekeypairs/", {"name": c.SIGNING_KEY, "pk": "key-1"})
self.ak.add("crypto/certificatekeypairs/", {"name": "other", "pk": "key-2"})
for i, scope in enumerate(c.OIDC_SCOPES):
self.ak.add("propertymappings/provider/scope/", {"pk": f"m{i}", "managed": scope})
self.ak.add("propertymappings/provider/scope/", {"pk": "m9", "managed": "goauthentik.io/x"})
self.ak.add("core/groups/", {"name": "YouTrack Users"})
self.s = c.oidc_spec(YOUTRACK)
def run_once(self):
c.ensure_oidc(self.ak, self.s, FLOWS, c.oidc_refs(self.ak))
def test_creates_owned_client(self):
self.run_once()
app = self.ak.get("core/applications/youtrack/")
self.assertEqual(app["meta_publisher"], c.MANAGER)
p = self.ak.db["providers/oauth2/"][app["provider"]]
self.assertEqual((p["name"], p["client_id"], p["client_type"], p["signing_key"]),
("oidc-youtrack", "youtrack", "confidential", "key-1"))
self.assertNotIn("client_secret", p)
self.assertEqual(p["grant_types"], ["authorization_code", "refresh_token"])
self.assertEqual(p["property_mappings"], ["m0", "m1", "m2"])
policy = self.ak.one("policies/expression/", name="oidc-youtrack")
self.assertIn("'YouTrack Users'", policy["expression"])
self.assertEqual([b["target"] for b in self.ak.list("policies/bindings/")], [app["pbm_uuid"]])
def test_second_pass_is_a_noop(self):
self.run_once()
before = len(self.ak.calls)
self.run_once()
self.assertEqual(self.ak.calls[before:], [])
def test_missing_group_is_a_gate_error(self):
self.ak.db["core/groups/"].clear()
with self.assertRaises(c.GateError):
self.run_once()
self.assertEqual(self.ak.list("providers/oauth2/"), [])
def test_refuses_unowned_application(self):
self.ak.add("core/applications/", {"slug": "youtrack", "meta_publisher": "", "provider": None})
with self.assertRaises(c.GateError):
self.run_once()
def test_redirect_change_patches_provider(self):
self.run_once()
self.s = dict(self.s, redirect_uris=[{"matching_mode": "strict", "url": "https://youtrack.renner.uno/cb",
"redirect_uri_type": "authorization"}])
self.run_once()
p = next(iter(self.ak.db["providers/oauth2/"].values()))
self.assertEqual(p["redirect_uris"], self.s["redirect_uris"])
def test_missing_signing_key_is_transient(self):
self.ak.db["crypto/certificatekeypairs/"].clear()
with self.assertRaises(c.ApiError):
c.oidc_refs(self.ak)
def test_ownership_scan_and_removal(self):
foreign = self.ak.add("providers/oauth2/", {"name": "gitea", "assigned_application_slug": "gitea"})
self.ak.add("core/applications/", {"slug": "gitea", "meta_publisher": "", "provider": foreign["pk"]})
self.run_once()
owned = list(c.owned_applications(self.ak))
self.assertEqual([a["slug"] for a in owned], ["youtrack"])
c.remove_gate(self.ak, owned[0])
self.assertIsNone(self.ak.get("core/applications/youtrack/"))
self.assertEqual([p["name"] for p in self.ak.list("providers/oauth2/")], ["gitea"])
self.assertIsNone(self.ak.one("policies/expression/", name="oidc-youtrack"))
def test_refuses_application_without_its_provider(self):
gate_provider = self.ak.add("providers/proxy/", {"name": "g", "assigned_application_slug": "youtrack"})
self.ak.add("core/applications/", {"slug": "youtrack", "meta_publisher": c.MANAGER,
"provider": gate_provider["pk"], "pbm_uuid": "pbm-x"})
with self.assertRaises(c.GateError):
self.run_once()
self.assertEqual(self.ak.list("providers/oauth2/"), [])
self.assertEqual(self.ak.get("core/applications/youtrack/")["provider"], gate_provider["pk"])
class FakeK8s:
def __init__(self, ingresses=(), clients=(), services=None):
self.ingresses, self.clients = list(ingresses), list(clients)
self.services = services or {}
self.status, self.applied = {}, []
def get(self, path):
if path == "/apis/networking.k8s.io/v1/ingresses":
return {"items": copy.deepcopy(self.ingresses)}
if "labelSelector=" in path:
return {"items": []}
if path.startswith("/api/v1/namespaces/") and "/services/" in path:
return copy.deepcopy(self.services[path.rsplit("/", 1)[1]])
raise AssertionError(path)
def oidc_clients(self):
return copy.deepcopy(self.clients)
def apply(self, obj):
self.applied.append(obj)
def patch_status(self, path, status):
self.status[path.rsplit("/", 1)[1]] = status
for cr in self.clients:
if cr["metadata"]["name"] == path.rsplit("/", 1)[1]:
cr["status"] = status
class ReconcileLoop(unittest.TestCase):
def setUp(self):
self.ak = FakeAuthentik()
self.ak.db["flows/instances/"] = {}
for slug, pk in FLOWS.items():
self.ak.add("flows/instances/", {"slug": slug, "pk": pk})
self.ak.add("outposts/instances/", {"name": "authentik Embedded Outpost", "managed": c.EMBEDDED_OUTPOST,
"providers": [], "config": {}})
self.ak.add("crypto/certificatekeypairs/", {"name": c.SIGNING_KEY, "pk": "key-1"})
for i, scope in enumerate(c.OIDC_SCOPES):
self.ak.add("propertymappings/provider/scope/", {"pk": f"m{i}", "managed": scope})
self.ak.add("core/groups/", {"name": "YouTrack Users"})
def client(self, name="youtrack", **spec):
cr = copy.deepcopy(YOUTRACK)
cr["metadata"]["name"] = name
cr["spec"].update(spec)
return cr
def test_creates_then_quiet(self):
k8s = FakeK8s(clients=[self.client()])
c.reconcile(k8s, self.ak)
self.assertEqual(k8s.status["youtrack"]["conditions"][0]["status"], "True")
self.assertIsNotNone(self.ak.get("core/applications/youtrack/"))
before, k8s.status = len(self.ak.calls), {}
c.reconcile(k8s, self.ak)
self.assertEqual(self.ak.calls[before:], [])
self.assertEqual(k8s.status, {}) # unchanged status is not re-written
def test_duplicate_slug_withholds_both(self):
k8s = FakeK8s(clients=[self.client(), self.client("other", slug="youtrack")])
c.reconcile(k8s, self.ak)
self.assertIsNone(self.ak.get("core/applications/youtrack/"))
self.assertEqual({k: v["conditions"][0]["status"] for k, v in k8s.status.items()},
{"youtrack": "False", "other": "False"})
def test_transient_error_keeps_client(self):
k8s = FakeK8s(clients=[self.client()])
c.reconcile(k8s, self.ak)
self.ak.db["crypto/certificatekeypairs/"].clear()
c.reconcile(k8s, self.ak)
self.assertIsNotNone(self.ak.get("core/applications/youtrack/"))
self.assertIn("transient", k8s.status["youtrack"]["conditions"][0]["message"])
def test_sweeps_client_whose_resource_is_gone(self):
k8s = FakeK8s(clients=[self.client()])
c.reconcile(k8s, self.ak)
k8s.clients = []
c.reconcile(k8s, self.ak)
self.assertIsNone(self.ak.get("core/applications/youtrack/"))
self.assertEqual(self.ak.list("providers/oauth2/"), [])
def test_slug_equal_to_gate_name_leaves_gate_alone(self):
k8s = FakeK8s(ingresses=[LLAMA], services={"llama-server": SERVICE})
c.reconcile(k8s, self.ak)
gate = c.gate_name(LLAMA)
app = self.ak.get(f"core/applications/{gate}/")
k8s.clients = [self.client("evil", slug=gate)]
c.reconcile(k8s, self.ak)
self.assertEqual(self.ak.get(f"core/applications/{gate}/")["provider"], app["provider"])
self.assertEqual(self.ak.list("providers/oauth2/"), [])
self.assertEqual(k8s.status["evil"]["conditions"][0]["status"], "False")
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
FROM node:24-alpine@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85
# Default to the latest Claude Code. Pinning a fixed version is the wrong
# default here: old releases eventually stop working against the API, so a
+2 -2
View File
@@ -2,7 +2,7 @@
# Cloudflare. The unit tests (no tags, so no envtest) gate the image: the
# final stage copies the binary out of this stage, so a failing test fails the
# build.
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
FROM golang:1.27@sha256:2920bb124f1a2e12a1d39f0cab756ae0068441ee1da7655dec7f5e374944095b AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -10,7 +10,7 @@ COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cloudflare-lb-sync ./cmd/cloudflare-lb-sync
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329
COPY --from=build /out/cloudflare-lb-sync /cloudflare-lb-sync
USER 65532
ENTRYPOINT ["/cloudflare-lb-sync"]
+2 -2
View File
@@ -23,7 +23,7 @@
# namespace, so can0 is only reachable with network_mode: host. That is also
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
# no capabilities, so both run unprivileged.
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913 AS build
# Pins live here, in the artifact, rather than in Ansible variables.
#
@@ -56,7 +56,7 @@ RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
&& rm -rf /opt/e3oncan/.git
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
+2 -2
View File
@@ -1,7 +1,7 @@
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. The unit tests
# (no tags, so no Docker and no envtest) gate the image: the final stage copies
# the binary out of this stage, so a failing test fails the build.
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
FROM golang:1.27@sha256:2920bb124f1a2e12a1d39f0cab756ae0068441ee1da7655dec7f5e374944095b AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -9,7 +9,7 @@ COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/kuma-sync ./cmd/kuma-sync
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329
COPY --from=build /out/kuma-sync /kuma-sync
USER 65532
ENTRYPOINT ["/kuma-sync"]
+1 -1
View File
@@ -2,7 +2,7 @@
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
# public label. Standard library only, so the image is the interpreter plus one
# file; runs unprivileged as its own user.
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY controller.py /opt/controller.py
@@ -1262,12 +1262,6 @@ data:
- authentik_policies_expression.add_expressionpolicy
- authentik_policies_expression.change_expressionpolicy
- authentik_policies_expression.delete_expressionpolicy
- authentik_providers_oauth2.view_oauth2provider
- authentik_providers_oauth2.add_oauth2provider
- authentik_providers_oauth2.change_oauth2provider
- authentik_providers_oauth2.delete_oauth2provider
- authentik_providers_oauth2.view_scopemapping
- authentik_crypto.view_certificatekeypair
- authentik_core.view_group
- authentik_flows.view_flow
- authentik_outposts.view_outpostserviceconnection
+1 -7
View File
@@ -62,12 +62,6 @@ rules:
- apiGroups: ["cilium.io"]
resources: ["ciliumnetworkpolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["auth.renner.uno"]
resources: ["oidcclients"]
verbs: ["get", "list", "watch"]
- apiGroups: ["auth.renner.uno"]
resources: ["oidcclients/status"]
verbs: ["get", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
@@ -111,7 +105,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-29-1349 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-1059 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -1,99 +0,0 @@
# OIDCClient: an Authentik OAuth2/OpenID client, reconciled by authentik-gate-sync (ci/images/authentik-gate-sync).
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: oidcclients.auth.renner.uno
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
group: auth.renner.uno
names:
kind: OIDCClient
listKind: OIDCClientList
plural: oidcclients
singular: oidcclient
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- jsonPath: .status.issuer
name: Issuer
type: string
- jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
apiVersion:
type: string
kind:
type: string
metadata:
type: object
spec:
type: object
required: [launchURL, redirectURIs]
properties:
slug:
description: Application slug and client ID; issuer is /application/o/<slug>/. Default metadata.name (then checked by the controller only).
type: string
maxLength: 50
pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
launchURL:
description: The app's URL, shown in the Authentik library.
type: string
pattern: ^https?://
redirectURIs:
type: array
minItems: 1
items:
type: object
required: [url]
properties:
url:
type: string
matchingMode:
type: string
enum: [strict, regex]
default: strict
group:
description: Authentik group whose members may sign in (managed in Authentik); none = any user.
type: string
authorizationFlow:
description: Authorization flow slug; default default-provider-authorization-implicit-consent.
type: string
status:
type: object
properties:
observedGeneration:
type: integer
format: int64
issuer:
type: string
conditions:
type: array
items:
type: object
required: [type, status]
properties:
type:
type: string
status:
type: string
reason:
type: string
message:
type: string
observedGeneration:
type: integer
format: int64
lastTransitionTime:
type: string
format: date-time
@@ -1,4 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- crd-oidcclients.yaml
@@ -27,7 +27,6 @@ resources:
- admission-policies/
- kuma-sync-crds/
- cloudflare-lb-sync-crds/
- authentik-gate-sync-crds/
# The OpenBao ClusterSecretStore lives in post-crds/ (needs ESO's CRDs and
# an initialized OpenBao; see kubernetes/MIGRATION-sops-openbao.md).
# cnpg-barman-plugin/ lives in its own Flux Kustomization